mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-12 15:29:23 -05:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
+155
-4
@@ -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": [],
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
+145
-4
@@ -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"],
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
+121
-4
@@ -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"<Settings {self.id}>"
|
||||
@@ -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)
|
||||
|
||||
+261
-22
@@ -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/<provider>/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", ""),
|
||||
)
|
||||
|
||||
+7
-19
@@ -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/<int:integration_id>/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))
|
||||
|
||||
+146
-24
@@ -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/<provider>/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/<provider>/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
|
||||
+22
-2
@@ -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"))
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -41,7 +41,12 @@
|
||||
<a href="{{ url_for('webhooks.list_webhooks') }}" class="bg-indigo-600 text-white p-4 rounded-lg text-center hover:bg-indigo-700">
|
||||
<i class="fas fa-plug mb-2"></i>
|
||||
<div>Webhooks</div>
|
||||
<div class="text-xs mt-1 opacity-90">Integrations</div>
|
||||
<div class="text-xs mt-1 opacity-90">Outgoing Events</div>
|
||||
</a>
|
||||
<a href="{{ url_for('admin.list_integrations_admin') }}" class="bg-purple-600 text-white p-4 rounded-lg text-center hover:bg-purple-700">
|
||||
<i class="fas fa-plug mb-2"></i>
|
||||
<div>Integrations</div>
|
||||
<div class="text-xs mt-1 opacity-90">OAuth Setup</div>
|
||||
</a>
|
||||
<a href="{{ url_for('admin.email_support') }}" class="bg-purple-600 text-white p-4 rounded-lg text-center hover:bg-purple-700">
|
||||
<i class="fas fa-envelope mb-2"></i>
|
||||
|
||||
@@ -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
|
||||
) }}
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
|
||||
{{ _('Configure OAuth credentials for each integration. Global integrations are shared across all users.') }}
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{% for provider_info in available_providers %}
|
||||
{% set provider = provider_info.provider %}
|
||||
{% set is_global = (provider != 'google_calendar') %}
|
||||
<a href="{{ url_for('admin.integration_setup', provider=provider) }}"
|
||||
class="block p-4 border border-border-light dark:border-border-dark rounded-lg hover:bg-background-light dark:hover:bg-background-dark transition-colors">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-semibold">{{ provider_info.display_name }}</h3>
|
||||
{% if is_global %}
|
||||
<span class="px-2 py-1 text-xs rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
{{ _('Global') }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 text-xs rounded bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
{{ _('Per User') }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ provider_info.description }}</p>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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
|
||||
) }}
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
{% if provider == 'trello' %}
|
||||
<!-- Trello API Key Setup -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="trello_api_key" class="block text-sm font-medium mb-2">
|
||||
{{ _('Trello API Key') }}
|
||||
</label>
|
||||
<input type="text"
|
||||
name="trello_api_key"
|
||||
id="trello_api_key"
|
||||
value="{{ current_creds.get('api_key', '') }}"
|
||||
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
|
||||
placeholder="{{ _('Get from https://trello.com/app-key') }}">
|
||||
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ _('Get your API key from') }} <a href="https://trello.com/app-key" target="_blank" class="text-primary hover:underline">trello.com/app-key</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="trello_token" class="block text-sm font-medium mb-2">
|
||||
{{ _('Trello Token') }}
|
||||
</label>
|
||||
<input type="password"
|
||||
name="trello_token"
|
||||
id="trello_token"
|
||||
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
|
||||
placeholder="{{ _('Leave empty to keep current value') }}">
|
||||
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ _('Generate a token with your API key. Visit') }}
|
||||
<a href="https://trello.com/1/authorize?expiration=never&scope=read,write&response_type=token&name=TimeTracker&key=YOUR_API_KEY" target="_blank" class="text-primary hover:underline">trello.com/1/authorize</a>
|
||||
{{ _('(replace YOUR_API_KEY with your actual API key)') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- OAuth-based Integrations -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="{{ provider }}_client_id" class="block text-sm font-medium mb-2">
|
||||
{{ _('OAuth Client ID') }}
|
||||
</label>
|
||||
<input type="text"
|
||||
name="{{ provider }}_client_id"
|
||||
id="{{ provider }}_client_id"
|
||||
value="{{ current_creds.get('client_id', '') }}"
|
||||
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
|
||||
placeholder="{{ _('OAuth Client ID') }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="{{ provider }}_client_secret" class="block text-sm font-medium mb-2">
|
||||
{{ _('OAuth Client Secret') }}
|
||||
</label>
|
||||
<input type="password"
|
||||
name="{{ provider }}_client_secret"
|
||||
id="{{ provider }}_client_secret"
|
||||
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
|
||||
placeholder="{{ _('Leave empty to keep current value') }}">
|
||||
</div>
|
||||
|
||||
{% if provider in ['outlook_calendar', 'microsoft_teams'] %}
|
||||
<div>
|
||||
<label for="{{ provider }}_tenant_id" class="block text-sm font-medium mb-2">
|
||||
{{ _('Tenant ID') }} <span class="text-text-muted-light dark:text-text-muted-dark">({{ _('optional') }})</span>
|
||||
</label>
|
||||
<input type="text"
|
||||
name="{{ provider }}_tenant_id"
|
||||
id="{{ provider }}_tenant_id"
|
||||
value="{{ current_creds.get('tenant_id', '') }}"
|
||||
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
|
||||
placeholder="{{ _('Use "common" for multi-tenant') }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if provider == 'gitlab' %}
|
||||
<div>
|
||||
<label for="gitlab_instance_url" class="block text-sm font-medium mb-2">
|
||||
{{ _('GitLab Instance URL') }} <span class="text-text-muted-light dark:text-text-muted-dark">({{ _('optional') }})</span>
|
||||
</label>
|
||||
<input type="url"
|
||||
name="gitlab_instance_url"
|
||||
id="gitlab_instance_url"
|
||||
value="{{ current_creds.get('instance_url', 'https://gitlab.com') }}"
|
||||
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
|
||||
placeholder="https://gitlab.com">
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<h3 class="font-semibold mb-2">{{ _('OAuth Redirect URI') }}</h3>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-2">
|
||||
{{ _('Add this URL as an authorized redirect URI in your OAuth app settings:') }}
|
||||
</p>
|
||||
<code class="block p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs break-all">
|
||||
{{ url_for('integrations.oauth_callback', provider=provider, _external=True) }}
|
||||
</code>
|
||||
{% if provider == 'google_calendar' %}
|
||||
<div class="mt-4 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded">
|
||||
<p class="text-sm text-green-800 dark:text-green-200 font-semibold mb-2">
|
||||
<i class="fas fa-magic mr-2"></i>{{ _('Automatic Connection Flow') }}
|
||||
</p>
|
||||
<ul class="text-sm text-green-700 dark:text-green-300 space-y-1 list-disc list-inside">
|
||||
<li>{{ _('After you save these credentials, users can click "Connect Google Calendar"') }}</li>
|
||||
<li>{{ _('They will be automatically redirected to Google OAuth') }}</li>
|
||||
<li>{{ _('No manual credential entry needed - fully automatic!') }}</li>
|
||||
<li>{{ _('Each user connects their own Google Calendar account') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-6 flex gap-4">
|
||||
<button type="submit" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
|
||||
<i class="fas fa-save mr-2"></i>{{ _('Save Credentials') }}
|
||||
</button>
|
||||
<a href="{{ url_for('admin.list_integrations_admin') }}" class="bg-gray-200 dark:bg-gray-700 px-6 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||
{{ _('Cancel') }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if provider == 'google_calendar' %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mt-6">
|
||||
<h3 class="text-lg font-semibold mb-4">{{ _('How Google Calendar Works') }}</h3>
|
||||
<div class="space-y-3 text-sm text-text-muted-light dark:text-text-muted-dark">
|
||||
<p>
|
||||
<i class="fas fa-info-circle text-blue-500 mr-2"></i>
|
||||
{{ _('After you save the OAuth credentials above, users can connect their Google Calendar by clicking "Connect Google Calendar" on the Integrations page.') }}
|
||||
</p>
|
||||
<p>
|
||||
<i class="fas fa-arrow-right text-green-500 mr-2"></i>
|
||||
{{ _('They will be automatically redirected to Google to authorize access - no manual credential entry needed!') }}
|
||||
</p>
|
||||
<p>
|
||||
<i class="fas fa-user text-purple-500 mr-2"></i>
|
||||
{{ _('Each user connects their own Google Calendar account (per-user integration).') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% elif integration and integration.is_active %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mt-6">
|
||||
<h3 class="text-lg font-semibold mb-4">{{ _('Connection Status') }}</h3>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="px-3 py-1 rounded bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
<i class="fas fa-check-circle mr-2"></i>{{ _('Connected') }}
|
||||
</span>
|
||||
<a href="{{ url_for('integrations.view_integration', integration_id=integration.id) }}" class="text-primary hover:underline">
|
||||
{{ _('View Integration') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% elif integration %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mt-6">
|
||||
<h3 class="text-lg font-semibold mb-4">{{ _('Next Steps') }}</h3>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
|
||||
{{ _('After saving credentials, connect the integration:') }}
|
||||
</p>
|
||||
<a href="{{ url_for('integrations.connect_integration', provider=provider) }}" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors inline-block">
|
||||
<i class="fas fa-link mr-2"></i>{{ _('Connect Integration') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -435,6 +435,178 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Google Calendar -->
|
||||
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<h3 class="text-md font-semibold mb-3 flex items-center">
|
||||
<i class="fab fa-google text-red-600 mr-2"></i>Google Calendar
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="google_calendar_client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
|
||||
<input type="text" name="google_calendar_client_id" id="google_calendar_client_id" value="{{ settings.google_calendar_client_id or '' }}" class="form-input" placeholder="{{ _('Google OAuth Client ID') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="google_calendar_client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
|
||||
<input type="password" name="google_calendar_client_secret" id="google_calendar_client_secret" class="form-input" placeholder="{{ _('Leave empty to keep current value') }}">
|
||||
{% if settings.google_calendar_client_secret_set %}
|
||||
<p class="mt-1 text-xs text-green-600 dark:text-green-400">{{ _('✓ Client secret is configured') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Outlook Calendar -->
|
||||
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<h3 class="text-md font-semibold mb-3 flex items-center">
|
||||
<i class="fab fa-microsoft text-blue-600 mr-2"></i>Outlook Calendar
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="outlook_calendar_client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
|
||||
<input type="text" name="outlook_calendar_client_id" id="outlook_calendar_client_id" value="{{ settings.outlook_calendar_client_id or '' }}" class="form-input" placeholder="{{ _('Microsoft OAuth Client ID') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="outlook_calendar_client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
|
||||
<input type="password" name="outlook_calendar_client_secret" id="outlook_calendar_client_secret" class="form-input" placeholder="{{ _('Leave empty to keep current value') }}">
|
||||
{% if settings.outlook_calendar_client_secret_set %}
|
||||
<p class="mt-1 text-xs text-green-600 dark:text-green-400">{{ _('✓ Client secret is configured') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="outlook_calendar_tenant_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Tenant ID (optional)</label>
|
||||
<input type="text" name="outlook_calendar_tenant_id" id="outlook_calendar_tenant_id" value="{{ settings.outlook_calendar_tenant_id or '' }}" class="form-input" placeholder="{{ _('Microsoft Tenant ID (common for multi-tenant)') }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Microsoft Teams -->
|
||||
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<h3 class="text-md font-semibold mb-3 flex items-center">
|
||||
<i class="fab fa-microsoft-teams text-blue-600 mr-2"></i>Microsoft Teams
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="microsoft_teams_client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
|
||||
<input type="text" name="microsoft_teams_client_id" id="microsoft_teams_client_id" value="{{ settings.microsoft_teams_client_id or '' }}" class="form-input" placeholder="{{ _('Microsoft OAuth Client ID') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="microsoft_teams_client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
|
||||
<input type="password" name="microsoft_teams_client_secret" id="microsoft_teams_client_secret" class="form-input" placeholder="{{ _('Leave empty to keep current value') }}">
|
||||
{% if settings.microsoft_teams_client_secret_set %}
|
||||
<p class="mt-1 text-xs text-green-600 dark:text-green-400">{{ _('✓ Client secret is configured') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="microsoft_teams_tenant_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Tenant ID (optional)</label>
|
||||
<input type="text" name="microsoft_teams_tenant_id" id="microsoft_teams_tenant_id" value="{{ settings.microsoft_teams_tenant_id or '' }}" class="form-input" placeholder="{{ _('Microsoft Tenant ID (common for multi-tenant)') }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Asana -->
|
||||
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<h3 class="text-md font-semibold mb-3 flex items-center">
|
||||
<i class="fab fa-asana text-fuchsia-600 mr-2"></i>Asana
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="asana_client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
|
||||
<input type="text" name="asana_client_id" id="asana_client_id" value="{{ settings.asana_client_id or '' }}" class="form-input" placeholder="{{ _('Asana OAuth Client ID') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="asana_client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
|
||||
<input type="password" name="asana_client_secret" id="asana_client_secret" class="form-input" placeholder="{{ _('Leave empty to keep current value') }}">
|
||||
{% if settings.asana_client_secret_set %}
|
||||
<p class="mt-1 text-xs text-green-600 dark:text-green-400">{{ _('✓ Client secret is configured') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trello -->
|
||||
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<h3 class="text-md font-semibold mb-3 flex items-center">
|
||||
<i class="fab fa-trello text-blue-500 mr-2"></i>Trello
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="trello_api_key" class="block text-sm font-medium text-gray-700 dark:text-gray-300">API Key</label>
|
||||
<input type="text" name="trello_api_key" id="trello_api_key" value="{{ settings.trello_api_key or '' }}" class="form-input" placeholder="{{ _('Trello API Key') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="trello_api_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300">API Secret</label>
|
||||
<input type="password" name="trello_api_secret" id="trello_api_secret" class="form-input" placeholder="{{ _('Leave empty to keep current value') }}">
|
||||
{% if settings.trello_api_secret_set %}
|
||||
<p class="mt-1 text-xs text-green-600 dark:text-green-400">{{ _('✓ API secret is configured') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GitLab -->
|
||||
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<h3 class="text-md font-semibold mb-3 flex items-center">
|
||||
<i class="fab fa-gitlab text-orange-600 mr-2"></i>GitLab
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="gitlab_client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
|
||||
<input type="text" name="gitlab_client_id" id="gitlab_client_id" value="{{ settings.gitlab_client_id or '' }}" class="form-input" placeholder="{{ _('GitLab OAuth Client ID') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="gitlab_client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
|
||||
<input type="password" name="gitlab_client_secret" id="gitlab_client_secret" class="form-input" placeholder="{{ _('Leave empty to keep current value') }}">
|
||||
{% if settings.gitlab_client_secret_set %}
|
||||
<p class="mt-1 text-xs text-green-600 dark:text-green-400">{{ _('✓ Client secret is configured') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="gitlab_instance_url" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Instance URL</label>
|
||||
<input type="text" name="gitlab_instance_url" id="gitlab_instance_url" value="{{ settings.gitlab_instance_url or 'https://gitlab.com' }}" class="form-input" placeholder="{{ _('https://gitlab.com or your self-hosted instance') }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QuickBooks -->
|
||||
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<h3 class="text-md font-semibold mb-3 flex items-center">
|
||||
<i class="fas fa-dollar-sign text-green-600 mr-2"></i>QuickBooks Online
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="quickbooks_client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
|
||||
<input type="text" name="quickbooks_client_id" id="quickbooks_client_id" value="{{ settings.quickbooks_client_id or '' }}" class="form-input" placeholder="{{ _('QuickBooks OAuth Client ID') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="quickbooks_client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
|
||||
<input type="password" name="quickbooks_client_secret" id="quickbooks_client_secret" class="form-input" placeholder="{{ _('Leave empty to keep current value') }}">
|
||||
{% if settings.quickbooks_client_secret_set %}
|
||||
<p class="mt-1 text-xs text-green-600 dark:text-green-400">{{ _('✓ Client secret is configured') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Xero -->
|
||||
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<h3 class="text-md font-semibold mb-3 flex items-center">
|
||||
<i class="fas fa-chart-line text-blue-600 mr-2"></i>Xero
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="xero_client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client ID</label>
|
||||
<input type="text" name="xero_client_id" id="xero_client_id" value="{{ settings.xero_client_id or '' }}" class="form-input" placeholder="{{ _('Xero OAuth Client ID') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="xero_client_secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Client Secret</label>
|
||||
<input type="password" name="xero_client_secret" id="xero_client_secret" class="form-input" placeholder="{{ _('Leave empty to keep current value') }}">
|
||||
{% if settings.xero_client_secret_set %}
|
||||
<p class="mt-1 text-xs text-green-600 dark:text-green-400">{{ _('✓ Client secret is configured') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Analytics Settings -->
|
||||
|
||||
@@ -26,12 +26,12 @@
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h3 class="text-lg font-semibold">
|
||||
{% if integration.provider == 'google' %}
|
||||
{% if integration.provider == 'google' or integration.provider == 'google_calendar' %}
|
||||
<i class="fab fa-google text-blue-600 mr-2"></i>Google Calendar
|
||||
{% elif integration.provider == 'outlook' %}
|
||||
{% elif integration.provider == 'outlook' or integration.provider == 'outlook_calendar' %}
|
||||
<i class="fab fa-microsoft text-blue-500 mr-2"></i>Outlook Calendar
|
||||
{% else %}
|
||||
{{ integration.provider|title }} Calendar
|
||||
{{ integration.provider|title|replace('_', ' ') }} Calendar
|
||||
{% endif %}
|
||||
</h3>
|
||||
{% if integration.is_active %}
|
||||
@@ -45,6 +45,10 @@
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-2">
|
||||
<i class="fas fa-calendar mr-1"></i>{{ integration.calendar_name }}
|
||||
</p>
|
||||
{% elif integration.config and integration.config.get('calendar_id') %}
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-2">
|
||||
<i class="fas fa-calendar mr-1"></i>{{ integration.config.get('calendar_id') }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if integration.last_sync_at %}
|
||||
@@ -63,10 +67,15 @@
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
{% if integration.provider in ['google_calendar', 'outlook_calendar'] %}
|
||||
<a href="{{ url_for('integrations.view_integration', integration_id=integration.id) }}" class="text-blue-600 hover:text-blue-800" title="{{ _('Manage Integration') }}">
|
||||
<i class="fas fa-cog"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if integration.is_active %}
|
||||
<form method="POST" action="{{ url_for('calendar.disconnect_integration', integration_id=integration.id) }}" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="text-red-600 hover:text-red-800" onclick="return confirm('{{ _('Are you sure you want to disconnect this integration?') }}')">
|
||||
<button type="submit" class="text-red-600 hover:text-red-800" onclick="return confirm('{{ _('Are you sure you want to disconnect this integration?') }}')" title="{{ _('Disconnect') }}">
|
||||
<i class="fas fa-unlink"></i>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -38,15 +38,32 @@
|
||||
<i class="fas fa-eye mr-2"></i>{{ _('View Integration') }}
|
||||
</a>
|
||||
{% else %}
|
||||
{% if provider.provider == 'trello' %}
|
||||
<a href="{{ url_for('admin.integration_setup', provider=provider.provider) if current_user.is_admin else '#' }}" class="block w-full bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors text-center {% if not current_user.is_admin %}opacity-50 cursor-not-allowed{% endif %}">
|
||||
<i class="fas fa-cog mr-2"></i>{{ _('Configure') }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('integrations.connect_integration', provider=provider.provider) }}" class="block w-full bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors text-center">
|
||||
<i class="fas fa-link mr-2"></i>{{ _('Reconnect') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if provider.provider == 'trello' %}
|
||||
<a href="{{ url_for('admin.integration_setup', provider=provider.provider) if current_user.is_admin else '#' }}" class="block w-full bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors text-center {% if not current_user.is_admin %}opacity-50 cursor-not-allowed{% endif %}">
|
||||
<i class="fas fa-cog mr-2"></i>{{ _('Setup') }}
|
||||
</a>
|
||||
{% elif provider.provider == 'google_calendar' %}
|
||||
<a href="{{ url_for('integrations.connect_integration', provider=provider.provider) }}" class="block w-full bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors text-center">
|
||||
<i class="fab fa-google mr-2"></i>{{ _('Connect Google Calendar') }}
|
||||
<div class="text-xs mt-1 opacity-90">{{ _('Automatically redirects to Google') }}</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('integrations.connect_integration', provider=provider.provider) }}" class="block w-full bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors text-center">
|
||||
<i class="fas fa-plus mr-2"></i>{{ _('Connect') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -61,7 +78,10 @@
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold">{{ integration.name }}</h3>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ integration.provider|title }}
|
||||
{{ integration.provider|title|replace('_', ' ') }}
|
||||
{% if integration.is_global %}
|
||||
<span class="ml-2 px-2 py-0.5 text-xs rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">{{ _('Global') }}</span>
|
||||
{% endif %}
|
||||
{% if integration.last_sync_at %}
|
||||
• {{ _('Last synced') }}: {{ integration.last_sync_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
{% endif %}
|
||||
|
||||
@@ -46,11 +46,61 @@
|
||||
{% if integration.last_sync_at %}
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">{{ _('Last Sync') }}</dt>
|
||||
<dd class="text-text-light dark:text-text-dark">{{ integration.last_sync_at.strftime('%Y-%m-%d %H:%M') }}</dd>
|
||||
<dd class="text-text-light dark:text-text-dark">
|
||||
{{ integration.last_sync_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
{% if integration.last_sync_status %}
|
||||
{% if integration.last_sync_status == 'success' %}
|
||||
<span class="ml-2 px-2 py-1 text-xs rounded bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ _('Success') }}</span>
|
||||
{% elif integration.last_sync_status == 'error' %}
|
||||
<span class="ml-2 px-2 py-1 text-xs rounded bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">{{ _('Error') }}</span>
|
||||
{% else %}
|
||||
<span class="ml-2 px-2 py-1 text-xs rounded bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">{{ _('Pending') }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if integration.last_error %}
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">{{ _('Last Error') }}</dt>
|
||||
<dd class="text-text-light dark:text-text-dark text-sm text-red-600 dark:text-red-400">{{ integration.last_error[:100] }}{% if integration.last_error|length > 100 %}...{% endif %}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Sync Events Log -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mt-6">
|
||||
<h2 class="text-lg font-semibold mb-4">{{ _('Sync History') }}</h2>
|
||||
{% if recent_events %}
|
||||
<div class="space-y-2 max-h-96 overflow-y-auto">
|
||||
{% for event in recent_events %}
|
||||
<div class="border-b border-border-light dark:border-border-dark pb-2 last:border-0">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-text-light dark:text-text-dark">{{ event.event_type|replace('_', ' ')|title }}</span>
|
||||
{% if event.status == 'success' %}
|
||||
<span class="px-2 py-0.5 text-xs rounded bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ _('Success') }}</span>
|
||||
{% elif event.status == 'error' %}
|
||||
<span class="px-2 py-0.5 text-xs rounded bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">{{ _('Error') }}</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-0.5 text-xs rounded bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">{{ _('Pending') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if event.message %}
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">{{ event.message }}</p>
|
||||
{% endif %}
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ event.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('No sync events yet') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -76,6 +76,59 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- OAuth Integration Setup Section -->
|
||||
<div class="bg-background-light dark:bg-gray-700 border border-border-light dark:border-border-dark rounded-lg p-4">
|
||||
<h3 class="text-base font-semibold mb-3">🔌 {{ _('Integration Setup (Optional)') }}</h3>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
|
||||
{{ _('Configure OAuth credentials now to enable calendar and other integrations. You can also configure these later in Admin → Settings.') }}
|
||||
</p>
|
||||
|
||||
<!-- Google Calendar OAuth -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2">
|
||||
<i class="fab fa-google text-blue-600 mr-2"></i>{{ _('Google Calendar OAuth') }}
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
name="google_calendar_client_id"
|
||||
placeholder="{{ _('Client ID (optional)') }}"
|
||||
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
name="google_calendar_client_secret"
|
||||
placeholder="{{ _('Client Secret (optional)') }}"
|
||||
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-2">
|
||||
{{ _('Get these from') }} <a href="https://console.cloud.google.com/apis/credentials" target="_blank" class="text-primary hover:underline">{{ _('Google Cloud Console') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<details class="text-sm mt-3">
|
||||
<summary class="cursor-pointer font-medium text-primary hover:underline">
|
||||
{{ _('How to get Google Calendar OAuth credentials?') }}
|
||||
</summary>
|
||||
<div class="mt-3 space-y-2 pl-4 border-l-2 border-primary/30 text-text-muted-light dark:text-text-muted-dark">
|
||||
<ol class="list-decimal list-inside space-y-2">
|
||||
<li>{{ _('Go to') }} <a href="https://console.cloud.google.com/" target="_blank" class="text-primary hover:underline">{{ _('Google Cloud Console') }}</a></li>
|
||||
<li>{{ _('Create a new project or select an existing one') }}</li>
|
||||
<li>{{ _('Enable the Google Calendar API') }}</li>
|
||||
<li>{{ _('Go to Credentials → Create Credentials → OAuth 2.0 Client ID') }}</li>
|
||||
<li>{{ _('Set application type to "Web application"') }}</li>
|
||||
<li>{{ _('Add authorized redirect URI:') }} <code class="bg-gray-100 dark:bg-gray-800 dark:text-text-light px-1 rounded text-xs break-all">{{ url_for('integrations.oauth_callback', provider='google_calendar', _external=True) }}</code></li>
|
||||
<li>{{ _('Copy the Client ID and Client Secret') }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Telemetry Opt-in Section -->
|
||||
<div class="bg-background-light dark:bg-gray-700 border border-border-light dark:border-border-dark rounded-lg p-4">
|
||||
<h3 class="text-base font-semibold mb-3">📊 {{ _('Help Us Improve (Optional)') }}</h3>
|
||||
|
||||
@@ -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)]}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user