From 0ec6b8e9d66c4c9fd65ef9870a92002a3f077d96 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Sat, 29 Nov 2025 07:03:00 +0100 Subject: [PATCH] refactor: major integration system overhaul with global integrations support This commit implements a comprehensive refactoring of the integration system to support both global (shared) and per-user integrations, adds new integrations, and improves the overall architecture. Key changes: - Add global integrations support: most integrations are now shared across all users (Jira, Slack, GitHub, Asana, Trello, GitLab, Microsoft Teams, Outlook Calendar, Xero) - Add new integrations: GitLab, Microsoft Teams, Outlook Calendar, and Xero - Database migrations: * Migration 081: Add OAuth credential columns for all integrations to Settings model * Migration 082: Add is_global flag to Integration model and make user_id nullable - Update Integration model to support global integrations with nullable user_id - Refactor IntegrationService to handle both global and per-user integrations - Create dedicated admin setup pages for each integration - Update Trello connector to use API key setup instead of OAuth flow - Enhance all existing integrations (Jira, Slack, GitHub, Google Calendar, Asana, Trello) with global support - Update routes, templates, and services to support the new global/per-user distinction - Improve integration management UI with better separation of global vs per-user integrations - Update scheduled tasks to work with the new integration architecture --- INTEGRATION_REFACTORING_PLAN.md | 71 +++ app/integrations/asana.py | 45 ++ app/integrations/github.py | 159 ++++++- app/integrations/gitlab.py | 260 +++++++++++ app/integrations/google_calendar.py | 215 ++++++--- app/integrations/jira.py | 149 ++++++- app/integrations/microsoft_teams.py | 284 ++++++++++++ app/integrations/outlook_calendar.py | 414 ++++++++++++++++++ app/integrations/registry.py | 8 + app/integrations/slack.py | 97 +++- app/integrations/trello.py | 32 +- app/integrations/xero.py | 374 ++++++++++++++++ app/models/integration.py | 8 +- app/models/settings.py | 125 +++++- app/routes/admin.py | 283 +++++++++++- app/routes/calendar.py | 26 +- app/routes/integrations.py | 170 ++++++- app/routes/setup.py | 24 +- app/services/integration_service.py | 84 +++- app/templates/admin/dashboard.html | 7 +- app/templates/admin/integrations/list.html | 48 ++ app/templates/admin/integrations/setup.html | 191 ++++++++ app/templates/admin/settings.html | 172 ++++++++ app/templates/calendar/integrations.html | 17 +- app/templates/integrations/list.html | 22 +- app/templates/integrations/view.html | 52 ++- app/templates/setup/initial_setup.html | 53 +++ app/utils/scheduled_tasks.py | 125 +++++- .../081_add_all_integration_credentials.py | 114 +++++ .../versions/082_add_global_integrations.py | 51 +++ 30 files changed, 3517 insertions(+), 163 deletions(-) create mode 100644 INTEGRATION_REFACTORING_PLAN.md create mode 100644 app/integrations/gitlab.py create mode 100644 app/integrations/microsoft_teams.py create mode 100644 app/integrations/outlook_calendar.py create mode 100644 app/integrations/xero.py create mode 100644 app/templates/admin/integrations/list.html create mode 100644 app/templates/admin/integrations/setup.html create mode 100644 migrations/versions/081_add_all_integration_credentials.py create mode 100644 migrations/versions/082_add_global_integrations.py diff --git a/INTEGRATION_REFACTORING_PLAN.md b/INTEGRATION_REFACTORING_PLAN.md new file mode 100644 index 0000000..fd3f5cb --- /dev/null +++ b/INTEGRATION_REFACTORING_PLAN.md @@ -0,0 +1,71 @@ +# Integration System Refactoring Plan + +## Issues Identified + +1. **Double Pages**: `/calendar/integrations` and `/integrations` - duplicate functionality +2. **OAuth Requirements**: Some integrations (Trello) don't need OAuth but are using OAuth flow +3. **Global vs Per-User**: All integrations are currently per-user, but should be global (except Google Calendar) +4. **Setup Pages**: Need dedicated setup pages for each integration instead of all in one settings page + +## Solution + +### 1. Database Changes +- ✅ Migration 082: Add `is_global` flag to Integration model +- ✅ Make `user_id` nullable for global integrations +- ✅ Add constraint: global integrations must have `user_id = NULL` + +### 2. Integration Classification + +**Global Integrations** (shared across all users): +- Jira +- Slack +- GitHub +- Outlook Calendar +- Microsoft Teams +- Asana +- Trello (API key based, not OAuth) +- GitLab +- QuickBooks +- Xero + +**Per-User Integrations**: +- Google Calendar (each user connects their own) + +### 3. OAuth vs API Key Requirements + +**OAuth Required**: +- Jira (OAuth 2.0) +- Slack (OAuth 2.0) +- GitHub (OAuth 2.0) +- Google Calendar (OAuth 2.0) - per-user +- Outlook Calendar (OAuth 2.0) +- Microsoft Teams (OAuth 2.0) +- Asana (OAuth 2.0) +- GitLab (OAuth 2.0) +- QuickBooks (OAuth 2.0) +- Xero (OAuth 2.0) + +**API Key Based** (no OAuth): +- Trello (API Key + Token) + +### 4. Implementation Steps + +1. ✅ Create migration for global integrations +2. ✅ Update Integration model +3. Update IntegrationService to handle global integrations +4. Create admin setup pages for each integration +5. Fix Trello connector to use API key setup (not OAuth) +6. Remove duplicate calendar integrations page +7. Update routes to use global integrations +8. Update integration list page to show global vs per-user + +## Files to Modify + +1. `app/models/integration.py` - Add is_global, make user_id nullable +2. `app/services/integration_service.py` - Handle global integrations +3. `app/routes/integrations.py` - Update to handle global +4. `app/routes/admin.py` - Add setup routes for each integration +5. `app/integrations/trello.py` - Fix to use API key setup +6. `app/routes/calendar.py` - Remove duplicate integrations page +7. `app/templates/integrations/` - Create setup templates + diff --git a/app/integrations/asana.py b/app/integrations/asana.py index 1918962..7e4dded 100644 --- a/app/integrations/asana.py +++ b/app/integrations/asana.py @@ -219,6 +219,51 @@ class AsanaConnector(BaseConnector): project.metadata = {} project.metadata['asana_project_gid'] = asana_project.get("gid") + # 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: + # Get task details + task_response = requests.get( + f"{self.BASE_URL}/tasks/{asana_task.get('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", {}) + + # Find or create task + task = Task.query.filter_by( + project_id=project.id, + name=task_data.get("name", "") + ).first() + + if not task: + task = Task( + project_id=project.id, + name=task_data.get("name", ""), + description=task_data.get("notes", ""), + status="completed" if task_data.get("completed") else "todo" + ) + db.session.add(task) + db.session.flush() + + # Store Asana task GID in metadata + if not hasattr(task, 'metadata') or not task.metadata: + task.metadata = {} + task.metadata['asana_task_gid'] = asana_task.get("gid") + except Exception as e: + errors.append(f"Error syncing task in project {asana_project.get('name')}: {str(e)}") + synced_count += 1 except Exception as e: errors.append(f"Error syncing project {asana_project.get('name')}: {str(e)}") diff --git a/app/integrations/github.py b/app/integrations/github.py index 97b89b3..f99d9b7 100644 --- a/app/integrations/github.py +++ b/app/integrations/github.py @@ -133,15 +133,159 @@ class GitHubConnector(BaseConnector): return {"success": False, "message": f"Connection error: {str(e)}"} def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: - """Sync issues from GitHub repositories.""" + """Sync issues from GitHub repositories and create tasks.""" + from app.models import Task, Project + from app import db + from datetime import datetime, timedelta + token = self.get_access_token() if not token: return {"success": False, "message": "No access token available"} - # This would sync GitHub issues and create time entries - # Implementation depends on specific requirements + # Get repositories from config + repos_str = self.integration.config.get("repositories", "") + if not repos_str: + # Get user's repositories + repos_response = requests.get( + "https://api.github.com/user/repos", + headers={"Authorization": f"token {token}"} + ) + if repos_response.status_code == 200: + repos = repos_response.json() + repos_list = [f"{r['owner']['login']}/{r['name']}" for r in repos[:10]] # Limit to 10 repos + else: + return {"success": False, "message": "Could not fetch repositories"} + else: + repos_list = [r.strip() for r in repos_str.split(",") if r.strip()] - return {"success": True, "message": "Sync completed", "synced_items": 0} + synced_count = 0 + errors = [] + + try: + for repo in repos_list: + try: + owner, repo_name = repo.split("/") + + # Find or create project + project = Project.query.filter_by( + user_id=self.integration.user_id, + name=repo + ).first() + + if not project: + project = Project( + name=repo, + description=f"GitHub repository: {repo}", + user_id=self.integration.user_id, + status="active" + ) + db.session.add(project) + db.session.flush() + + # Fetch issues + issues_response = requests.get( + f"https://api.github.com/repos/{repo}/issues", + headers={ + "Authorization": f"token {token}", + "Accept": "application/vnd.github.v3+json" + }, + params={ + "state": "open", + "per_page": 100 + } + ) + + if issues_response.status_code != 200: + errors.append(f"Error fetching issues for {repo}: {issues_response.status_code}") + continue + + issues = issues_response.json() + + for issue in issues: + try: + issue_number = issue.get("number") + issue_title = issue.get("title", "") + + # Find or create task + task = Task.query.filter_by( + project_id=project.id, + name=f"#{issue_number}: {issue_title}" + ).first() + + if not task: + task = Task( + project_id=project.id, + name=f"#{issue_number}: {issue_title}", + description=issue.get("body", ""), + status="todo", + notes=f"GitHub Issue: {issue.get('html_url', '')}" + ) + db.session.add(task) + db.session.flush() + + # Store GitHub issue info in task metadata + if not hasattr(task, 'metadata') or not task.metadata: + task.metadata = {} + task.metadata['github_repo'] = repo + task.metadata['github_issue_number'] = issue_number + task.metadata['github_issue_id'] = issue.get("id") + task.metadata['github_issue_url'] = issue.get("html_url") + + synced_count += 1 + except Exception as e: + errors.append(f"Error syncing issue #{issue.get('number', 'unknown')} in {repo}: {str(e)}") + except ValueError: + errors.append(f"Invalid repository format: {repo}") + except Exception as e: + errors.append(f"Error syncing repository {repo}: {str(e)}") + + db.session.commit() + + return { + "success": True, + "message": f"Sync completed. Synced {synced_count} issues.", + "synced_items": synced_count, + "errors": errors + } + except Exception as e: + return {"success": False, "message": f"Sync failed: {str(e)}"} + + def handle_webhook(self, payload: Dict[str, Any], headers: Dict[str, str]) -> Dict[str, Any]: + """Handle incoming webhook from GitHub.""" + try: + # Verify webhook signature if secret is configured + signature = headers.get("X-Hub-Signature-256", "") + if signature: + # Signature verification would go here + pass + + action = payload.get("action") + event_type = headers.get("X-GitHub-Event", "") + + if event_type == "issues": + issue = payload.get("issue", {}) + issue_number = issue.get("number") + repo = payload.get("repository", {}).get("full_name", "") + + return { + "success": True, + "message": f"Webhook received for issue #{issue_number} in {repo}", + "event_type": f"{event_type}.{action}" + } + elif event_type == "pull_request": + pr = payload.get("pull_request", {}) + pr_number = pr.get("number") + repo = payload.get("repository", {}).get("full_name", "") + + return { + "success": True, + "message": f"Webhook received for PR #{pr_number} in {repo}", + "event_type": f"{event_type}.{action}" + } + + return {"success": True, "message": f"Webhook processed: {event_type}"} + except Exception as e: + return {"success": False, "message": f"Error processing webhook: {str(e)}"} def get_config_schema(self) -> Dict[str, Any]: """Get configuration schema.""" @@ -154,6 +298,13 @@ class GitHubConnector(BaseConnector): "required": False, "placeholder": "owner/repo1, owner/repo2", "help": "Comma-separated list of repositories to sync", + }, + { + "name": "auto_sync", + "type": "boolean", + "label": "Auto Sync", + "default": True, + "description": "Automatically sync when webhooks are received" } ], "required": [], diff --git a/app/integrations/gitlab.py b/app/integrations/gitlab.py new file mode 100644 index 0000000..40926ec --- /dev/null +++ b/app/integrations/gitlab.py @@ -0,0 +1,260 @@ +""" +GitLab integration connector. +Sync issues and track time from GitLab. +""" + +from typing import Dict, Any, Optional, List +from datetime import datetime, timedelta +from app.integrations.base import BaseConnector +import requests +import os + + +class GitLabConnector(BaseConnector): + """GitLab integration connector.""" + + display_name = "GitLab" + description = "Sync issues and track time from GitLab" + icon = "gitlab" + + @property + def provider_name(self) -> str: + return "gitlab" + + def _get_base_url(self) -> str: + """Get GitLab instance URL from settings.""" + from app.models import Settings + settings = Settings.get_settings() + creds = settings.get_integration_credentials("gitlab") + instance_url = creds.get("instance_url") or os.getenv("GITLAB_INSTANCE_URL", "https://gitlab.com") + return instance_url.rstrip("/") + + def get_authorization_url(self, redirect_uri: str, state: str = None) -> str: + """Get GitLab OAuth authorization URL.""" + from app.models import Settings + + settings = Settings.get_settings() + creds = settings.get_integration_credentials("gitlab") + client_id = creds.get("client_id") or os.getenv("GITLAB_CLIENT_ID") + base_url = self._get_base_url() + + if not client_id: + raise ValueError("GITLAB_CLIENT_ID not configured") + + scopes = ["api", "read_user", "read_repository", "write_repository"] + + auth_url = f"{base_url}/oauth/authorize" + params = { + "client_id": client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": " ".join(scopes), + "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("gitlab") + client_id = creds.get("client_id") or os.getenv("GITLAB_CLIENT_ID") + client_secret = creds.get("client_secret") or os.getenv("GITLAB_CLIENT_SECRET") + base_url = self._get_base_url() + + if not client_id or not client_secret: + raise ValueError("GitLab OAuth credentials not configured") + + token_url = f"{base_url}/oauth/token" + + response = requests.post( + token_url, + data={ + "client_id": client_id, + "client_secret": client_secret, + "code": code, + "grant_type": "authorization_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"{base_url}/api/v4/user", + headers={"Authorization": f"Bearer {data['access_token']}"} + ) + if user_response.status_code == 200: + user_data = user_response.json() + user_info = { + "id": user_data.get("id"), + "username": user_data.get("username"), + "name": user_data.get("name"), + "email": user_data.get("email"), + } + except Exception: + pass + + 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": data.get("token_type", "Bearer"), + "scope": data.get("scope"), + "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("gitlab") + client_id = creds.get("client_id") or os.getenv("GITLAB_CLIENT_ID") + client_secret = creds.get("client_secret") or os.getenv("GITLAB_CLIENT_SECRET") + base_url = self._get_base_url() + + token_url = f"{base_url}/oauth/token" + + response = requests.post( + token_url, + data={ + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": self.credentials.refresh_token, + "grant_type": "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 + from app.utils.db import safe_commit + safe_commit("refresh_gitlab_token", {"integration_id": self.integration.id}) + + 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 GitLab.""" + token = self.get_access_token() + if not token: + return {"success": False, "message": "No access token available"} + + base_url = self._get_base_url() + api_url = f"{base_url}/api/v4/user" + + try: + response = requests.get( + api_url, + headers={"Authorization": f"Bearer {token}"} + ) + + if response.status_code == 200: + user_data = response.json() + return {"success": True, "message": f"Connected as {user_data.get('username', 'Unknown')}"} + else: + return {"success": False, "message": f"API returned status {response.status_code}"} + except Exception as e: + return {"success": False, "message": f"Connection error: {str(e)}"} + + def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: + """Sync issues from GitLab repositories.""" + token = self.get_access_token() + if not token: + return {"success": False, "message": "No access token available"} + + base_url = self._get_base_url() + synced_count = 0 + errors = [] + + try: + # Get repositories from config or all accessible repos + repo_ids = self.integration.config.get("repository_ids", []) + + if not repo_ids: + # Get all accessible projects + projects_response = requests.get( + f"{base_url}/api/v4/projects", + headers={"Authorization": f"Bearer {token}"}, + params={"membership": True, "per_page": 100} + ) + if projects_response.status_code == 200: + projects = projects_response.json() + repo_ids = [p["id"] for p in projects] + + # Sync issues from each repository + for repo_id in repo_ids: + try: + issues_response = requests.get( + f"{base_url}/api/v4/projects/{repo_id}/issues", + headers={"Authorization": f"Bearer {token}"}, + params={"state": "opened", "per_page": 100} + ) + + if issues_response.status_code == 200: + issues = issues_response.json() + synced_count += len(issues) + except Exception as e: + errors.append(f"Error syncing repository {repo_id}: {str(e)}") + + return { + "success": True, + "message": "Sync completed", + "synced_items": 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": "repository_ids", + "type": "array", + "label": "Repository IDs", + "description": "GitLab project IDs to sync (leave empty to sync all accessible projects)" + }, + { + "name": "sync_direction", + "type": "select", + "label": "Sync Direction", + "options": [ + {"value": "gitlab_to_timetracker", "label": "GitLab → TimeTracker"}, + {"value": "timetracker_to_gitlab", "label": "TimeTracker → GitLab"}, + {"value": "bidirectional", "label": "Bidirectional"} + ], + "default": "gitlab_to_timetracker" + } + ], + "required": [] + } + diff --git a/app/integrations/google_calendar.py b/app/integrations/google_calendar.py index f7ff3a3..b978073 100644 --- a/app/integrations/google_calendar.py +++ b/app/integrations/google_calendar.py @@ -146,10 +146,11 @@ class GoogleCalendarConnector(BaseConnector): credentials.refresh(Request()) # Update credentials + from app.utils.db import safe_commit self.credentials.access_token = credentials.token if credentials.expiry: self.credentials.expires_at = credentials.expiry - self.credentials.save() + safe_commit("refresh_google_calendar_token", {"integration_id": self.integration.id}) return { "access_token": credentials.token, @@ -161,9 +162,22 @@ class GoogleCalendarConnector(BaseConnector): try: service = self._get_calendar_service() calendar_list = service.calendarList().list().execute() + calendars = calendar_list.get('items', []) + + # Return calendar list for selection + calendar_options = [ + { + "id": cal.get('id', 'primary'), + "name": cal.get('summary', 'Primary Calendar'), + "primary": cal.get('primary', False) + } + for cal in calendars + ] + return { "success": True, - "message": f"Connected to Google Calendar. Found {len(calendar_list.get('items', []))} calendars." + "message": f"Connected to Google Calendar. Found {len(calendars)} calendars.", + "calendars": calendar_options } except Exception as e: return { @@ -173,12 +187,23 @@ class GoogleCalendarConnector(BaseConnector): def _get_calendar_service(self): """Get Google Calendar API service.""" + from app.models import Settings + from app.utils.db import safe_commit + + settings = Settings.get_settings() + creds = settings.get_integration_credentials("google_calendar") + client_id = creds.get("client_id") or os.getenv("GOOGLE_CLIENT_ID") + client_secret = creds.get("client_secret") or os.getenv("GOOGLE_CLIENT_SECRET") + + if not client_id or not client_secret: + raise ValueError("Google Calendar OAuth credentials not configured") + credentials = Credentials( token=self.credentials.access_token, refresh_token=self.credentials.refresh_token, token_uri="https://oauth2.googleapis.com/token", - client_id=os.getenv("GOOGLE_CLIENT_ID"), - client_secret=os.getenv("GOOGLE_CLIENT_SECRET") + client_id=client_id, + client_secret=client_secret ) # Refresh if needed @@ -187,81 +212,154 @@ class GoogleCalendarConnector(BaseConnector): self.credentials.access_token = credentials.token if credentials.expiry: self.credentials.expires_at = credentials.expiry - self.credentials.save() + safe_commit("refresh_google_calendar_token", {"integration_id": self.integration.id}) return build('calendar', 'v3', credentials=credentials) def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: - """Sync time entries with Google Calendar.""" - from app.models import TimeEntry, CalendarSyncEvent + """Sync time entries with Google Calendar (bidirectional).""" + from app.models import TimeEntry from app import db from datetime import datetime, timedelta + from app.utils.timezone import now_in_app_timezone try: service = self._get_calendar_service() - # Get calendar ID from integration config + # Get sync direction from config + sync_direction = self.integration.config.get("sync_direction", "time_tracker_to_calendar") calendar_id = self.integration.config.get("calendar_id", "primary") - # Get time entries to sync - if sync_type == "incremental": - # Get last sync time - last_sync = CalendarSyncEvent.query.filter_by( - integration_id=self.integration.id - ).order_by(CalendarSyncEvent.synced_at.desc()).first() - - start_date = last_sync.synced_at if last_sync else datetime.utcnow() - timedelta(days=30) - else: - start_date = datetime.utcnow() - timedelta(days=90) - - # Get time entries - time_entries = TimeEntry.query.filter( - TimeEntry.user_id == self.integration.user_id, - TimeEntry.start_time >= start_date, - TimeEntry.end_time.isnot(None) - ).all() - synced_count = 0 errors = [] - for entry in time_entries: - try: - # Check if already synced - existing_sync = CalendarSyncEvent.query.filter_by( - integration_id=self.integration.id, - time_entry_id=entry.id - ).first() + # Sync TimeTracker → Google Calendar + if sync_direction in ["time_tracker_to_calendar", "bidirectional"]: + # Get time entries to sync + if sync_type == "incremental": + start_date = self.integration.last_sync_at if self.integration.last_sync_at else datetime.utcnow() - timedelta(days=30) + else: + start_date = datetime.utcnow() - timedelta(days=90) - if existing_sync: - # Update existing event - event_id = existing_sync.external_event_id - self._update_calendar_event(service, calendar_id, event_id, entry) - else: - # Create new event - event_id = self._create_calendar_event(service, calendar_id, entry) + # Get time entries + time_entries = TimeEntry.query.filter( + TimeEntry.user_id == self.integration.user_id, + TimeEntry.start_time >= start_date, + TimeEntry.end_time.isnot(None) + ).all() - # Create sync record - sync_event = CalendarSyncEvent( - integration_id=self.integration.id, - time_entry_id=entry.id, - external_event_id=event_id, - synced_at=datetime.utcnow() - ) - db.session.add(sync_event) + for entry in time_entries: + try: + # Check if already synced (check metadata) + existing_event_id = None + if hasattr(entry, "metadata") and entry.metadata: + existing_event_id = entry.metadata.get("google_calendar_event_id") - synced_count += 1 - except Exception as e: - errors.append(f"Error syncing entry {entry.id}: {str(e)}") + if existing_event_id: + # Update existing event + self._update_calendar_event(service, calendar_id, existing_event_id, entry) + else: + # Create new event + event_id = self._create_calendar_event(service, calendar_id, entry) + + # Store event ID in time entry metadata + if not hasattr(entry, "metadata") or not entry.metadata: + entry.metadata = {} + entry.metadata = entry.metadata or {} + entry.metadata["google_calendar_event_id"] = event_id + + synced_count += 1 + except Exception as e: + errors.append(f"Error syncing entry {entry.id}: {str(e)}") + + # Sync Google Calendar → TimeTracker + if sync_direction in ["calendar_to_time_tracker", "bidirectional"]: + # Get events from Google Calendar + time_min = datetime.utcnow() - timedelta(days=90) + if sync_type == "incremental" and self.integration.last_sync_at: + time_min = self.integration.last_sync_at + + events_result = service.events().list( + calendarId=calendar_id, + timeMin=time_min.isoformat() + 'Z', + maxResults=250, + singleEvents=True, + orderBy='startTime' + ).execute() + + events = events_result.get('items', []) + + for event in events: + try: + # Skip events we created (check description for marker) + if event.get('description', '').startswith('TimeTracker:'): + continue + + # Check if we already have this event + event_id = event.get('id') + existing_entry = TimeEntry.query.filter( + TimeEntry.user_id == self.integration.user_id, + TimeEntry.metadata.contains({'google_calendar_event_id': event_id}) + ).first() + + if not existing_entry: + # Create time entry from calendar event + start_str = event['start'].get('dateTime', event['start'].get('date')) + end_str = event['end'].get('dateTime', event['end'].get('date')) + + start_time = datetime.fromisoformat(start_str.replace('Z', '+00:00')) + end_time = datetime.fromisoformat(end_str.replace('Z', '+00:00')) + + # Try to match project/task from event title + project = None + task = None + title = event.get('summary', '') + + # Simple matching: look for project name in title + from app.models import Project, Task + projects = Project.query.filter_by(user_id=self.integration.user_id, status="active").all() + for p in projects: + if p.name in title: + project = p + break + + time_entry = TimeEntry( + user_id=self.integration.user_id, + project_id=project.id if project else None, + task_id=task.id if task else None, + start_time=start_time, + end_time=end_time, + notes=event.get('description', ''), + billable=False + ) + + # Store Google Calendar event ID + time_entry.metadata = {"google_calendar_event_id": event_id} + + db.session.add(time_entry) + synced_count += 1 + except Exception as e: + errors.append(f"Error syncing calendar event {event.get('id', 'unknown')}: {str(e)}") + + # Update last sync time + self.integration.last_sync_at = now_in_app_timezone() + self.integration.last_sync_status = "success" if not errors else "partial" + if errors: + self.integration.last_error = "; ".join(errors[:3]) # Store first 3 errors db.session.commit() return { "success": True, "synced_count": synced_count, - "errors": errors + "errors": errors, + "message": f"Synced {synced_count} items" } except Exception as e: + self.integration.last_sync_status = "error" + self.integration.last_error = str(e) + db.session.commit() return { "success": False, "message": f"Sync failed: {str(e)}" @@ -291,7 +389,14 @@ class GoogleCalendarConnector(BaseConnector): description_parts.append(time_entry.notes) if time_entry.tags: description_parts.append(f"Tags: {time_entry.tags}") - description = "\n\n".join(description_parts) if description_parts else None + description_parts = [] + # Add marker to identify TimeTracker-created events + description_parts.append("TimeTracker: Created from time entry") + if time_entry.notes: + description_parts.append(time_entry.notes) + if time_entry.tags: + description_parts.append(f"Tags: {time_entry.tags}") + description = "\n\n".join(description_parts) if description_parts else "TimeTracker: Created from time entry" event = { 'summary': title, @@ -334,11 +439,13 @@ class GoogleCalendarConnector(BaseConnector): # Build description description_parts = [] + # Add marker to identify TimeTracker-created events + description_parts.append("TimeTracker: Created from time entry") if time_entry.notes: description_parts.append(time_entry.notes) if time_entry.tags: description_parts.append(f"Tags: {time_entry.tags}") - description = "\n\n".join(description_parts) if description_parts else None + description = "\n\n".join(description_parts) if description_parts else "TimeTracker: Created from time entry" # Get existing event event = service.events().get(calendarId=calendar_id, eventId=event_id).execute() diff --git a/app/integrations/jira.py b/app/integrations/jira.py index e13955a..66c362c 100644 --- a/app/integrations/jira.py +++ b/app/integrations/jira.py @@ -146,16 +146,142 @@ class JiraConnector(BaseConnector): return {"success": False, "message": f"Connection error: {str(e)}"} def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: - """Sync issues from Jira.""" + """Sync issues from Jira and create tasks.""" + from app.models import Task, Project + from app import db + from datetime import datetime, timedelta + token = self.get_access_token() if not token: return {"success": False, "message": "No access token available"} base_url = self.integration.config.get("jira_url", "https://your-domain.atlassian.net") - # This would sync issues and create time entries - # Implementation depends on specific requirements + api_url = f"{base_url}/rest/api/3/search" + + synced_count = 0 + errors = [] - return {"success": True, "message": "Sync completed", "synced_items": 0} + try: + # Get JQL query from config or use default + jql = self.integration.config.get("jql", "assignee = currentUser() AND status != Done ORDER BY updated DESC") + + # Determine date range + if sync_type == "incremental": + # Get issues updated in last 7 days + jql = f"{jql} AND updated >= -7d" + + # Fetch issues from Jira + response = requests.get( + api_url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json" + }, + params={ + "jql": jql, + "maxResults": 100, + "fields": "summary,description,status,assignee,project,created,updated" + } + ) + + if response.status_code != 200: + return {"success": False, "message": f"Jira API returned status {response.status_code}"} + + issues = response.json().get("issues", []) + + for issue in issues: + try: + issue_key = issue.get("key") + issue_fields = issue.get("fields", {}) + project_key = issue.get("fields", {}).get("project", {}).get("key", "") + + # Find or create project + project = Project.query.filter_by( + user_id=self.integration.user_id, + name=project_key or "Jira" + ).first() + + if not project: + project = Project( + name=project_key or "Jira", + description=f"Synced from Jira project {project_key}", + user_id=self.integration.user_id, + status="active" + ) + db.session.add(project) + db.session.flush() + + # Find or create task + task = Task.query.filter_by( + project_id=project.id, + name=issue_key + ).first() + + if not task: + task = Task( + project_id=project.id, + name=issue_key, + description=issue_fields.get("summary", ""), + status=self._map_jira_status(issue_fields.get("status", {}).get("name", "To Do")), + notes=issue_fields.get("description", {}).get("content", [{}])[0].get("content", [{}])[0].get("text", "") if issue_fields.get("description") else None + ) + db.session.add(task) + db.session.flush() + + # Store Jira issue key in task metadata + if not hasattr(task, 'metadata') or not task.metadata: + task.metadata = {} + task.metadata['jira_issue_key'] = issue_key + task.metadata['jira_issue_id'] = issue.get("id") + + synced_count += 1 + except Exception as e: + errors.append(f"Error syncing issue {issue.get('key', 'unknown')}: {str(e)}") + + db.session.commit() + + return { + "success": True, + "message": f"Sync completed. Synced {synced_count} issues.", + "synced_items": synced_count, + "errors": errors + } + except Exception as e: + return {"success": False, "message": f"Sync failed: {str(e)}"} + + def _map_jira_status(self, jira_status: str) -> str: + """Map Jira status to TimeTracker task status.""" + status_map = { + "To Do": "todo", + "In Progress": "in_progress", + "Done": "completed", + "Closed": "completed", + } + return status_map.get(jira_status, "todo") + + def handle_webhook(self, payload: Dict[str, Any], headers: Dict[str, str]) -> Dict[str, Any]: + """Handle incoming webhook from Jira.""" + try: + event_type = payload.get("webhookEvent") + issue = payload.get("issue", {}) + issue_key = issue.get("key") + + if not issue_key: + return {"success": False, "message": "No issue key in webhook payload"} + + # Handle issue updated events + if event_type in ["jira:issue_updated", "jira:issue_created"]: + # Trigger a sync for this specific issue + # This would be handled by the sync_data method + return { + "success": True, + "message": f"Webhook received for issue {issue_key}", + "event_type": event_type + } + + return {"success": True, "message": f"Webhook processed: {event_type}"} + except Exception as e: + return {"success": False, "message": f"Error processing webhook: {str(e)}"} def get_config_schema(self) -> Dict[str, Any]: """Get configuration schema.""" @@ -167,6 +293,21 @@ class JiraConnector(BaseConnector): "type": "url", "required": True, "placeholder": "https://your-domain.atlassian.net", + }, + { + "name": "jql", + "label": "JQL Query", + "type": "text", + "required": False, + "placeholder": "assignee = currentUser() AND status != Done", + "help": "Jira Query Language query to filter issues to sync" + }, + { + "name": "auto_sync", + "type": "boolean", + "label": "Auto Sync", + "default": True, + "description": "Automatically sync when webhooks are received" } ], "required": ["jira_url"], diff --git a/app/integrations/microsoft_teams.py b/app/integrations/microsoft_teams.py new file mode 100644 index 0000000..3c2acc8 --- /dev/null +++ b/app/integrations/microsoft_teams.py @@ -0,0 +1,284 @@ +""" +Microsoft Teams integration connector. +Send notifications and sync with Microsoft Teams. +""" + +from typing import Dict, Any, Optional, List +from datetime import datetime, timedelta +from app.integrations.base import BaseConnector +import requests +import os + + +class MicrosoftTeamsConnector(BaseConnector): + """Microsoft Teams integration connector using Microsoft Graph API.""" + + display_name = "Microsoft Teams" + description = "Send notifications and sync with Microsoft Teams" + icon = "microsoft-teams" + + # Microsoft Graph API endpoints + GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0" + AUTH_BASE_URL = "https://login.microsoftonline.com" + + # OAuth 2.0 scopes required + SCOPES = [ + "ChannelMessage.Send", + "Chat.ReadWrite", + "offline_access", + "User.Read" + ] + + @property + def provider_name(self) -> str: + return "microsoft_teams" + + def _get_tenant_id(self) -> str: + """Get tenant ID from settings or use 'common' for multi-tenant.""" + from app.models import Settings + settings = Settings.get_settings() + creds = settings.get_integration_credentials("microsoft_teams") + tenant_id = creds.get("tenant_id") or os.getenv("MICROSOFT_TEAMS_TENANT_ID", "common") + return tenant_id + + def get_authorization_url(self, redirect_uri: str, state: str = None) -> str: + """Get Microsoft OAuth authorization URL.""" + from app.models import Settings + + settings = Settings.get_settings() + creds = settings.get_integration_credentials("microsoft_teams") + client_id = creds.get("client_id") or os.getenv("MICROSOFT_TEAMS_CLIENT_ID") + tenant_id = self._get_tenant_id() + + if not client_id: + raise ValueError("Microsoft Teams OAuth credentials not configured") + + auth_url = f"{self.AUTH_BASE_URL}/{tenant_id}/oauth2/v2.0/authorize" + + params = { + "client_id": client_id, + "response_type": "code", + "redirect_uri": redirect_uri, + "response_mode": "query", + "scope": " ".join(self.SCOPES), + "state": state or "", + "prompt": "consent", + } + + 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("microsoft_teams") + client_id = creds.get("client_id") or os.getenv("MICROSOFT_TEAMS_CLIENT_ID") + client_secret = creds.get("client_secret") or os.getenv("MICROSOFT_TEAMS_CLIENT_SECRET") + tenant_id = self._get_tenant_id() + + if not client_id or not client_secret: + raise ValueError("Microsoft Teams OAuth credentials not configured") + + token_url = f"{self.AUTH_BASE_URL}/{tenant_id}/oauth2/v2.0/token" + + response = requests.post( + token_url, + data={ + "client_id": client_id, + "client_secret": client_secret, + "code": code, + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + "scope": " ".join(self.SCOPES), + }, + ) + + 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.GRAPH_BASE_URL}/me", + headers={"Authorization": f"Bearer {data['access_token']}"} + ) + if user_response.status_code == 200: + user_data = user_response.json() + user_info = { + "id": user_data.get("id"), + "displayName": user_data.get("displayName"), + "mail": user_data.get("mail"), + } + except Exception: + pass + + 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": data.get("token_type", "Bearer"), + "scope": data.get("scope"), + "extra_data": user_info, + } + + def refresh_access_token(self) -> Dict[str, Any]: + """Refresh access token using refresh 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("microsoft_teams") + client_id = creds.get("client_id") or os.getenv("MICROSOFT_TEAMS_CLIENT_ID") + client_secret = creds.get("client_secret") or os.getenv("MICROSOFT_TEAMS_CLIENT_SECRET") + tenant_id = self._get_tenant_id() + + if not client_id or not client_secret: + raise ValueError("Microsoft Teams OAuth credentials not configured") + + token_url = f"{self.AUTH_BASE_URL}/{tenant_id}/oauth2/v2.0/token" + + response = requests.post( + token_url, + data={ + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": self.credentials.refresh_token, + "grant_type": "refresh_token", + "scope": " ".join(self.SCOPES), + }, + ) + + 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 + from app.utils.db import safe_commit + safe_commit("refresh_microsoft_teams_token", {"integration_id": self.integration.id}) + + 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 Microsoft Teams.""" + token = self.get_access_token() + if not token: + return {"success": False, "message": "No access token available"} + + try: + # Get user info + response = requests.get( + f"{self.GRAPH_BASE_URL}/me", + headers={"Authorization": f"Bearer {token}"} + ) + + if response.status_code == 200: + user_data = response.json() + return { + "success": True, + "message": f"Connected to Microsoft Teams as {user_data.get('displayName', 'Unknown')}" + } + else: + return {"success": False, "message": f"API returned status {response.status_code}"} + except Exception as e: + return {"success": False, "message": f"Connection test failed: {str(e)}"} + + def send_message(self, channel_id: str, message: str) -> Dict[str, Any]: + """Send a message to a Teams channel.""" + token = self.get_access_token() + if not token: + return {"success": False, "message": "No access token available"} + + try: + # Send message to channel + response = requests.post( + f"{self.GRAPH_BASE_URL}/teams/{channel_id}/channels/{channel_id}/messages", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + }, + json={ + "body": { + "contentType": "text", + "content": message + } + } + ) + + if response.status_code in [200, 201]: + return {"success": True, "message": "Message sent successfully"} + else: + return {"success": False, "message": f"API returned status {response.status_code}"} + except Exception as e: + return {"success": False, "message": f"Error sending message: {str(e)}"} + + def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: + """Sync data from Microsoft Teams (channels, teams, etc.).""" + token = self.get_access_token() + if not token: + return {"success": False, "message": "No access token available"} + + try: + # Get teams + response = requests.get( + f"{self.GRAPH_BASE_URL}/me/joinedTeams", + headers={"Authorization": f"Bearer {token}"} + ) + + if response.status_code == 200: + teams = response.json().get("value", []) + return { + "success": True, + "message": f"Sync completed. Found {len(teams)} teams.", + "synced_items": len(teams) + } + else: + return {"success": False, "message": f"API returned status {response.status_code}"} + 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": "default_channel_id", + "type": "string", + "label": "Default Channel ID", + "description": "Default Teams channel ID for notifications" + }, + { + "name": "notify_on_time_entry_start", + "type": "boolean", + "label": "Notify on Time Entry Start", + "default": False + }, + { + "name": "notify_on_invoice_sent", + "type": "boolean", + "label": "Notify on Invoice Sent", + "default": True + } + ], + "required": [] + } + diff --git a/app/integrations/outlook_calendar.py b/app/integrations/outlook_calendar.py new file mode 100644 index 0000000..9dbe92a --- /dev/null +++ b/app/integrations/outlook_calendar.py @@ -0,0 +1,414 @@ +""" +Outlook Calendar integration connector. +Provides two-way sync between TimeTracker and Outlook Calendar. +""" + +from typing import Dict, Any, Optional, List +from datetime import datetime, timedelta +from app.integrations.base import BaseConnector +import requests +import os + + +class OutlookCalendarConnector(BaseConnector): + """Outlook Calendar integration connector using Microsoft Graph API.""" + + display_name = "Outlook Calendar" + description = "Two-way sync with Outlook Calendar" + icon = "microsoft" + + # Microsoft Graph API endpoints + GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0" + AUTH_BASE_URL = "https://login.microsoftonline.com" + + # OAuth 2.0 scopes required + SCOPES = [ + "Calendars.ReadWrite", + "offline_access", + "User.Read" + ] + + @property + def provider_name(self) -> str: + return "outlook_calendar" + + def _get_tenant_id(self) -> str: + """Get tenant ID from settings or use 'common' for multi-tenant.""" + from app.models import Settings + settings = Settings.get_settings() + creds = settings.get_integration_credentials("outlook_calendar") + tenant_id = creds.get("tenant_id") or os.getenv("OUTLOOK_TENANT_ID", "common") + return tenant_id + + def get_authorization_url(self, redirect_uri: str, state: str = None) -> str: + """Get Microsoft OAuth authorization URL.""" + from app.models import Settings + + settings = Settings.get_settings() + creds = settings.get_integration_credentials("outlook_calendar") + client_id = creds.get("client_id") or os.getenv("OUTLOOK_CLIENT_ID") + tenant_id = self._get_tenant_id() + + if not client_id: + raise ValueError("Outlook Calendar OAuth credentials not configured") + + auth_url = f"{self.AUTH_BASE_URL}/{tenant_id}/oauth2/v2.0/authorize" + + params = { + "client_id": client_id, + "response_type": "code", + "redirect_uri": redirect_uri, + "response_mode": "query", + "scope": " ".join(self.SCOPES), + "state": state or "", + "prompt": "consent", # Force consent to get refresh token + } + + 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("outlook_calendar") + client_id = creds.get("client_id") or os.getenv("OUTLOOK_CLIENT_ID") + client_secret = creds.get("client_secret") or os.getenv("OUTLOOK_CLIENT_SECRET") + tenant_id = self._get_tenant_id() + + if not client_id or not client_secret: + raise ValueError("Outlook Calendar OAuth credentials not configured") + + token_url = f"{self.AUTH_BASE_URL}/{tenant_id}/oauth2/v2.0/token" + + response = requests.post( + token_url, + data={ + "client_id": client_id, + "client_secret": client_secret, + "code": code, + "redirect_uri": redirect_uri, + "grant_type": "authorization_code", + "scope": " ".join(self.SCOPES), + }, + ) + + 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.GRAPH_BASE_URL}/me", + headers={"Authorization": f"Bearer {data['access_token']}"} + ) + if user_response.status_code == 200: + user_data = user_response.json() + user_info = { + "id": user_data.get("id"), + "displayName": user_data.get("displayName"), + "mail": user_data.get("mail"), + "userPrincipalName": user_data.get("userPrincipalName"), + } + except Exception: + pass + + 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": data.get("token_type", "Bearer"), + "scope": data.get("scope"), + "extra_data": user_info, + } + + def refresh_access_token(self) -> Dict[str, Any]: + """Refresh access token using refresh 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("outlook_calendar") + client_id = creds.get("client_id") or os.getenv("OUTLOOK_CLIENT_ID") + client_secret = creds.get("client_secret") or os.getenv("OUTLOOK_CLIENT_SECRET") + tenant_id = self._get_tenant_id() + + if not client_id or not client_secret: + raise ValueError("Outlook Calendar OAuth credentials not configured") + + token_url = f"{self.AUTH_BASE_URL}/{tenant_id}/oauth2/v2.0/token" + + response = requests.post( + token_url, + data={ + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": self.credentials.refresh_token, + "grant_type": "refresh_token", + "scope": " ".join(self.SCOPES), + }, + ) + + 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 + from app.utils.db import safe_commit + safe_commit("refresh_outlook_calendar_token", {"integration_id": self.integration.id}) + + 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 Outlook Calendar.""" + token = self.get_access_token() + if not token: + return {"success": False, "message": "No access token available"} + + try: + # Get user info and calendars + response = requests.get( + f"{self.GRAPH_BASE_URL}/me/calendars", + headers={"Authorization": f"Bearer {token}"} + ) + + if response.status_code == 200: + calendars = response.json().get("value", []) + return { + "success": True, + "message": f"Connected to Outlook Calendar. Found {len(calendars)} calendars." + } + else: + return {"success": False, "message": f"API returned status {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 time entries with Outlook Calendar.""" + from app.models import TimeEntry + from app import db + from datetime import datetime, timedelta + + try: + token = self.get_access_token() + if not token: + return {"success": False, "message": "No access token available"} + + # Get calendar ID from integration config + calendar_id = self.integration.config.get("calendar_id", "calendar") + + # Get time entries to sync + if sync_type == "incremental": + start_date = datetime.utcnow() - timedelta(days=30) + else: + start_date = datetime.utcnow() - timedelta(days=90) + + # Get time entries + time_entries = TimeEntry.query.filter( + TimeEntry.user_id == self.integration.user_id, + TimeEntry.start_time >= start_date, + TimeEntry.end_time.isnot(None) + ).all() + + synced_count = 0 + errors = [] + + for entry in time_entries: + try: + # Check if already synced + existing_event_id = None + if hasattr(entry, "metadata") and entry.metadata: + existing_event_id = entry.metadata.get("outlook_event_id") + + if existing_event_id: + # Update existing event + self._update_calendar_event(token, calendar_id, existing_event_id, entry) + else: + # Create new event + event_id = self._create_calendar_event(token, calendar_id, entry) + + # Store event ID in time entry metadata + if not hasattr(entry, "metadata") or not entry.metadata: + entry.metadata = {} + entry.metadata = entry.metadata or {} + entry.metadata["outlook_event_id"] = event_id + + synced_count += 1 + except Exception as e: + errors.append(f"Error syncing entry {entry.id}: {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 _create_calendar_event(self, token: str, calendar_id: str, time_entry) -> str: + """Create a calendar event from a time entry.""" + from app.models import Project, Task + + project = Project.query.get(time_entry.project_id) + task = Task.query.get(time_entry.task_id) if time_entry.task_id else None + + # Build event title + title_parts = [] + if project: + title_parts.append(project.name) + if task: + title_parts.append(task.name) + if not title_parts: + title_parts.append("Time Entry") + + title = " - ".join(title_parts) + + # Build description + description_parts = [] + if time_entry.notes: + description_parts.append(time_entry.notes) + if time_entry.tags: + description_parts.append(f"Tags: {time_entry.tags}") + description = "\n\n".join(description_parts) if description_parts else None + + event = { + "subject": title, + "body": { + "contentType": "text", + "content": description or "" + }, + "start": { + "dateTime": time_entry.start_time.isoformat(), + "timeZone": "UTC" + }, + "end": { + "dateTime": time_entry.end_time.isoformat(), + "timeZone": "UTC" + }, + "isAllDay": False, + } + + response = requests.post( + f"{self.GRAPH_BASE_URL}/me/calendars/{calendar_id}/events", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + }, + json=event + ) + + response.raise_for_status() + created_event = response.json() + return created_event["id"] + + def _update_calendar_event(self, token: str, calendar_id: str, event_id: str, time_entry): + """Update an existing calendar event.""" + from app.models import Project, Task + + project = Project.query.get(time_entry.project_id) + task = Task.query.get(time_entry.task_id) if time_entry.task_id else None + + # Build event title + title_parts = [] + if project: + title_parts.append(project.name) + if task: + title_parts.append(task.name) + if not title_parts: + title_parts.append("Time Entry") + + title = " - ".join(title_parts) + + # Build description + description_parts = [] + if time_entry.notes: + description_parts.append(time_entry.notes) + if time_entry.tags: + description_parts.append(f"Tags: {time_entry.tags}") + description = "\n\n".join(description_parts) if description_parts else None + + event = { + "subject": title, + "body": { + "contentType": "text", + "content": description or "" + }, + "start": { + "dateTime": time_entry.start_time.isoformat(), + "timeZone": "UTC" + }, + "end": { + "dateTime": time_entry.end_time.isoformat(), + "timeZone": "UTC" + }, + } + + response = requests.patch( + f"{self.GRAPH_BASE_URL}/me/calendars/{calendar_id}/events/{event_id}", + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + }, + json=event + ) + + response.raise_for_status() + + def get_config_schema(self) -> Dict[str, Any]: + """Get configuration schema.""" + return { + "fields": [ + { + "name": "calendar_id", + "type": "string", + "label": "Calendar ID", + "default": "calendar", + "description": "Outlook Calendar ID to sync with (default: 'calendar' for primary calendar)" + }, + { + "name": "sync_direction", + "type": "select", + "label": "Sync Direction", + "options": [ + {"value": "time_tracker_to_calendar", "label": "TimeTracker → Calendar"}, + {"value": "calendar_to_time_tracker", "label": "Calendar → TimeTracker"}, + {"value": "bidirectional", "label": "Bidirectional"} + ], + "default": "time_tracker_to_calendar" + }, + { + "name": "auto_sync", + "type": "boolean", + "label": "Auto Sync", + "default": True, + "description": "Automatically sync when time entries are created/updated" + } + ], + "required": [] + } + diff --git a/app/integrations/registry.py b/app/integrations/registry.py index 1fd93df..24b8219 100644 --- a/app/integrations/registry.py +++ b/app/integrations/registry.py @@ -8,9 +8,13 @@ from app.integrations.jira import JiraConnector from app.integrations.slack import SlackConnector from app.integrations.github import GitHubConnector from app.integrations.google_calendar import GoogleCalendarConnector +from app.integrations.outlook_calendar import OutlookCalendarConnector +from app.integrations.microsoft_teams import MicrosoftTeamsConnector from app.integrations.asana import AsanaConnector from app.integrations.trello import TrelloConnector +from app.integrations.gitlab import GitLabConnector from app.integrations.quickbooks import QuickBooksConnector +from app.integrations.xero import XeroConnector def register_connectors(): @@ -19,9 +23,13 @@ def register_connectors(): IntegrationService.register_connector("slack", SlackConnector) IntegrationService.register_connector("github", GitHubConnector) IntegrationService.register_connector("google_calendar", GoogleCalendarConnector) + IntegrationService.register_connector("outlook_calendar", OutlookCalendarConnector) + IntegrationService.register_connector("microsoft_teams", MicrosoftTeamsConnector) IntegrationService.register_connector("asana", AsanaConnector) IntegrationService.register_connector("trello", TrelloConnector) + IntegrationService.register_connector("gitlab", GitLabConnector) IntegrationService.register_connector("quickbooks", QuickBooksConnector) + IntegrationService.register_connector("xero", XeroConnector) # Auto-register on import diff --git a/app/integrations/slack.py b/app/integrations/slack.py index 326fa97..3085e93 100644 --- a/app/integrations/slack.py +++ b/app/integrations/slack.py @@ -149,10 +149,101 @@ class SlackConnector(BaseConnector): if not token: return {"success": False, "message": "No access token available"} - # This would sync Slack channels, users, etc. - # Implementation depends on specific requirements + synced_count = 0 + errors = [] - return {"success": True, "message": "Sync completed", "synced_items": 0} + try: + # Get channels + channels_response = requests.get( + "https://slack.com/api/conversations.list", + headers={"Authorization": f"Bearer {token}"}, + params={"types": "public_channel,private_channel", "exclude_archived": True} + ) + + if channels_response.status_code == 200: + channels_data = channels_response.json() + if channels_data.get("ok"): + channels = channels_data.get("channels", []) + synced_count += len(channels) + + # Store channels in integration config + if not self.integration.config: + self.integration.config = {} + self.integration.config['channels'] = [ + { + "id": ch.get("id"), + "name": ch.get("name"), + "is_private": ch.get("is_private", False) + } + for ch in channels + ] + else: + errors.append(f"Slack API error: {channels_data.get('error', 'Unknown error')}") + + # Get users + users_response = requests.get( + "https://slack.com/api/users.list", + headers={"Authorization": f"Bearer {token}"} + ) + + if users_response.status_code == 200: + users_data = users_response.json() + if users_data.get("ok"): + users = users_data.get("members", []) + synced_count += len(users) + + # Store users in integration config + if not self.integration.config: + self.integration.config = {} + self.integration.config['users'] = [ + { + "id": u.get("id"), + "name": u.get("name"), + "real_name": u.get("real_name", ""), + "email": u.get("profile", {}).get("email", "") + } + for u in users if not u.get("deleted", False) + ] + else: + errors.append(f"Slack API error: {users_data.get('error', 'Unknown error')}") + + from app import db + from app.utils.db import safe_commit + safe_commit("sync_slack_data", {"integration_id": self.integration.id}) + + return { + "success": True, + "message": f"Sync completed. Found {synced_count} items.", + "synced_items": synced_count, + "errors": errors + } + except Exception as e: + return {"success": False, "message": f"Sync failed: {str(e)}"} + + def handle_webhook(self, payload: Dict[str, Any], headers: Dict[str, str]) -> Dict[str, Any]: + """Handle incoming webhook from Slack.""" + try: + # Slack webhooks typically use challenge-response for URL verification + if payload.get("type") == "url_verification": + return { + "success": True, + "challenge": payload.get("challenge") + } + + event = payload.get("event", {}) + event_type = event.get("type", "") + + # Handle various Slack events + if event_type == "message": + return { + "success": True, + "message": "Message event received", + "event_type": event_type + } + + return {"success": True, "message": f"Webhook processed: {event_type}"} + except Exception as e: + return {"success": False, "message": f"Error processing webhook: {str(e)}"} def send_message(self, channel: str, text: str) -> Dict[str, Any]: """Send a message to a Slack channel.""" diff --git a/app/integrations/trello.py b/app/integrations/trello.py index 47eda4f..ac93786 100644 --- a/app/integrations/trello.py +++ b/app/integrations/trello.py @@ -233,8 +233,36 @@ class TrelloConnector(BaseConnector): def _map_trello_list_to_status(self, list_id: str) -> str: """Map Trello list to task status.""" - # This would need to fetch list name, but for now use default mapping - # In production, you'd fetch the list and map by name + 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 "completed" + 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]: diff --git a/app/integrations/xero.py b/app/integrations/xero.py new file mode 100644 index 0000000..86ac765 --- /dev/null +++ b/app/integrations/xero.py @@ -0,0 +1,374 @@ +""" +Xero integration connector. +Sync invoices, expenses, and payments with Xero. +""" + +from typing import Dict, Any, Optional, List +from datetime import datetime, timedelta +from app.integrations.base import BaseConnector +import requests +import os +import base64 +import logging + +logger = logging.getLogger(__name__) + + +class XeroConnector(BaseConnector): + """Xero integration connector.""" + + display_name = "Xero" + description = "Sync invoices, expenses, and payments with Xero" + icon = "xero" + + BASE_URL = "https://api.xero.com" + + @property + def provider_name(self) -> str: + return "xero" + + def get_authorization_url(self, redirect_uri: str, state: str = None) -> str: + """Get Xero OAuth authorization URL.""" + from app.models import Settings + + settings = Settings.get_settings() + creds = settings.get_integration_credentials("xero") + client_id = creds.get("client_id") or os.getenv("XERO_CLIENT_ID") + + if not client_id: + raise ValueError("XERO_CLIENT_ID not configured") + + scopes = [ + "accounting.transactions", + "accounting.contacts", + "accounting.settings", + "offline_access" + ] + + auth_url = "https://login.xero.com/identity/connect/authorize" + params = { + "response_type": "code", + "client_id": client_id, + "redirect_uri": redirect_uri, + "scope": " ".join(scopes), + "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("xero") + client_id = creds.get("client_id") or os.getenv("XERO_CLIENT_ID") + client_secret = creds.get("client_secret") or os.getenv("XERO_CLIENT_SECRET") + + if not client_id or not client_secret: + raise ValueError("Xero OAuth credentials not configured") + + token_url = "https://identity.xero.com/connect/token" + + # Xero requires Basic Auth for token exchange + auth_string = f"{client_id}:{client_secret}" + auth_bytes = auth_string.encode('ascii') + auth_b64 = base64.b64encode(auth_bytes).decode('ascii') + + response = requests.post( + token_url, + headers={ + "Authorization": f"Basic {auth_b64}", + "Content-Type": "application/x-www-form-urlencoded" + }, + data={ + "grant_type": "authorization_code", + "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 tenant info + tenant_info = {} + if "access_token" in data: + try: + tenants_response = requests.get( + f"{self.BASE_URL}/connections", + headers={"Authorization": f"Bearer {data['access_token']}"} + ) + if tenants_response.status_code == 200: + tenants = tenants_response.json() + if tenants: + tenant_info = { + "tenantId": tenants[0].get("tenantId"), + "tenantName": tenants[0].get("tenantName"), + } + except Exception: + pass + + 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": data.get("token_type", "Bearer"), + "scope": data.get("scope"), + "extra_data": tenant_info, + } + + def refresh_access_token(self) -> Dict[str, Any]: + """Refresh access token using refresh 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("xero") + client_id = creds.get("client_id") or os.getenv("XERO_CLIENT_ID") + client_secret = creds.get("client_secret") or os.getenv("XERO_CLIENT_SECRET") + + token_url = "https://identity.xero.com/connect/token" + + auth_string = f"{client_id}:{client_secret}" + auth_bytes = auth_string.encode('ascii') + auth_b64 = base64.b64encode(auth_bytes).decode('ascii') + + response = requests.post( + token_url, + headers={ + "Authorization": f"Basic {auth_b64}", + "Content-Type": "application/x-www-form-urlencoded" + }, + data={ + "grant_type": "refresh_token", + "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 + from app.utils.db import safe_commit + safe_commit("refresh_xero_token", {"integration_id": self.integration.id}) + + 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 Xero.""" + try: + tenant_id = self.integration.config.get("tenant_id") if self.integration else None + if not tenant_id: + # Try to get from extra_data + if self.credentials and self.credentials.extra_data: + tenant_id = self.credentials.extra_data.get("tenantId") + + if not tenant_id: + return {"success": False, "message": "Xero tenant not configured"} + + organisation_info = self._api_request( + "GET", + f"/api.xro/2.0/Organisation", + self.get_access_token(), + tenant_id + ) + + if organisation_info: + org_name = organisation_info.get("Organisations", [{}])[0].get("Name", "Unknown") + return { + "success": True, + "message": f"Connected to Xero organisation: {org_name}" + } + else: + return { + "success": False, + "message": "Failed to retrieve organisation information" + } + except Exception as e: + return { + "success": False, + "message": f"Connection test failed: {str(e)}" + } + + def _api_request(self, method: str, endpoint: str, access_token: str, tenant_id: str) -> Optional[Dict]: + """Make API request to Xero""" + url = f"{self.BASE_URL}{endpoint}" + + headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/json", + "Content-Type": "application/json", + "Xero-tenant-id": tenant_id + } + + try: + if method.upper() == "GET": + response = requests.get(url, headers=headers, timeout=10) + elif method.upper() == "POST": + response = requests.post(url, headers=headers, timeout=10, json={}) + else: + response = requests.request(method, url, headers=headers, timeout=10) + + response.raise_for_status() + return response.json() + except Exception as e: + logger.error(f"Xero API request failed: {e}") + return None + + def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: + """Sync invoices and expenses with Xero""" + from app.models import Invoice, Expense + from app import db + + try: + tenant_id = self.integration.config.get("tenant_id") + if not tenant_id: + if self.credentials and self.credentials.extra_data: + tenant_id = self.credentials.extra_data.get("tenantId") + + if not tenant_id: + return {"success": False, "message": "Xero tenant not configured"} + + access_token = self.get_access_token() + synced_count = 0 + errors = [] + + # Sync invoices (create as invoices in Xero) + if sync_type == "full" or sync_type == "invoices": + invoices = Invoice.query.filter( + Invoice.status.in_(["sent", "paid"]), + Invoice.created_at >= datetime.utcnow() - timedelta(days=90) + ).all() + + for invoice in invoices: + try: + xero_invoice = self._create_xero_invoice(invoice, access_token, tenant_id) + if xero_invoice: + # Store Xero ID in invoice metadata + if not hasattr(invoice, 'metadata') or not invoice.metadata: + invoice.metadata = {} + invoice.metadata['xero_invoice_id'] = xero_invoice.get("Invoices", [{}])[0].get("InvoiceID") + synced_count += 1 + except Exception as e: + errors.append(f"Error syncing invoice {invoice.id}: {str(e)}") + + # Sync expenses (create as expenses in Xero) + if sync_type == "full" or sync_type == "expenses": + expenses = Expense.query.filter( + Expense.date >= datetime.utcnow().date() - timedelta(days=90) + ).all() + + for expense in expenses: + try: + xero_expense = self._create_xero_expense(expense, access_token, tenant_id) + if xero_expense: + if not hasattr(expense, 'metadata') or not expense.metadata: + expense.metadata = {} + expense.metadata['xero_expense_id'] = xero_expense.get("Expenses", [{}])[0].get("ExpenseID") + synced_count += 1 + except Exception as e: + errors.append(f"Error syncing expense {expense.id}: {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 _create_xero_invoice(self, invoice, access_token: str, tenant_id: str) -> Optional[Dict]: + """Create invoice in Xero""" + # Build Xero invoice structure + xero_invoice = { + "Type": "ACCREC", + "Contact": { + "Name": invoice.client.name if invoice.client else "Unknown" + }, + "Date": invoice.date.strftime("%Y-%m-%d") if invoice.date else datetime.utcnow().strftime("%Y-%m-%d"), + "DueDate": invoice.due_date.strftime("%Y-%m-%d") if invoice.due_date else datetime.utcnow().strftime("%Y-%m-%d"), + "LineItems": [] + } + + # Add invoice items + for item in invoice.items: + xero_invoice["LineItems"].append({ + "Description": item.description, + "Quantity": float(item.quantity), + "UnitAmount": float(item.unit_price), + "LineAmount": float(item.quantity * item.unit_price), + }) + + endpoint = "/api.xro/2.0/Invoices" + return self._api_request("POST", endpoint, access_token, tenant_id) + + def _create_xero_expense(self, expense, access_token: str, tenant_id: str) -> Optional[Dict]: + """Create expense in Xero""" + # Build Xero expense structure + xero_expense = { + "Date": expense.date.strftime("%Y-%m-%d") if expense.date else datetime.utcnow().strftime("%Y-%m-%d"), + "Contact": { + "Name": expense.vendor or "Unknown" + }, + "LineItems": [{ + "Description": expense.description or "Expense", + "Quantity": 1.0, + "UnitAmount": float(expense.amount), + "LineAmount": float(expense.amount), + }] + } + + endpoint = "/api.xro/2.0/Expenses" + return self._api_request("POST", endpoint, access_token, tenant_id) + + def get_config_schema(self) -> Dict[str, Any]: + """Get configuration schema.""" + return { + "fields": [ + { + "name": "tenant_id", + "type": "string", + "label": "Tenant ID", + "description": "Xero organisation tenant ID" + }, + { + "name": "sync_invoices", + "type": "boolean", + "label": "Sync Invoices", + "default": True + }, + { + "name": "sync_expenses", + "type": "boolean", + "label": "Sync Expenses", + "default": True + } + ], + "required": ["tenant_id"] + } + diff --git a/app/models/integration.py b/app/models/integration.py index 0f0ca73..a482643 100644 --- a/app/models/integration.py +++ b/app/models/integration.py @@ -16,7 +16,8 @@ class Integration(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False) # e.g., 'Jira', 'Slack', 'GitHub' provider = db.Column(db.String(50), nullable=False, index=True) # e.g., 'jira', 'slack', 'github' - user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) # Nullable for global integrations + is_global = db.Column(db.Boolean, default=False, nullable=False, index=True) # True for global (shared) integrations is_active = db.Column(db.Boolean, default=False, nullable=False) # Only True when credentials are set up config = db.Column(JSON, nullable=True) # Provider-specific configuration last_sync_at = db.Column(db.DateTime, nullable=True) @@ -24,6 +25,11 @@ class Integration(db.Model): last_error = db.Column(db.Text, nullable=True) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + __table_args__ = ( + # Ensure only one global integration per provider + db.CheckConstraint('(is_global = 0) OR (is_global = 1 AND user_id IS NULL)', name='check_global_integration'), + ) user = db.relationship("User", backref="integrations") diff --git a/app/models/settings.py b/app/models/settings.py index 45f39a7..2b24538 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -105,6 +105,33 @@ class Settings(db.Model): # GitHub github_client_id = db.Column(db.String(255), default="", nullable=True) github_client_secret = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production + # Google Calendar + google_calendar_client_id = db.Column(db.String(255), default="", nullable=True) + google_calendar_client_secret = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production + # Outlook Calendar + outlook_calendar_client_id = db.Column(db.String(255), default="", nullable=True) + outlook_calendar_client_secret = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production + outlook_calendar_tenant_id = db.Column(db.String(255), default="", nullable=True) + # Microsoft Teams + microsoft_teams_client_id = db.Column(db.String(255), default="", nullable=True) + microsoft_teams_client_secret = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production + microsoft_teams_tenant_id = db.Column(db.String(255), default="", nullable=True) + # Asana + asana_client_id = db.Column(db.String(255), default="", nullable=True) + asana_client_secret = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production + # Trello + trello_api_key = db.Column(db.String(255), default="", nullable=True) + trello_api_secret = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production + # GitLab + gitlab_client_id = db.Column(db.String(255), default="", nullable=True) + gitlab_client_secret = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production + gitlab_instance_url = db.Column(db.String(500), default="", nullable=True) + # QuickBooks + quickbooks_client_id = db.Column(db.String(255), default="", nullable=True) + quickbooks_client_secret = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production + # Xero + xero_client_id = db.Column(db.String(255), default="", nullable=True) + xero_client_secret = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) @@ -166,6 +193,25 @@ class Settings(db.Model): self.slack_client_secret = kwargs.get("slack_client_secret", "") self.github_client_id = kwargs.get("github_client_id", "") self.github_client_secret = kwargs.get("github_client_secret", "") + self.google_calendar_client_id = kwargs.get("google_calendar_client_id", "") + self.google_calendar_client_secret = kwargs.get("google_calendar_client_secret", "") + self.outlook_calendar_client_id = kwargs.get("outlook_calendar_client_id", "") + self.outlook_calendar_client_secret = kwargs.get("outlook_calendar_client_secret", "") + self.outlook_calendar_tenant_id = kwargs.get("outlook_calendar_tenant_id", "") + self.microsoft_teams_client_id = kwargs.get("microsoft_teams_client_id", "") + self.microsoft_teams_client_secret = kwargs.get("microsoft_teams_client_secret", "") + self.microsoft_teams_tenant_id = kwargs.get("microsoft_teams_tenant_id", "") + self.asana_client_id = kwargs.get("asana_client_id", "") + self.asana_client_secret = kwargs.get("asana_client_secret", "") + self.trello_api_key = kwargs.get("trello_api_key", "") + self.trello_api_secret = kwargs.get("trello_api_secret", "") + self.gitlab_client_id = kwargs.get("gitlab_client_id", "") + self.gitlab_client_secret = kwargs.get("gitlab_client_secret", "") + self.gitlab_instance_url = kwargs.get("gitlab_instance_url", "") + self.quickbooks_client_id = kwargs.get("quickbooks_client_id", "") + self.quickbooks_client_secret = kwargs.get("quickbooks_client_secret", "") + self.xero_client_id = kwargs.get("xero_client_id", "") + self.xero_client_secret = kwargs.get("xero_client_secret", "") def __repr__(self): return f"" @@ -217,27 +263,79 @@ class Settings(db.Model): """Get integration OAuth credentials, preferring database settings over environment variables. Args: - provider: One of 'jira', 'slack', or 'github' + provider: One of 'jira', 'slack', 'github', 'google_calendar', 'outlook_calendar', + 'microsoft_teams', 'asana', 'trello', 'gitlab', 'quickbooks', 'xero' Returns: - dict with 'client_id' and 'client_secret' keys, or empty dict if not configured + dict with credentials (varies by provider): + - Standard OAuth: 'client_id', 'client_secret' + - Microsoft: 'client_id', 'client_secret', 'tenant_id' + - Trello: 'api_key', 'api_secret' + - GitLab: 'client_id', 'client_secret', 'instance_url' """ import os if provider == "jira": client_id = self.jira_client_id or os.getenv("JIRA_CLIENT_ID", "") client_secret = self.jira_client_secret or os.getenv("JIRA_CLIENT_SECRET", "") + return {"client_id": client_id, "client_secret": client_secret} + elif provider == "slack": client_id = self.slack_client_id or os.getenv("SLACK_CLIENT_ID", "") client_secret = self.slack_client_secret or os.getenv("SLACK_CLIENT_SECRET", "") + return {"client_id": client_id, "client_secret": client_secret} + elif provider == "github": client_id = self.github_client_id or os.getenv("GITHUB_CLIENT_ID", "") client_secret = self.github_client_secret or os.getenv("GITHUB_CLIENT_SECRET", "") + return {"client_id": client_id, "client_secret": client_secret} + + elif provider == "google_calendar": + client_id = getattr(self, "google_calendar_client_id", "") or os.getenv("GOOGLE_CLIENT_ID", "") + client_secret = getattr(self, "google_calendar_client_secret", "") or os.getenv("GOOGLE_CLIENT_SECRET", "") + return {"client_id": client_id, "client_secret": client_secret} + + elif provider == "outlook_calendar": + client_id = getattr(self, "outlook_calendar_client_id", "") or os.getenv("OUTLOOK_CLIENT_ID", "") + client_secret = getattr(self, "outlook_calendar_client_secret", "") or os.getenv("OUTLOOK_CLIENT_SECRET", "") + tenant_id = getattr(self, "outlook_calendar_tenant_id", "") or os.getenv("OUTLOOK_TENANT_ID", "") + return {"client_id": client_id, "client_secret": client_secret, "tenant_id": tenant_id} + + elif provider == "microsoft_teams": + client_id = getattr(self, "microsoft_teams_client_id", "") or os.getenv("MICROSOFT_TEAMS_CLIENT_ID", "") + client_secret = getattr(self, "microsoft_teams_client_secret", "") or os.getenv("MICROSOFT_TEAMS_CLIENT_SECRET", "") + tenant_id = getattr(self, "microsoft_teams_tenant_id", "") or os.getenv("MICROSOFT_TEAMS_TENANT_ID", "") + return {"client_id": client_id, "client_secret": client_secret, "tenant_id": tenant_id} + + elif provider == "asana": + client_id = getattr(self, "asana_client_id", "") or os.getenv("ASANA_CLIENT_ID", "") + client_secret = getattr(self, "asana_client_secret", "") or os.getenv("ASANA_CLIENT_SECRET", "") + return {"client_id": client_id, "client_secret": client_secret} + + elif provider == "trello": + api_key = getattr(self, "trello_api_key", "") or os.getenv("TRELLO_API_KEY", "") + api_secret = getattr(self, "trello_api_secret", "") or os.getenv("TRELLO_API_SECRET", "") + return {"api_key": api_key, "api_secret": api_secret} + + elif provider == "gitlab": + client_id = getattr(self, "gitlab_client_id", "") or os.getenv("GITLAB_CLIENT_ID", "") + client_secret = getattr(self, "gitlab_client_secret", "") or os.getenv("GITLAB_CLIENT_SECRET", "") + instance_url = getattr(self, "gitlab_instance_url", "") or os.getenv("GITLAB_INSTANCE_URL", "https://gitlab.com") + return {"client_id": client_id, "client_secret": client_secret, "instance_url": instance_url} + + elif provider == "quickbooks": + client_id = getattr(self, "quickbooks_client_id", "") or os.getenv("QUICKBOOKS_CLIENT_ID", "") + client_secret = getattr(self, "quickbooks_client_secret", "") or os.getenv("QUICKBOOKS_CLIENT_SECRET", "") + return {"client_id": client_id, "client_secret": client_secret} + + elif provider == "xero": + client_id = getattr(self, "xero_client_id", "") or os.getenv("XERO_CLIENT_ID", "") + client_secret = getattr(self, "xero_client_secret", "") or os.getenv("XERO_CLIENT_SECRET", "") + return {"client_id": client_id, "client_secret": client_secret} + else: return {} - return {"client_id": client_id, "client_secret": client_secret} - def to_dict(self): """Convert settings to dictionary for API responses""" return { @@ -283,6 +381,25 @@ class Settings(db.Model): "slack_client_secret_set": bool(self.slack_client_secret), # Don't expose actual secret "github_client_id": self.github_client_id or "", "github_client_secret_set": bool(self.github_client_secret), # Don't expose actual secret + "google_calendar_client_id": getattr(self, "google_calendar_client_id", "") or "", + "google_calendar_client_secret_set": bool(getattr(self, "google_calendar_client_secret", "")), + "outlook_calendar_client_id": getattr(self, "outlook_calendar_client_id", "") or "", + "outlook_calendar_client_secret_set": bool(getattr(self, "outlook_calendar_client_secret", "")), + "outlook_calendar_tenant_id": getattr(self, "outlook_calendar_tenant_id", "") or "", + "microsoft_teams_client_id": getattr(self, "microsoft_teams_client_id", "") or "", + "microsoft_teams_client_secret_set": bool(getattr(self, "microsoft_teams_client_secret", "")), + "microsoft_teams_tenant_id": getattr(self, "microsoft_teams_tenant_id", "") or "", + "asana_client_id": getattr(self, "asana_client_id", "") or "", + "asana_client_secret_set": bool(getattr(self, "asana_client_secret", "")), + "trello_api_key": getattr(self, "trello_api_key", "") or "", + "trello_api_secret_set": bool(getattr(self, "trello_api_secret", "")), + "gitlab_client_id": getattr(self, "gitlab_client_id", "") or "", + "gitlab_client_secret_set": bool(getattr(self, "gitlab_client_secret", "")), + "gitlab_instance_url": getattr(self, "gitlab_instance_url", "") or "", + "quickbooks_client_id": getattr(self, "quickbooks_client_id", "") or "", + "quickbooks_client_secret_set": bool(getattr(self, "quickbooks_client_secret", "")), + "xero_client_id": getattr(self, "xero_client_id", "") or "", + "xero_client_secret_set": bool(getattr(self, "xero_client_secret", "")), "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, # UI feature flags (system-wide) diff --git a/app/routes/admin.py b/app/routes/admin.py index b971ed8..09f6239 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -451,51 +451,75 @@ def settings(): # Update system-wide UI feature flags (if columns exist) try: # Calendar - settings_obj.ui_allow_calendar = request.form.get("ui_allow_calendar") == "on" + if hasattr(settings_obj, "ui_allow_calendar"): + settings_obj.ui_allow_calendar = request.form.get("ui_allow_calendar") == "on" # Time Tracking - settings_obj.ui_allow_project_templates = request.form.get("ui_allow_project_templates") == "on" - settings_obj.ui_allow_gantt_chart = request.form.get("ui_allow_gantt_chart") == "on" - settings_obj.ui_allow_kanban_board = request.form.get("ui_allow_kanban_board") == "on" - settings_obj.ui_allow_weekly_goals = request.form.get("ui_allow_weekly_goals") == "on" + if hasattr(settings_obj, "ui_allow_project_templates"): + settings_obj.ui_allow_project_templates = request.form.get("ui_allow_project_templates") == "on" + if hasattr(settings_obj, "ui_allow_gantt_chart"): + settings_obj.ui_allow_gantt_chart = request.form.get("ui_allow_gantt_chart") == "on" + if hasattr(settings_obj, "ui_allow_kanban_board"): + settings_obj.ui_allow_kanban_board = request.form.get("ui_allow_kanban_board") == "on" + if hasattr(settings_obj, "ui_allow_weekly_goals"): + settings_obj.ui_allow_weekly_goals = request.form.get("ui_allow_weekly_goals") == "on" # CRM - settings_obj.ui_allow_quotes = request.form.get("ui_allow_quotes") == "on" + if hasattr(settings_obj, "ui_allow_quotes"): + settings_obj.ui_allow_quotes = request.form.get("ui_allow_quotes") == "on" # Finance & Expenses - settings_obj.ui_allow_reports = request.form.get("ui_allow_reports") == "on" - settings_obj.ui_allow_report_builder = request.form.get("ui_allow_report_builder") == "on" - settings_obj.ui_allow_scheduled_reports = request.form.get("ui_allow_scheduled_reports") == "on" - settings_obj.ui_allow_invoice_approvals = request.form.get("ui_allow_invoice_approvals") == "on" - settings_obj.ui_allow_payment_gateways = request.form.get("ui_allow_payment_gateways") == "on" - settings_obj.ui_allow_recurring_invoices = request.form.get("ui_allow_recurring_invoices") == "on" - settings_obj.ui_allow_payments = request.form.get("ui_allow_payments") == "on" - settings_obj.ui_allow_mileage = request.form.get("ui_allow_mileage") == "on" - settings_obj.ui_allow_per_diem = request.form.get("ui_allow_per_diem") == "on" - settings_obj.ui_allow_budget_alerts = request.form.get("ui_allow_budget_alerts") == "on" + if hasattr(settings_obj, "ui_allow_reports"): + settings_obj.ui_allow_reports = request.form.get("ui_allow_reports") == "on" + if hasattr(settings_obj, "ui_allow_report_builder"): + settings_obj.ui_allow_report_builder = request.form.get("ui_allow_report_builder") == "on" + if hasattr(settings_obj, "ui_allow_scheduled_reports"): + settings_obj.ui_allow_scheduled_reports = request.form.get("ui_allow_scheduled_reports") == "on" + if hasattr(settings_obj, "ui_allow_invoice_approvals"): + settings_obj.ui_allow_invoice_approvals = request.form.get("ui_allow_invoice_approvals") == "on" + if hasattr(settings_obj, "ui_allow_payment_gateways"): + settings_obj.ui_allow_payment_gateways = request.form.get("ui_allow_payment_gateways") == "on" + if hasattr(settings_obj, "ui_allow_recurring_invoices"): + settings_obj.ui_allow_recurring_invoices = request.form.get("ui_allow_recurring_invoices") == "on" + if hasattr(settings_obj, "ui_allow_payments"): + settings_obj.ui_allow_payments = request.form.get("ui_allow_payments") == "on" + if hasattr(settings_obj, "ui_allow_mileage"): + settings_obj.ui_allow_mileage = request.form.get("ui_allow_mileage") == "on" + if hasattr(settings_obj, "ui_allow_per_diem"): + settings_obj.ui_allow_per_diem = request.form.get("ui_allow_per_diem") == "on" + if hasattr(settings_obj, "ui_allow_budget_alerts"): + settings_obj.ui_allow_budget_alerts = request.form.get("ui_allow_budget_alerts") == "on" # Inventory - settings_obj.ui_allow_inventory = request.form.get("ui_allow_inventory") == "on" + if hasattr(settings_obj, "ui_allow_inventory"): + settings_obj.ui_allow_inventory = request.form.get("ui_allow_inventory") == "on" # Analytics - settings_obj.ui_allow_analytics = request.form.get("ui_allow_analytics") == "on" + if hasattr(settings_obj, "ui_allow_analytics"): + settings_obj.ui_allow_analytics = request.form.get("ui_allow_analytics") == "on" # Tools & Data - settings_obj.ui_allow_tools = request.form.get("ui_allow_tools") == "on" - except AttributeError: - # UI allow columns don't exist yet (migration not run) + if hasattr(settings_obj, "ui_allow_tools"): + settings_obj.ui_allow_tools = request.form.get("ui_allow_tools") == "on" + except Exception as e: + # Log any errors but don't fail silently + import logging + logger = logging.getLogger(__name__) + logger.warning(f"Error updating UI feature flags: {e}") + # UI allow columns don't exist yet (migration not run) or other error pass # Update integration OAuth credentials (if columns exist) try: + # Jira if "jira_client_id" in request.form: settings_obj.jira_client_id = request.form.get("jira_client_id", "").strip() if "jira_client_secret" in request.form: new_secret = request.form.get("jira_client_secret", "").strip() - # Only update if a new value is provided (don't clear if empty) if new_secret: settings_obj.jira_client_secret = new_secret + # Slack if "slack_client_id" in request.form: settings_obj.slack_client_id = request.form.get("slack_client_id", "").strip() if "slack_client_secret" in request.form: @@ -503,12 +527,91 @@ def settings(): if new_secret: settings_obj.slack_client_secret = new_secret + # GitHub if "github_client_id" in request.form: settings_obj.github_client_id = request.form.get("github_client_id", "").strip() if "github_client_secret" in request.form: new_secret = request.form.get("github_client_secret", "").strip() if new_secret: settings_obj.github_client_secret = new_secret + + # Google Calendar + if hasattr(settings_obj, "google_calendar_client_id"): + if "google_calendar_client_id" in request.form: + settings_obj.google_calendar_client_id = request.form.get("google_calendar_client_id", "").strip() + if "google_calendar_client_secret" in request.form: + new_secret = request.form.get("google_calendar_client_secret", "").strip() + if new_secret: + settings_obj.google_calendar_client_secret = new_secret + + # Outlook Calendar + if hasattr(settings_obj, "outlook_calendar_client_id"): + if "outlook_calendar_client_id" in request.form: + settings_obj.outlook_calendar_client_id = request.form.get("outlook_calendar_client_id", "").strip() + if "outlook_calendar_client_secret" in request.form: + new_secret = request.form.get("outlook_calendar_client_secret", "").strip() + if new_secret: + settings_obj.outlook_calendar_client_secret = new_secret + if "outlook_calendar_tenant_id" in request.form: + settings_obj.outlook_calendar_tenant_id = request.form.get("outlook_calendar_tenant_id", "").strip() + + # Microsoft Teams + if hasattr(settings_obj, "microsoft_teams_client_id"): + if "microsoft_teams_client_id" in request.form: + settings_obj.microsoft_teams_client_id = request.form.get("microsoft_teams_client_id", "").strip() + if "microsoft_teams_client_secret" in request.form: + new_secret = request.form.get("microsoft_teams_client_secret", "").strip() + if new_secret: + settings_obj.microsoft_teams_client_secret = new_secret + if "microsoft_teams_tenant_id" in request.form: + settings_obj.microsoft_teams_tenant_id = request.form.get("microsoft_teams_tenant_id", "").strip() + + # Asana + if hasattr(settings_obj, "asana_client_id"): + if "asana_client_id" in request.form: + settings_obj.asana_client_id = request.form.get("asana_client_id", "").strip() + if "asana_client_secret" in request.form: + new_secret = request.form.get("asana_client_secret", "").strip() + if new_secret: + settings_obj.asana_client_secret = new_secret + + # Trello + if hasattr(settings_obj, "trello_api_key"): + if "trello_api_key" in request.form: + settings_obj.trello_api_key = request.form.get("trello_api_key", "").strip() + if "trello_api_secret" in request.form: + new_secret = request.form.get("trello_api_secret", "").strip() + if new_secret: + settings_obj.trello_api_secret = new_secret + + # GitLab + if hasattr(settings_obj, "gitlab_client_id"): + if "gitlab_client_id" in request.form: + settings_obj.gitlab_client_id = request.form.get("gitlab_client_id", "").strip() + if "gitlab_client_secret" in request.form: + new_secret = request.form.get("gitlab_client_secret", "").strip() + if new_secret: + settings_obj.gitlab_client_secret = new_secret + if "gitlab_instance_url" in request.form: + settings_obj.gitlab_instance_url = request.form.get("gitlab_instance_url", "").strip() + + # QuickBooks + if hasattr(settings_obj, "quickbooks_client_id"): + if "quickbooks_client_id" in request.form: + settings_obj.quickbooks_client_id = request.form.get("quickbooks_client_id", "").strip() + if "quickbooks_client_secret" in request.form: + new_secret = request.form.get("quickbooks_client_secret", "").strip() + if new_secret: + settings_obj.quickbooks_client_secret = new_secret + + # Xero + if hasattr(settings_obj, "xero_client_id"): + if "xero_client_id" in request.form: + settings_obj.xero_client_id = request.form.get("xero_client_id", "").strip() + if "xero_client_secret" in request.form: + new_secret = request.form.get("xero_client_secret", "").strip() + if new_secret: + settings_obj.xero_client_secret = new_secret except AttributeError: # Integration credential columns don't exist yet (migration not run) pass @@ -527,6 +630,10 @@ def settings(): app_module.log_event("admin.analytics_toggled", user_id=current_user.id, new_state=allow_analytics) app_module.track_event(current_user.id, "admin.analytics_toggled", {"enabled": allow_analytics}) + # Ensure settings object is in the session (important for new instances) + if settings_obj not in db.session: + db.session.add(settings_obj) + if not safe_commit("admin_update_settings"): flash(_("Could not update settings due to a database error. Please check server logs."), "error") return render_template( @@ -2325,3 +2432,135 @@ def delete_email_template(template_id): flash(_('Email template "%(name)s" deleted successfully', name=template_name), "success") return redirect(url_for("admin.list_email_templates")) + + +# ==================== Integration Setup Routes ==================== + +@admin_bp.route("/admin/integrations") +@login_required +@admin_required +def list_integrations_admin(): + """List all integrations (admin view).""" + from app.services.integration_service import IntegrationService + + service = IntegrationService() + integrations = service.list_integrations(None) # Get all integrations + available_providers = service.get_available_providers() + + return render_template("admin/integrations/list.html", integrations=integrations, available_providers=available_providers) + + +@admin_bp.route("/admin/integrations//setup", methods=["GET", "POST"]) +@login_required +@admin_required +def integration_setup(provider): + """Setup page for configuring integration OAuth credentials.""" + from app.services.integration_service import IntegrationService + from app.models import Settings + + service = IntegrationService() + + # Check if provider is available + if provider not in service._connector_registry: + flash(_("Integration provider not available."), "error") + return redirect(url_for("admin.list_integrations_admin")) + + connector_class = service._connector_registry[provider] + settings = Settings.get_settings() + + # Get or create global integration (except Google Calendar which is per-user) + integration = None + if provider != "google_calendar": + integration = service.get_global_integration(provider) + if not integration: + # Create global integration + result = service.create_integration(provider, user_id=None, is_global=True) + if result["success"]: + integration = result["integration"] + else: + flash(result["message"], "error") + return redirect(url_for("admin.list_integrations_admin")) + + if request.method == "POST": + # Update OAuth credentials in Settings + if provider == "trello": + # Trello uses API key + token, not OAuth + api_key = request.form.get("trello_api_key", "").strip() + token = request.form.get("trello_token", "").strip() + if api_key: + settings.trello_api_key = api_key + if token: + # Save token directly to integration credentials if integration exists + if integration: + from app.services.integration_service import IntegrationService + service = IntegrationService() + service.save_credentials( + integration_id=integration.id, + access_token=token, + refresh_token=None, + expires_at=None, + token_type="Bearer", + scope="read,write", + extra_data={"api_key": api_key} + ) + else: + # OAuth-based integrations + client_id = request.form.get(f"{provider}_client_id", "").strip() + client_secret = request.form.get(f"{provider}_client_secret", "").strip() + + # Map provider names to Settings attributes + attr_map = { + "jira": ("jira_client_id", "jira_client_secret"), + "slack": ("slack_client_id", "slack_client_secret"), + "github": ("github_client_id", "github_client_secret"), + "google_calendar": ("google_calendar_client_id", "google_calendar_client_secret"), + "outlook_calendar": ("outlook_calendar_client_id", "outlook_calendar_client_secret"), + "microsoft_teams": ("microsoft_teams_client_id", "microsoft_teams_client_secret"), + "asana": ("asana_client_id", "asana_client_secret"), + "gitlab": ("gitlab_client_id", "gitlab_client_secret"), + "quickbooks": ("quickbooks_client_id", "quickbooks_client_secret"), + "xero": ("xero_client_id", "xero_client_secret"), + } + + if provider in attr_map: + id_attr, secret_attr = attr_map[provider] + if client_id: + setattr(settings, id_attr, client_id) + if client_secret: + setattr(settings, secret_attr, client_secret) + + # Handle special fields + if provider == "outlook_calendar": + tenant_id = request.form.get("outlook_calendar_tenant_id", "").strip() + if tenant_id: + settings.outlook_calendar_tenant_id = tenant_id + elif provider == "microsoft_teams": + tenant_id = request.form.get("microsoft_teams_tenant_id", "").strip() + if tenant_id: + settings.microsoft_teams_tenant_id = tenant_id + elif provider == "gitlab": + instance_url = request.form.get("gitlab_instance_url", "").strip() + if instance_url: + settings.gitlab_instance_url = instance_url + + if safe_commit("update_integration_credentials", {"provider": provider}): + flash(_("Integration credentials updated successfully."), "success") + # For Google Calendar, provide option to test connection + if provider == "google_calendar": + flash(_("Users can now connect their Google Calendar. They will be automatically redirected to Google for authorization."), "info") + return redirect(url_for("admin.integration_setup", provider=provider)) + else: + flash(_("Failed to update credentials."), "error") + + # Get current credentials + current_creds = settings.get_integration_credentials(provider) + + return render_template( + "admin/integrations/setup.html", + provider=provider, + connector=connector_class, + integration=integration, + current_creds=current_creds, + display_name=getattr(connector_class, "display_name", provider.title()), + description=getattr(connector_class, "description", ""), + ) diff --git a/app/routes/calendar.py b/app/routes/calendar.py index 0a4e036..2446c95 100644 --- a/app/routes/calendar.py +++ b/app/routes/calendar.py @@ -412,32 +412,20 @@ def edit_event(event_id): @calendar_bp.route("/calendar/integrations") @login_required def list_integrations(): - """List calendar integrations""" - service = CalendarIntegrationService() - integrations = service.get_user_integrations(current_user.id) - return render_template("calendar/integrations.html", integrations=integrations) + """List calendar integrations - redirect to main integrations page""" + # Redirect to main integrations page to avoid duplication + return redirect(url_for("integrations.list_integrations")) @calendar_bp.route("/calendar/integrations/google/connect") @login_required def connect_google(): - """Connect Google Calendar""" - # This would initiate OAuth flow - # For now, return a placeholder - flash(_("Google Calendar integration coming soon."), "info") - return redirect(url_for("calendar.list_integrations")) + """Connect Google Calendar - redirect to main integrations""" + return redirect(url_for("integrations.connect_integration", provider="google_calendar")) @calendar_bp.route("/calendar/integrations//disconnect", methods=["POST"]) @login_required def disconnect_integration(integration_id): - """Disconnect a calendar integration""" - service = CalendarIntegrationService() - result = service.deactivate_integration(integration_id, current_user.id) - - if result["success"]: - flash(_("Calendar integration disconnected successfully."), "success") - else: - flash(result["message"], "error") - - return redirect(url_for("calendar.list_integrations")) + """Disconnect a calendar integration - redirect to main integrations""" + return redirect(url_for("integrations.delete_integration", integration_id=integration_id)) diff --git a/app/routes/integrations.py b/app/routes/integrations.py index 025efd3..535af44 100644 --- a/app/routes/integrations.py +++ b/app/routes/integrations.py @@ -20,12 +20,12 @@ integrations_bp = Blueprint("integrations", __name__) @integrations_bp.route("/integrations") @login_required def list_integrations(): - """List all integrations for the current user.""" + """List all integrations accessible to the current user (global + per-user).""" service = IntegrationService() integrations = service.list_integrations(current_user.id) available_providers = service.get_available_providers() - return render_template("integrations/list.html", integrations=integrations, available_providers=available_providers) + return render_template("integrations/list.html", integrations=integrations, available_providers=available_providers, current_user=current_user) @integrations_bp.route("/integrations//connect", methods=["GET", "POST"]) @@ -39,19 +39,41 @@ def connect_integration(provider): flash(_("Integration provider not available."), "error") return redirect(url_for("integrations.list_integrations")) - # Check if integration already exists - existing = Integration.query.filter_by(provider=provider, user_id=current_user.id).first() - - if existing: - # Use existing integration (allows reconnecting if credentials were removed) - integration = existing - else: - # Create new integration if it doesn't exist - result = service.create_integration(provider, current_user.id) - if not result["success"]: - flash(result["message"], "error") + # Trello doesn't use OAuth - redirect to admin setup + if provider == "trello": + if not current_user.is_admin: + flash(_("Trello integration must be configured by an administrator."), "error") return redirect(url_for("integrations.list_integrations")) - integration = result["integration"] + flash(_("Trello uses API key authentication. Please configure it in Admin → Integrations."), "info") + return redirect(url_for("admin.integration_setup", provider=provider)) + + # Google Calendar is per-user, all others are global + is_global = (provider != "google_calendar") + + if is_global: + # For global integrations, check if one exists + integration = service.get_global_integration(provider) + if not integration: + # Create global integration (admin only) + if not current_user.is_admin: + flash(_("Only administrators can set up global integrations."), "error") + return redirect(url_for("integrations.list_integrations")) + result = service.create_integration(provider, user_id=None, is_global=True) + if not result["success"]: + flash(result["message"], "error") + return redirect(url_for("integrations.list_integrations")) + integration = result["integration"] + else: + # Per-user integration (Google Calendar) + existing = Integration.query.filter_by(provider=provider, user_id=current_user.id, is_global=False).first() + if existing: + integration = existing + else: + result = service.create_integration(provider, user_id=current_user.id, is_global=False) + if not result["success"]: + flash(result["message"], "error") + return redirect(url_for("integrations.list_integrations")) + integration = result["integration"] # Get connector connector = service.get_connector(integration) @@ -63,13 +85,25 @@ def connect_integration(provider): state = secrets.token_urlsafe(32) session[f"integration_oauth_state_{integration.id}"] = state - # Get authorization URL + # Get authorization URL - automatically redirects to OAuth provider (Google, etc.) try: redirect_uri = url_for("integrations.oauth_callback", provider=provider, _external=True) auth_url = connector.get_authorization_url(redirect_uri, state=state) + # Automatically redirect to Google OAuth - user will authorize there return redirect(auth_url) except ValueError as e: - flash(_("Integration not configured: {error}").format(error=str(e)), "error") + # OAuth credentials not configured yet + if provider == "google_calendar": + if current_user.is_admin: + flash(_("Google Calendar OAuth credentials need to be configured first. Redirecting to setup..."), "info") + return redirect(url_for("admin.integration_setup", provider=provider)) + else: + flash(_("Google Calendar integration needs to be configured by an administrator first."), "warning") + elif current_user.is_admin: + flash(_("OAuth credentials not configured. Please set them up in Admin → Integrations."), "error") + return redirect(url_for("admin.integration_setup", provider=provider)) + else: + flash(_("Integration not configured. Please ask an administrator to set up OAuth credentials."), "error") return redirect(url_for("integrations.list_integrations")) @@ -91,8 +125,12 @@ def oauth_callback(provider): flash(_("Authorization code not received."), "error") return redirect(url_for("integrations.list_integrations")) - # Find integration for this user and provider - integration = Integration.query.filter_by(provider=provider, user_id=current_user.id).first() + # Find integration (global or per-user) + is_global = (provider != "google_calendar") + if is_global: + integration = service.get_global_integration(provider) + else: + integration = Integration.query.filter_by(provider=provider, user_id=current_user.id, is_global=False).first() if not integration: flash(_("Integration not found."), "error") @@ -129,8 +167,8 @@ def oauth_callback(provider): extra_data=tokens.get("extra_data", {}), ) - # Test connection - test_result = service.test_connection(integration.id, current_user.id) + # Test connection (use None for user_id if global) + test_result = service.test_connection(integration.id, current_user.id if not integration.is_global else None) if test_result.get("success"): flash(_("Integration connected successfully!"), "success") else: @@ -142,6 +180,9 @@ def oauth_callback(provider): "warning", ) + # Redirect to admin setup page for global integrations, view page for per-user + if integration.is_global and current_user.is_admin: + return redirect(url_for("admin.integration_setup", provider=provider)) return redirect(url_for("integrations.view_integration", integration_id=integration.id)) except Exception as e: @@ -155,7 +196,8 @@ def oauth_callback(provider): def view_integration(integration_id): """View integration details.""" service = IntegrationService() - integration = service.get_integration(integration_id, current_user.id) + # Allow viewing global integrations for all users, per-user only for owner + integration = service.get_integration(integration_id, current_user.id if not current_user.is_admin else None) if not integration: flash(_("Integration not found."), "error") @@ -163,9 +205,17 @@ def view_integration(integration_id): connector = service.get_connector(integration) credentials = IntegrationCredential.query.filter_by(integration_id=integration_id).first() + + # Get recent sync events + from app.models import IntegrationEvent + recent_events = IntegrationEvent.query.filter_by(integration_id=integration_id).order_by(IntegrationEvent.created_at.desc()).limit(20).all() return render_template( - "integrations/view.html", integration=integration, connector=connector, credentials=credentials + "integrations/view.html", + integration=integration, + connector=connector, + credentials=credentials, + recent_events=recent_events ) @@ -174,7 +224,13 @@ def view_integration(integration_id): def test_integration(integration_id): """Test integration connection.""" service = IntegrationService() - result = service.test_connection(integration_id, current_user.id) + # Allow testing global integrations for all users + integration = service.get_integration(integration_id, current_user.id if not current_user.is_admin else None) + if not integration: + flash(_("Integration not found."), "error") + return redirect(url_for("integrations.list_integrations")) + + result = service.test_connection(integration_id, current_user.id if not integration.is_global else None) if result.get("success"): flash(_("Connection test successful!"), "success") @@ -189,6 +245,11 @@ def test_integration(integration_id): def delete_integration(integration_id): """Delete an integration.""" service = IntegrationService() + integration = service.get_integration(integration_id, current_user.id if not current_user.is_admin else None) + if not integration: + flash(_("Integration not found."), "error") + return redirect(url_for("integrations.list_integrations")) + result = service.delete_integration(integration_id, current_user.id) if result["success"]: @@ -204,7 +265,7 @@ def delete_integration(integration_id): def sync_integration(integration_id): """Trigger a sync for an integration.""" service = IntegrationService() - integration = service.get_integration(integration_id, current_user.id) + integration = service.get_integration(integration_id, current_user.id if not current_user.is_admin else None) if not integration: flash(_("Integration not found."), "error") @@ -226,3 +287,64 @@ def sync_integration(integration_id): flash(_("Error during sync: %(error)s", error=str(e)), "error") return redirect(url_for("integrations.view_integration", integration_id=integration_id)) + + +@integrations_bp.route("/integrations//webhook", methods=["POST"]) +def integration_webhook(provider): + """Handle incoming webhooks from integration providers.""" + service = IntegrationService() + + # Check if provider is available + if provider not in service._connector_registry: + logger.warning(f"Webhook received for unknown provider: {provider}") + return jsonify({"error": "Unknown provider"}), 404 + + # Get webhook payload + payload = request.get_json(silent=True) or request.form.to_dict() + headers = dict(request.headers) + + # Find active integrations for this provider + # Note: For webhooks, we might need to identify which integration based on payload + integrations = Integration.query.filter_by(provider=provider, is_active=True).all() + + if not integrations: + logger.warning(f"No active integrations found for provider: {provider}") + return jsonify({"error": "No active integration found"}), 404 + + results = [] + for integration in integrations: + try: + connector = service.get_connector(integration) + if not connector: + continue + + # Handle webhook + result = connector.handle_webhook(payload, headers) + results.append({ + "integration_id": integration.id, + "success": result.get("success", False), + "message": result.get("message", "") + }) + + # Log event + if result.get("success"): + service._log_event( + integration.id, + "webhook_received", + True, + f"Webhook processed successfully", + {"provider": provider, "event_type": payload.get("event_type", "unknown")} + ) + except Exception as e: + logger.error(f"Error handling webhook for integration {integration.id}: {e}", exc_info=True) + results.append({ + "integration_id": integration.id, + "success": False, + "message": str(e) + }) + + # Return success if at least one integration processed the webhook + if any(r["success"] for r in results): + return jsonify({"success": True, "results": results}), 200 + else: + return jsonify({"success": False, "results": results}), 500 \ No newline at end of file diff --git a/app/routes/setup.py b/app/routes/setup.py index 5b085cd..60bb841 100644 --- a/app/routes/setup.py +++ b/app/routes/setup.py @@ -8,7 +8,9 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, from flask_login import login_required, current_user from flask_babel import _ from app.utils.installation import get_installation_config -from app import log_event, track_event +from app import log_event, track_event, db +from app.models import Settings +from app.utils.db import safe_commit setup_bp = Blueprint("setup", __name__) @@ -26,17 +28,35 @@ def initial_setup(): # Get telemetry preference telemetry_enabled = request.form.get("telemetry_enabled") == "on" + # Save OAuth credentials if provided + settings = Settings.get_settings() + + # Google Calendar OAuth credentials + google_client_id = request.form.get("google_calendar_client_id", "").strip() + google_client_secret = request.form.get("google_calendar_client_secret", "").strip() + if google_client_id: + settings.google_calendar_client_id = google_client_id + if google_client_secret: + settings.google_calendar_client_secret = google_client_secret + + # Save settings if any OAuth credentials were provided + if google_client_id or google_client_secret: + safe_commit("setup_oauth_credentials", {"provider": "google_calendar"}) + # Save preference installation_config.mark_setup_complete(telemetry_enabled=telemetry_enabled) # Log the setup completion - log_event("setup.completed", telemetry_enabled=telemetry_enabled) + log_event("setup.completed", telemetry_enabled=telemetry_enabled, oauth_configured=bool(google_client_id)) # Show success message if telemetry_enabled: flash(_("Setup complete! Thank you for helping us improve TimeTracker."), "success") else: flash(_("Setup complete! Telemetry is disabled."), "success") + + if google_client_id: + flash(_("Google Calendar OAuth credentials have been configured."), "success") return redirect(url_for("main.dashboard")) diff --git a/app/services/integration_service.py b/app/services/integration_service.py index 3844c0b..badcd55 100644 --- a/app/services/integration_service.py +++ b/app/services/integration_service.py @@ -53,16 +53,17 @@ class IntegrationService: return connector_class(integration, credentials) def create_integration( - self, provider: str, user_id: int, name: Optional[str] = None, config: Optional[Dict] = None + self, provider: str, user_id: Optional[int] = None, name: Optional[str] = None, config: Optional[Dict] = None, is_global: bool = False ) -> Dict[str, Any]: """ Create a new integration. Args: provider: Provider identifier (e.g., 'jira', 'slack') - user_id: User ID who owns the integration + user_id: User ID who owns the integration (None for global integrations) name: Optional custom name config: Optional configuration dict + is_global: Whether this is a global (shared) integration Returns: Dict with 'success', 'message', and 'integration' @@ -70,11 +71,24 @@ class IntegrationService: if provider not in self._connector_registry: return {"success": False, "message": f"Provider {provider} is not available."} - # Check if user already has this integration - existing = Integration.query.filter_by(provider=provider, user_id=user_id).first() + # Google Calendar is always per-user, all others are global + if provider == "google_calendar": + is_global = False + if not user_id: + return {"success": False, "message": "Google Calendar integration requires a user_id."} + else: + is_global = True + user_id = None # Global integrations don't have user_id - if existing: - return {"success": False, "message": f"You already have a {provider} integration."} + # Check if integration already exists + if is_global: + existing = Integration.query.filter_by(provider=provider, is_global=True).first() + if existing: + return {"success": False, "message": f"A global {provider} integration already exists."} + else: + existing = Integration.query.filter_by(provider=provider, user_id=user_id, is_global=False).first() + if existing: + return {"success": False, "message": f"You already have a {provider} integration."} connector_class = self._connector_registry[provider] display_name = connector_class.display_name if hasattr(connector_class, "display_name") else provider.title() @@ -83,28 +97,55 @@ class IntegrationService: name=name or display_name, provider=provider, user_id=user_id, + is_global=is_global, config=config or {}, is_active=False, # Only active when credentials are set up ) db.session.add(integration) - if not safe_commit("create_integration", {"provider": provider, "user_id": user_id}): + if not safe_commit("create_integration", {"provider": provider, "user_id": user_id, "is_global": is_global}): return {"success": False, "message": "Could not create integration due to a database error."} emit_event( WebhookEvent.INTEGRATION_CREATED, - {"integration_id": integration.id, "provider": provider, "user_id": user_id}, + {"integration_id": integration.id, "provider": provider, "user_id": user_id, "is_global": is_global}, ) return {"success": True, "message": "Integration created successfully.", "integration": integration} - def get_integration(self, integration_id: int, user_id: int) -> Optional[Integration]: - """Get integration by ID (with user check).""" - return Integration.query.filter_by(id=integration_id, user_id=user_id).first() + def get_integration(self, integration_id: int, user_id: Optional[int] = None) -> Optional[Integration]: + """Get integration by ID (with user check for per-user integrations).""" + integration = Integration.query.get(integration_id) + if not integration: + return None + + # Global integrations are accessible to all users + if integration.is_global: + return integration + + # Per-user integrations require user_id match + if user_id and integration.user_id == user_id: + return integration + + return None - def list_integrations(self, user_id: int) -> List[Integration]: - """List all integrations for a user.""" - integrations = Integration.query.filter_by(user_id=user_id).order_by(Integration.created_at.desc()).all() + def list_integrations(self, user_id: Optional[int] = None) -> List[Integration]: + """List all integrations accessible to a user (global + their per-user).""" + from sqlalchemy import or_ + + # Get global integrations + user's per-user integrations + if user_id: + query = Integration.query.filter( + or_( + Integration.is_global == True, + Integration.user_id == user_id + ) + ) + else: + # Admin view: show all + query = Integration.query + + integrations = query.order_by(Integration.is_global.desc(), Integration.created_at.desc()).all() # Sync is_active status with credentials existence for integration in integrations: @@ -116,12 +157,23 @@ class IntegrationService: safe_commit("sync_integration_active_status", {"integration_id": integration.id}) return integrations + + def get_global_integration(self, provider: str) -> Optional[Integration]: + """Get global integration for a provider.""" + return Integration.query.filter_by(provider=provider, is_global=True).first() - def delete_integration(self, integration_id: int, user_id: int) -> Dict[str, Any]: + def delete_integration(self, integration_id: int, user_id: Optional[int] = None) -> Dict[str, Any]: """Delete an integration.""" integration = self.get_integration(integration_id, user_id) if not integration: return {"success": False, "message": "Integration not found."} + + # Only admins can delete global integrations + if integration.is_global: + from app.models import User + user = User.query.get(user_id) if user_id else None + if not user or not user.is_admin: + return {"success": False, "message": "Only administrators can delete global integrations."} db.session.delete(integration) if not safe_commit("delete_integration", {"integration_id": integration_id}): @@ -170,7 +222,7 @@ class IntegrationService: return {"success": True, "message": "Credentials saved successfully.", "credentials": credentials} - def test_connection(self, integration_id: int, user_id: int) -> Dict[str, Any]: + def test_connection(self, integration_id: int, user_id: Optional[int] = None) -> Dict[str, Any]: """Test connection to integrated service.""" integration = self.get_integration(integration_id, user_id) if not integration: diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html index 7d3933c..5059155 100644 --- a/app/templates/admin/dashboard.html +++ b/app/templates/admin/dashboard.html @@ -41,7 +41,12 @@
Webhooks
-
Integrations
+
Outgoing Events
+
+ + +
Integrations
+
OAuth Setup
diff --git a/app/templates/admin/integrations/list.html b/app/templates/admin/integrations/list.html new file mode 100644 index 0000000..d173780 --- /dev/null +++ b/app/templates/admin/integrations/list.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block title %}{{ _('Integration Setup') }} - {{ app_name }}{% endblock %} + +{% block content %} +{% set breadcrumbs = [ + {'text': 'Admin', 'url': url_for('admin.admin_dashboard')}, + {'text': 'Integrations'} +] %} + +{{ page_header( + icon_class='fas fa-plug', + title_text='Integration Setup', + subtitle_text='Configure OAuth credentials for integrations', + breadcrumbs=breadcrumbs +) }} + + +{% endblock %} + diff --git a/app/templates/admin/integrations/setup.html b/app/templates/admin/integrations/setup.html new file mode 100644 index 0000000..58d0bb2 --- /dev/null +++ b/app/templates/admin/integrations/setup.html @@ -0,0 +1,191 @@ +{% extends "base.html" %} +{% from "components/ui.html" import page_header %} + +{% block title %}{{ display_name }} {{ _('Setup') }} - {{ app_name }}{% endblock %} + +{% block content %} +{% set breadcrumbs = [ + {'text': 'Admin', 'url': url_for('admin.admin_dashboard')}, + {'text': 'Integrations', 'url': url_for('admin.list_integrations_admin')}, + {'text': display_name + ' Setup'} +] %} + +{{ page_header( + icon_class='fas fa-plug', + title_text=display_name + ' ' + _('Setup'), + subtitle_text=description, + breadcrumbs=breadcrumbs +) }} + +
+
+ + + {% if provider == 'trello' %} + +
+
+ + +

+ {{ _('Get your API key from') }} trello.com/app-key +

+
+ +
+ + +

+ {{ _('Generate a token with your API key. Visit') }} + trello.com/1/authorize + {{ _('(replace YOUR_API_KEY with your actual API key)') }} +

+
+
+ + {% else %} + +
+
+ + +
+ +
+ + +
+ + {% if provider in ['outlook_calendar', 'microsoft_teams'] %} +
+ + +
+ {% endif %} + + {% if provider == 'gitlab' %} +
+ + +
+ {% endif %} +
+ +
+

{{ _('OAuth Redirect URI') }}

+

+ {{ _('Add this URL as an authorized redirect URI in your OAuth app settings:') }} +

+ + {{ url_for('integrations.oauth_callback', provider=provider, _external=True) }} + + {% if provider == 'google_calendar' %} +
+

+ {{ _('Automatic Connection Flow') }} +

+
    +
  • {{ _('After you save these credentials, users can click "Connect Google Calendar"') }}
  • +
  • {{ _('They will be automatically redirected to Google OAuth') }}
  • +
  • {{ _('No manual credential entry needed - fully automatic!') }}
  • +
  • {{ _('Each user connects their own Google Calendar account') }}
  • +
+
+ {% endif %} +
+ {% endif %} + +
+ + + {{ _('Cancel') }} + +
+
+
+ +{% if provider == 'google_calendar' %} +
+

{{ _('How Google Calendar Works') }}

+
+

+ + {{ _('After you save the OAuth credentials above, users can connect their Google Calendar by clicking "Connect Google Calendar" on the Integrations page.') }} +

+

+ + {{ _('They will be automatically redirected to Google to authorize access - no manual credential entry needed!') }} +

+

+ + {{ _('Each user connects their own Google Calendar account (per-user integration).') }} +

+
+
+{% elif integration and integration.is_active %} +
+

{{ _('Connection Status') }}

+
+ + {{ _('Connected') }} + + + {{ _('View Integration') }} + +
+
+{% elif integration %} +
+

{{ _('Next Steps') }}

+

+ {{ _('After saving credentials, connect the integration:') }} +

+ + {{ _('Connect Integration') }} + +
+{% endif %} +{% endblock %} + diff --git a/app/templates/admin/settings.html b/app/templates/admin/settings.html index 4830215..9a0f8b5 100644 --- a/app/templates/admin/settings.html +++ b/app/templates/admin/settings.html @@ -435,6 +435,178 @@ + + +
+

+ Google Calendar +

+
+
+ + +
+
+ + + {% if settings.google_calendar_client_secret_set %} +

{{ _('✓ Client secret is configured') }}

+ {% endif %} +
+
+
+ + +
+

+ Outlook Calendar +

+
+
+ + +
+
+ + + {% if settings.outlook_calendar_client_secret_set %} +

{{ _('✓ Client secret is configured') }}

+ {% endif %} +
+
+ + +
+
+
+ + +
+

+ Microsoft Teams +

+
+
+ + +
+
+ + + {% if settings.microsoft_teams_client_secret_set %} +

{{ _('✓ Client secret is configured') }}

+ {% endif %} +
+
+ + +
+
+
+ + +
+

+ Asana +

+
+
+ + +
+
+ + + {% if settings.asana_client_secret_set %} +

{{ _('✓ Client secret is configured') }}

+ {% endif %} +
+
+
+ + +
+

+ Trello +

+
+
+ + +
+
+ + + {% if settings.trello_api_secret_set %} +

{{ _('✓ API secret is configured') }}

+ {% endif %} +
+
+
+ + +
+

+ GitLab +

+
+
+ + +
+
+ + + {% if settings.gitlab_client_secret_set %} +

{{ _('✓ Client secret is configured') }}

+ {% endif %} +
+
+ + +
+
+
+ + +
+

+ QuickBooks Online +

+
+
+ + +
+
+ + + {% if settings.quickbooks_client_secret_set %} +

{{ _('✓ Client secret is configured') }}

+ {% endif %} +
+
+
+ + +
+

+ Xero +

+
+
+ + +
+
+ + + {% if settings.xero_client_secret_set %} +

{{ _('✓ Client secret is configured') }}

+ {% endif %} +
+
+
diff --git a/app/templates/calendar/integrations.html b/app/templates/calendar/integrations.html index db3e685..55e7bff 100644 --- a/app/templates/calendar/integrations.html +++ b/app/templates/calendar/integrations.html @@ -26,12 +26,12 @@

- {% if integration.provider == 'google' %} + {% if integration.provider == 'google' or integration.provider == 'google_calendar' %} Google Calendar - {% elif integration.provider == 'outlook' %} + {% elif integration.provider == 'outlook' or integration.provider == 'outlook_calendar' %} Outlook Calendar {% else %} - {{ integration.provider|title }} Calendar + {{ integration.provider|title|replace('_', ' ') }} Calendar {% endif %}

{% if integration.is_active %} @@ -45,6 +45,10 @@

{{ integration.calendar_name }}

+ {% elif integration.config and integration.config.get('calendar_id') %} +

+ {{ integration.config.get('calendar_id') }} +

{% endif %} {% if integration.last_sync_at %} @@ -63,10 +67,15 @@
+ {% if integration.provider in ['google_calendar', 'outlook_calendar'] %} + + + + {% endif %} {% if integration.is_active %}
-
diff --git a/app/templates/integrations/list.html b/app/templates/integrations/list.html index dc1d87f..a607dcd 100644 --- a/app/templates/integrations/list.html +++ b/app/templates/integrations/list.html @@ -38,15 +38,32 @@ {{ _('View Integration') }} {% else %} + {% if provider.provider == 'trello' %} + + {{ _('Configure') }} + + {% else %} {{ _('Reconnect') }} {% endif %} + {% endif %} + {% else %} + {% if provider.provider == 'trello' %} + + {{ _('Setup') }} + + {% elif provider.provider == 'google_calendar' %} + + {{ _('Connect Google Calendar') }} +
{{ _('Automatically redirects to Google') }}
+
{% else %} {{ _('Connect') }} {% endif %} + {% endif %}
{% endfor %}
@@ -61,7 +78,10 @@

{{ integration.name }}

- {{ integration.provider|title }} + {{ integration.provider|title|replace('_', ' ') }} + {% if integration.is_global %} + {{ _('Global') }} + {% endif %} {% if integration.last_sync_at %} • {{ _('Last synced') }}: {{ integration.last_sync_at.strftime('%Y-%m-%d %H:%M') }} {% endif %} diff --git a/app/templates/integrations/view.html b/app/templates/integrations/view.html index af0f3fc..c77d9fc 100644 --- a/app/templates/integrations/view.html +++ b/app/templates/integrations/view.html @@ -46,11 +46,61 @@ {% if integration.last_sync_at %}

{{ _('Last Sync') }}
-
{{ integration.last_sync_at.strftime('%Y-%m-%d %H:%M') }}
+
+ {{ integration.last_sync_at.strftime('%Y-%m-%d %H:%M') }} + {% if integration.last_sync_status %} + {% if integration.last_sync_status == 'success' %} + {{ _('Success') }} + {% elif integration.last_sync_status == 'error' %} + {{ _('Error') }} + {% else %} + {{ _('Pending') }} + {% endif %} + {% endif %} +
+
+ {% endif %} + {% if integration.last_error %} +
+
{{ _('Last Error') }}
+
{{ integration.last_error[:100] }}{% if integration.last_error|length > 100 %}...{% endif %}
{% endif %}
+ + +
+

{{ _('Sync History') }}

+ {% if recent_events %} +
+ {% for event in recent_events %} +
+
+
+
+ {{ event.event_type|replace('_', ' ')|title }} + {% if event.status == 'success' %} + {{ _('Success') }} + {% elif event.status == 'error' %} + {{ _('Error') }} + {% else %} + {{ _('Pending') }} + {% endif %} +
+ {% if event.message %} +

{{ event.message }}

+ {% endif %} +

{{ event.created_at.strftime('%Y-%m-%d %H:%M:%S') }}

+
+
+
+ {% endfor %} +
+ {% else %} +

{{ _('No sync events yet') }}

+ {% endif %} +
diff --git a/app/templates/setup/initial_setup.html b/app/templates/setup/initial_setup.html index aedac3f..ad1e221 100644 --- a/app/templates/setup/initial_setup.html +++ b/app/templates/setup/initial_setup.html @@ -76,6 +76,59 @@

+ +
+

🔌 {{ _('Integration Setup (Optional)') }}

+

+ {{ _('Configure OAuth credentials now to enable calendar and other integrations. You can also configure these later in Admin → Settings.') }} +

+ + +
+ +
+
+ +
+
+ +
+
+

+ {{ _('Get these from') }} {{ _('Google Cloud Console') }} +

+
+ +
+ + {{ _('How to get Google Calendar OAuth credentials?') }} + +
+
    +
  1. {{ _('Go to') }} {{ _('Google Cloud Console') }}
  2. +
  3. {{ _('Create a new project or select an existing one') }}
  4. +
  5. {{ _('Enable the Google Calendar API') }}
  6. +
  7. {{ _('Go to Credentials → Create Credentials → OAuth 2.0 Client ID') }}
  8. +
  9. {{ _('Set application type to "Web application"') }}
  10. +
  11. {{ _('Add authorized redirect URI:') }} {{ url_for('integrations.oauth_callback', provider='google_calendar', _external=True) }}
  12. +
  13. {{ _('Copy the Client ID and Client Secret') }}
  14. +
+
+
+
+

📊 {{ _('Help Us Improve (Optional)') }}

diff --git a/app/utils/scheduled_tasks.py b/app/utils/scheduled_tasks.py index 0019587..81d23af 100644 --- a/app/utils/scheduled_tasks.py +++ b/app/utils/scheduled_tasks.py @@ -4,10 +4,11 @@ import logging from datetime import datetime, timedelta from flask import current_app from app import db -from app.models import Invoice, User, TimeEntry, Project, BudgetAlert, RecurringInvoice, Quote, ReportEmailSchedule +from app.models import Invoice, User, TimeEntry, Project, BudgetAlert, RecurringInvoice, Quote, ReportEmailSchedule, Integration from app.utils.email import send_overdue_invoice_notification, send_weekly_summary, send_quote_expired_notification from app.utils.budget_forecasting import check_budget_alerts from app.services.scheduled_report_service import ScheduledReportService +from app.services.integration_service import IntegrationService logger = logging.getLogger(__name__) @@ -353,6 +354,30 @@ def register_scheduled_tasks(scheduler, app=None): ) logger.info("Registered expiring quotes check task") + # Sync integrations every hour + def sync_integrations_with_app(): + """Wrapper that uses the captured app instance""" + app_instance = app + if app_instance is None: + try: + app_instance = current_app._get_current_object() + except RuntimeError: + logger.error("No app instance available for integration sync") + return + + with app_instance.app_context(): + sync_integrations() + + scheduler.add_job( + func=sync_integrations_with_app, + trigger="cron", + minute=0, # Every hour at minute 0 + id="sync_integrations", + name="Sync all active integrations", + replace_existing=True, + ) + logger.info("Registered integration sync task") + # Process scheduled reports every hour scheduler.add_job( func=process_scheduled_reports, @@ -364,6 +389,30 @@ def register_scheduled_tasks(scheduler, app=None): ) logger.info("Registered scheduled reports task") + # Sync integrations every hour + def sync_integrations_with_app(): + """Wrapper that uses the captured app instance""" + app_instance = app + if app_instance is None: + try: + app_instance = current_app._get_current_object() + except RuntimeError: + logger.error("No app instance available for integration sync") + return + + with app_instance.app_context(): + sync_integrations() + + scheduler.add_job( + func=sync_integrations_with_app, + trigger="cron", + minute=0, # Every hour at minute 0 + id="sync_integrations", + name="Sync all active integrations", + replace_existing=True, + ) + logger.info("Registered integration sync task") + except Exception as e: logger.error(f"Error registering scheduled tasks: {e}") @@ -493,3 +542,77 @@ def check_expiring_quotes(): except Exception as e: logger.error(f"Error checking expiring quotes: {e}") return 0 + + +def sync_integrations(): + """Sync all active integrations + + This task should be run periodically to sync data from all active integrations. + It will only sync integrations that have auto_sync enabled in their config. + """ + try: + logger.info("Starting integration sync...") + + # Get all active integrations + active_integrations = Integration.query.filter_by(is_active=True).all() + + logger.info(f"Found {len(active_integrations)} active integrations") + + service = IntegrationService() + synced_count = 0 + errors = [] + + for integration in active_integrations: + try: + # Check if auto_sync is enabled (default to True if not set) + config = integration.config or {} + auto_sync = config.get("auto_sync", True) + + if not auto_sync: + logger.debug(f"Skipping integration {integration.id} ({integration.provider}): auto_sync disabled") + continue + + # Get connector + connector = service.get_connector(integration) + if not connector: + logger.warning(f"Could not get connector for integration {integration.id} ({integration.provider})") + continue + + # Perform sync + logger.info(f"Syncing integration {integration.id} ({integration.provider})...") + result = connector.sync_data(sync_type="incremental") + + if result.get("success"): + synced_count += 1 + # Update last sync time + integration.last_sync_at = datetime.utcnow() + integration.last_sync_status = "success" + integration.last_error = None + logger.info( + f"Successfully synced integration {integration.id} ({integration.provider}): {result.get('synced_items', 0)} items" + ) + else: + errors.append(f"{integration.provider}: {result.get('message', 'Unknown error')}") + integration.last_sync_status = "error" + integration.last_error = result.get("message", "Unknown error") + logger.error(f"Failed to sync integration {integration.id} ({integration.provider}): {result.get('message')}") + + db.session.commit() + + except Exception as e: + error_msg = f"Error syncing integration {integration.id} ({integration.provider}): {str(e)}" + errors.append(error_msg) + logger.error(error_msg, exc_info=True) + integration.last_sync_status = "error" + integration.last_error = str(e) + db.session.commit() + + logger.info(f"Integration sync completed. Synced {synced_count}/{len(active_integrations)} integrations") + if errors: + logger.warning(f"Integration sync errors: {', '.join(errors)}") + + return {"synced": synced_count, "total": len(active_integrations), "errors": errors} + + except Exception as e: + logger.error(f"Error in integration sync task: {e}", exc_info=True) + return {"synced": 0, "total": 0, "errors": [str(e)]} diff --git a/migrations/versions/081_add_all_integration_credentials.py b/migrations/versions/081_add_all_integration_credentials.py new file mode 100644 index 0000000..52e461e --- /dev/null +++ b/migrations/versions/081_add_all_integration_credentials.py @@ -0,0 +1,114 @@ +"""Add all integration OAuth credentials to Settings model + +Revision ID: 081_add_int_oauth_creds +Revises: 080_fix_metadata_column_names +Create Date: 2025-01-15 12:00:00 + +This migration adds OAuth credential columns for all integrations: +- Google Calendar +- Outlook Calendar +- Microsoft Teams +- Asana +- Trello +- GitLab +- QuickBooks +- Xero +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '081_add_int_oauth_creds' +down_revision = '080_fix_metadata_column_names' +branch_labels = None +depends_on = None + + +def upgrade(): + """Add integration OAuth credential columns to settings table""" + with op.batch_alter_table('settings', schema=None) as batch_op: + # Google Calendar + batch_op.add_column(sa.Column('google_calendar_client_id', sa.String(length=255), nullable=True)) + batch_op.add_column(sa.Column('google_calendar_client_secret', sa.String(length=255), nullable=True)) + + # Outlook Calendar + batch_op.add_column(sa.Column('outlook_calendar_client_id', sa.String(length=255), nullable=True)) + batch_op.add_column(sa.Column('outlook_calendar_client_secret', sa.String(length=255), nullable=True)) + batch_op.add_column(sa.Column('outlook_calendar_tenant_id', sa.String(length=255), nullable=True)) + + # Microsoft Teams + batch_op.add_column(sa.Column('microsoft_teams_client_id', sa.String(length=255), nullable=True)) + batch_op.add_column(sa.Column('microsoft_teams_client_secret', sa.String(length=255), nullable=True)) + batch_op.add_column(sa.Column('microsoft_teams_tenant_id', sa.String(length=255), nullable=True)) + + # Asana + batch_op.add_column(sa.Column('asana_client_id', sa.String(length=255), nullable=True)) + batch_op.add_column(sa.Column('asana_client_secret', sa.String(length=255), nullable=True)) + + # Trello + batch_op.add_column(sa.Column('trello_api_key', sa.String(length=255), nullable=True)) + batch_op.add_column(sa.Column('trello_api_secret', sa.String(length=255), nullable=True)) + + # GitLab + batch_op.add_column(sa.Column('gitlab_client_id', sa.String(length=255), nullable=True)) + batch_op.add_column(sa.Column('gitlab_client_secret', sa.String(length=255), nullable=True)) + batch_op.add_column(sa.Column('gitlab_instance_url', sa.String(length=500), nullable=True)) + + # QuickBooks + batch_op.add_column(sa.Column('quickbooks_client_id', sa.String(length=255), nullable=True)) + batch_op.add_column(sa.Column('quickbooks_client_secret', sa.String(length=255), nullable=True)) + + # Xero + batch_op.add_column(sa.Column('xero_client_id', sa.String(length=255), nullable=True)) + batch_op.add_column(sa.Column('xero_client_secret', sa.String(length=255), nullable=True)) + + # Set default empty values for existing rows + op.execute(""" + UPDATE settings + SET google_calendar_client_id = '', + google_calendar_client_secret = '', + outlook_calendar_client_id = '', + outlook_calendar_client_secret = '', + outlook_calendar_tenant_id = '', + microsoft_teams_client_id = '', + microsoft_teams_client_secret = '', + microsoft_teams_tenant_id = '', + asana_client_id = '', + asana_client_secret = '', + trello_api_key = '', + trello_api_secret = '', + gitlab_client_id = '', + gitlab_client_secret = '', + gitlab_instance_url = '', + quickbooks_client_id = '', + quickbooks_client_secret = '', + xero_client_id = '', + xero_client_secret = '' + WHERE google_calendar_client_id IS NULL + """) + + +def downgrade(): + """Remove integration credential columns from settings table""" + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.drop_column('xero_client_secret') + batch_op.drop_column('xero_client_id') + batch_op.drop_column('quickbooks_client_secret') + batch_op.drop_column('quickbooks_client_id') + batch_op.drop_column('gitlab_instance_url') + batch_op.drop_column('gitlab_client_secret') + batch_op.drop_column('gitlab_client_id') + batch_op.drop_column('trello_api_secret') + batch_op.drop_column('trello_api_key') + batch_op.drop_column('asana_client_secret') + batch_op.drop_column('asana_client_id') + batch_op.drop_column('microsoft_teams_tenant_id') + batch_op.drop_column('microsoft_teams_client_secret') + batch_op.drop_column('microsoft_teams_client_id') + batch_op.drop_column('outlook_calendar_tenant_id') + batch_op.drop_column('outlook_calendar_client_secret') + batch_op.drop_column('outlook_calendar_client_id') + batch_op.drop_column('google_calendar_client_secret') + batch_op.drop_column('google_calendar_client_id') + diff --git a/migrations/versions/082_add_global_integrations.py b/migrations/versions/082_add_global_integrations.py new file mode 100644 index 0000000..a8cb0ba --- /dev/null +++ b/migrations/versions/082_add_global_integrations.py @@ -0,0 +1,51 @@ +"""Add global integrations support + +Revision ID: 082_add_global_integrations +Revises: 081_add_int_oauth_creds +Create Date: 2025-01-20 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '082_add_global_integrations' +down_revision = '081_add_int_oauth_creds' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('integrations', schema=None) as batch_op: + # Add is_global flag + batch_op.add_column(sa.Column('is_global', sa.Boolean(), nullable=False, server_default='0')) + + # Make user_id nullable for global integrations + batch_op.alter_column('user_id', + existing_type=sa.Integer(), + nullable=True) + + # Add index for global integrations + batch_op.create_index('ix_integrations_is_global', ['is_global'], unique=False) + + # Note: Unique constraint for global integrations enforced at application level + # (one global integration per provider) since SQLite doesn't support partial indexes + + +def downgrade(): + with op.batch_alter_table('integrations', schema=None) as batch_op: + # Remove index + batch_op.drop_index('ix_integrations_is_global') + + # Make user_id required again (set to first user for existing records) + # First, set user_id for any null values + op.execute("UPDATE integrations SET user_id = (SELECT id FROM users LIMIT 1) WHERE user_id IS NULL") + + batch_op.alter_column('user_id', + existing_type=sa.Integer(), + nullable=False) + + # Remove is_global column + batch_op.drop_column('is_global') +