Files
TimeTracker/app/services/integration_service.py
T
Dries Peeters b4486a627f fix: CI tests, code quality, and duplicate DB indexes
- Webhook models: remove duplicate index definitions so db.create_all()
  no longer raises 'index already exists' (columns already have index=True)
- ImportService: fix circular import by late-importing ClientService,
  ProjectService, TimeTrackingService in __init__
- reports: fix F823 by renaming unpack variable _ to _entry_count to avoid
  shadowing gettext _ in export_task_excel()
- Code quality: add .flake8 with extend-ignore so flake8 CI passes;
  simplify pyproject.toml isort config (drop unsupported options)
- Format: run black and isort on app/
- tests: restore minimal app fixture in test_import_export_models
2026-03-15 10:51:52 +01:00

362 lines
14 KiB
Python

"""
Service for managing integrations.
"""
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
from app import db
from app.constants import WebhookEvent
from app.models import Integration, IntegrationCredential, IntegrationEvent, User
from app.utils.db import safe_commit
from app.utils.event_bus import emit_event
logger = logging.getLogger(__name__)
class IntegrationService:
"""
Service for integration management operations.
Handles:
- Creating and managing integrations
- OAuth flow management
- Token refresh
- Connection testing
"""
# Registry of available connectors
_connector_registry = {}
@classmethod
def register_connector(cls, provider: str, connector_class):
"""Register a connector class for a provider."""
cls._connector_registry[provider] = connector_class
@classmethod
def get_connector(cls, integration: Integration) -> Optional[Any]:
"""
Get connector instance for an integration.
Args:
integration: Integration model instance
Returns:
Connector instance or None
"""
if integration.provider not in cls._connector_registry:
return None
connector_class = cls._connector_registry[integration.provider]
credentials = IntegrationCredential.query.filter_by(integration_id=integration.id).first()
return connector_class(integration, credentials)
def create_integration(
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 (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'
"""
if provider not in self._connector_registry:
return {"success": False, "message": f"Provider {provider} is not available."}
# Google Calendar, CalDAV, and ActivityWatch are per-user, all others are global
if provider in ("google_calendar", "caldav_calendar", "activitywatch"):
is_global = False
if not user_id:
return {"success": False, "message": f"{provider} integration requires a user_id."}
else:
is_global = True
user_id = None # Global integrations don't have user_id
# 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()
integration = Integration(
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, "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, "is_global": is_global},
)
return {"success": True, "message": "Integration created successfully.", "integration": integration}
def get_integration(
self, integration_id: int, user_id: Optional[int] = None, allow_admin_override: bool = False
) -> Optional[Integration]:
"""
Get integration by ID (with user check for per-user integrations).
Args:
integration_id: ID of the integration
user_id: User ID to check ownership (None for admins viewing any integration)
allow_admin_override: If True and user_id is None, allow access to any integration (for admins)
Returns:
Integration if found and accessible, None otherwise
"""
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 allow_admin_override is True and user_id is None, allow access (admin viewing any integration)
if allow_admin_override and user_id is None:
return integration
if user_id and integration.user_id == user_id:
return integration
return None
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 (batch load to avoid N+1)
integration_ids = [i.id for i in integrations]
cred_integration_ids = set()
if integration_ids:
creds = IntegrationCredential.query.filter(IntegrationCredential.integration_id.in_(integration_ids)).all()
cred_integration_ids = {c.integration_id for c in creds}
for integration in integrations:
has_credentials = integration.id in cred_integration_ids
# Update is_active if it doesn't match credentials status
if integration.is_active != has_credentials:
integration.is_active = has_credentials
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: 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."}
# Explicitly delete associated credentials first
credentials = IntegrationCredential.query.filter_by(integration_id=integration_id).all()
for credential in credentials:
db.session.delete(credential)
# Delete associated events (for cleanup, though cascade should handle it)
events = IntegrationEvent.query.filter_by(integration_id=integration_id).all()
for event in events:
db.session.delete(event)
# Delete the integration
provider = integration.provider # Save before deletion
db.session.delete(integration)
if not safe_commit("delete_integration", {"integration_id": integration_id}):
return {"success": False, "message": "Could not delete integration due to a database error."}
emit_event(WebhookEvent.INTEGRATION_DELETED, {"integration_id": integration_id, "provider": provider})
return {"success": True, "message": "Integration deleted successfully."}
def reset_integration(self, integration_id: int, user_id: Optional[int] = None) -> Dict[str, Any]:
"""Reset an integration by removing credentials and clearing config."""
integration = self.get_integration(integration_id, user_id)
if not integration:
return {"success": False, "message": "Integration not found."}
# Only admins can reset 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 reset global integrations."}
# Delete associated credentials
credentials = IntegrationCredential.query.filter_by(integration_id=integration_id).all()
for credential in credentials:
db.session.delete(credential)
# Clear config, reset status fields
integration.config = {}
integration.is_active = False
integration.last_sync_at = None
integration.last_sync_status = None
integration.last_error = None
if not safe_commit("reset_integration", {"integration_id": integration_id}):
return {"success": False, "message": "Could not reset integration due to a database error."}
emit_event(
WebhookEvent.INTEGRATION_UPDATED,
{"integration_id": integration_id, "provider": integration.provider, "action": "reset"},
)
return {"success": True, "message": "Integration reset successfully. You can now reconfigure it."}
def save_credentials(
self,
integration_id: int,
access_token: str,
refresh_token: Optional[str] = None,
expires_at: Optional[datetime] = None,
token_type: str = "Bearer",
scope: Optional[str] = None,
extra_data: Optional[Dict] = None,
) -> Dict[str, Any]:
"""Save OAuth credentials for an integration."""
integration = Integration.query.get(integration_id)
if not integration:
return {"success": False, "message": "Integration not found."}
# Get or create credentials
credentials = IntegrationCredential.query.filter_by(integration_id=integration_id).first()
if not credentials:
credentials = IntegrationCredential(integration_id=integration_id)
db.session.add(credentials)
credentials.access_token = access_token
credentials.refresh_token = refresh_token
credentials.expires_at = expires_at
credentials.token_type = token_type
credentials.scope = scope
credentials.extra_data = extra_data or {}
# Mark integration as active when credentials are saved
integration.is_active = True
if not safe_commit("save_integration_credentials", {"integration_id": integration_id}):
return {"success": False, "message": "Could not save credentials due to a database error."}
return {"success": True, "message": "Credentials saved successfully.", "credentials": credentials}
def test_connection(
self, integration_id: int, user_id: Optional[int] = None, allow_admin_override: bool = False
) -> Dict[str, Any]:
"""Test connection to integrated service."""
integration = self.get_integration(integration_id, user_id, allow_admin_override=allow_admin_override)
if not integration:
return {"success": False, "message": "Integration not found."}
connector = self.get_connector(integration)
if not connector:
return {"success": False, "message": f"Connector for {integration.provider} is not available."}
try:
result = connector.test_connection()
# Log event
self._log_event(integration_id, "test_connection", result.get("success", False), result.get("message"))
return result
except Exception as e:
logger.error(f"Error testing connection for integration {integration_id}: {e}")
return {"success": False, "message": f"Error testing connection: {str(e)}"}
def _log_event(
self,
integration_id: int,
event_type: str,
status: bool,
message: Optional[str] = None,
metadata: Optional[Dict] = None,
):
"""Log an integration event."""
event = IntegrationEvent(
integration_id=integration_id,
event_type=event_type,
status="success" if status else "error",
message=message,
event_metadata=metadata or {},
)
db.session.add(event)
safe_commit("log_integration_event", {"integration_id": integration_id})
def update_integration_active_status(self, integration_id: int):
"""Update integration is_active status based on credentials."""
integration = Integration.query.get(integration_id)
if not integration:
return
has_credentials = IntegrationCredential.query.filter_by(integration_id=integration_id).first() is not None
if integration.is_active != has_credentials:
integration.is_active = has_credentials
safe_commit("update_integration_active_status", {"integration_id": integration_id})
@classmethod
def get_available_providers(cls) -> List[Dict[str, Any]]:
"""Get list of available integration providers."""
providers = []
for provider, connector_class in cls._connector_registry.items():
providers.append(
{
"provider": provider,
"display_name": getattr(connector_class, "display_name", provider.title()),
"description": getattr(connector_class, "description", ""),
"icon": getattr(connector_class, "icon", "plug"),
}
)
return providers