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:
Dries Peeters
2025-11-29 07:03:00 +01:00
parent dcbdfcc288
commit 0ec6b8e9d6
30 changed files with 3517 additions and 163 deletions
+71
View File
@@ -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
+45
View File
@@ -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
View File
@@ -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": [],
+260
View File
@@ -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": []
}
+161 -54
View File
@@ -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
View File
@@ -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"],
+284
View File
@@ -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": []
}
+414
View File
@@ -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
View File
@@ -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
+94 -3
View File
@@ -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."""
+30 -2
View File
@@ -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]:
+374
View File
@@ -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"]
}
+7 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"))
+68 -16
View File
@@ -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:
+6 -1
View File
@@ -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 %}
+191
View File
@@ -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 %}
+172
View File
@@ -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 -->
+13 -4
View File
@@ -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>
+21 -1
View File
@@ -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 %}
+51 -1
View File
@@ -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>
+53
View File
@@ -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>
+124 -1
View File
@@ -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')