mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 04:40:32 -05:00
b4486a627f
- 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
306 lines
10 KiB
Python
306 lines
10 KiB
Python
"""
|
|
Base connector interface for integrations.
|
|
"""
|
|
|
|
from abc import ABC, abstractmethod
|
|
from datetime import datetime
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
class BaseConnector(ABC):
|
|
"""
|
|
Base class for all integration connectors.
|
|
|
|
All connectors must implement these methods to provide
|
|
a consistent interface for integration management.
|
|
"""
|
|
|
|
def __init__(self, integration, credentials):
|
|
"""
|
|
Initialize connector with integration and credentials.
|
|
|
|
Args:
|
|
integration: Integration model instance
|
|
credentials: IntegrationCredential model instance
|
|
"""
|
|
self.integration = integration
|
|
self.credentials = credentials
|
|
|
|
@property
|
|
@abstractmethod
|
|
def provider_name(self) -> str:
|
|
"""Return the provider name (e.g., 'jira', 'slack', 'github')."""
|
|
pass
|
|
|
|
@property
|
|
@abstractmethod
|
|
def display_name(self) -> str:
|
|
"""Return the display name for the provider."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_authorization_url(self, redirect_uri: str, state: str = None) -> str:
|
|
"""
|
|
Get OAuth authorization URL.
|
|
|
|
Args:
|
|
redirect_uri: OAuth callback URL
|
|
state: Optional state parameter for CSRF protection
|
|
|
|
Returns:
|
|
Authorization URL
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def exchange_code_for_tokens(self, code: str, redirect_uri: str) -> Dict[str, Any]:
|
|
"""
|
|
Exchange authorization code for access tokens.
|
|
|
|
Args:
|
|
code: Authorization code from OAuth callback
|
|
redirect_uri: OAuth callback URL
|
|
|
|
Returns:
|
|
Dict with access_token, refresh_token, expires_at, etc.
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def refresh_access_token(self) -> Dict[str, Any]:
|
|
"""
|
|
Refresh access token using refresh token.
|
|
|
|
Returns:
|
|
Dict with new access_token, expires_at, etc.
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def test_connection(self) -> Dict[str, Any]:
|
|
"""
|
|
Test the connection to the service.
|
|
|
|
Returns:
|
|
Dict with 'success' (bool) and 'message' (str)
|
|
"""
|
|
pass
|
|
|
|
def get_access_token(self) -> Optional[str]:
|
|
"""
|
|
Get current access token, refreshing if needed.
|
|
|
|
Returns:
|
|
Access token string or None
|
|
"""
|
|
if not self.credentials:
|
|
return None
|
|
|
|
# Check if token needs refresh
|
|
if self.credentials.needs_refresh():
|
|
try:
|
|
new_tokens = self.refresh_access_token()
|
|
if new_tokens.get("access_token"):
|
|
return new_tokens["access_token"]
|
|
except Exception:
|
|
pass
|
|
|
|
return self.credentials.access_token if self.credentials else None
|
|
|
|
def sync_data(self, sync_type: str = "full") -> Dict[str, Any]:
|
|
"""
|
|
Sync data from the integrated service.
|
|
|
|
Args:
|
|
sync_type: Type of sync ('full', 'incremental', etc.)
|
|
|
|
Returns:
|
|
Dict with sync results
|
|
"""
|
|
# Default implementation - override in subclasses
|
|
return {"success": False, "message": "Sync not implemented for this connector"}
|
|
|
|
def handle_webhook(
|
|
self, payload: Dict[str, Any], headers: Dict[str, str], raw_body: Optional[bytes] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Handle incoming webhook from the service.
|
|
|
|
Args:
|
|
payload: Webhook payload (parsed JSON/dict)
|
|
headers: Request headers
|
|
raw_body: Raw request body bytes (for signature verification)
|
|
|
|
Returns:
|
|
Dict with processing results
|
|
"""
|
|
# Default implementation - override in subclasses
|
|
return {"success": False, "message": "Webhook handling not implemented for this connector"}
|
|
|
|
def get_config_schema(self) -> Dict[str, Any]:
|
|
"""
|
|
Get configuration schema for this connector.
|
|
|
|
Returns:
|
|
Dict describing configuration fields with structure:
|
|
{
|
|
"fields": [
|
|
{
|
|
"name": "field_name",
|
|
"type": "string|number|boolean|select|array|json|password|url|text",
|
|
"label": "Display Label",
|
|
"description": "Help text",
|
|
"placeholder": "Placeholder text",
|
|
"default": default_value,
|
|
"required": True/False,
|
|
"options": [{"value": "val", "label": "Label"}] for select,
|
|
"help": "Additional help text",
|
|
"validation": {"min": 1, "max": 100} for numbers,
|
|
}
|
|
],
|
|
"required": ["field_name"],
|
|
"sections": [
|
|
{
|
|
"title": "Section Title",
|
|
"description": "Section description",
|
|
"fields": ["field_name1", "field_name2"]
|
|
}
|
|
],
|
|
"sync_settings": {
|
|
"enabled": True/False,
|
|
"auto_sync": True/False,
|
|
"sync_interval": "hourly|daily|weekly|manual",
|
|
"sync_direction": "provider_to_timetracker|timetracker_to_provider|bidirectional",
|
|
"sync_items": ["tasks", "projects", "time_entries"],
|
|
}
|
|
}
|
|
"""
|
|
return {
|
|
"fields": [],
|
|
"required": [],
|
|
"sections": [],
|
|
"sync_settings": {
|
|
"enabled": True,
|
|
"auto_sync": False,
|
|
"sync_interval": "manual",
|
|
"sync_direction": "provider_to_timetracker",
|
|
"sync_items": [],
|
|
},
|
|
}
|
|
|
|
def validate_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
Validate configuration.
|
|
|
|
Args:
|
|
config: Configuration dict to validate
|
|
|
|
Returns:
|
|
Dict with 'valid' (bool) and 'errors' (list)
|
|
"""
|
|
schema = self.get_config_schema()
|
|
errors = []
|
|
|
|
# Check required fields
|
|
required_fields = schema.get("required", [])
|
|
for field_name in required_fields:
|
|
if field_name not in config or not config[field_name]:
|
|
# Find field label for error message
|
|
field_label = field_name
|
|
for field in schema.get("fields", []):
|
|
if field.get("name") == field_name:
|
|
field_label = field.get("label", field_name)
|
|
break
|
|
errors.append(f"{field_label} is required")
|
|
|
|
# Validate field types and constraints
|
|
for field in schema.get("fields", []):
|
|
field_name = field.get("name")
|
|
if field_name not in config:
|
|
continue
|
|
|
|
value = config[field_name]
|
|
field_type = field.get("type", "string")
|
|
|
|
# Type validation
|
|
if field_type == "number" and value is not None:
|
|
try:
|
|
float(value)
|
|
# Check min/max if specified
|
|
validation = field.get("validation", {})
|
|
if "min" in validation and float(value) < validation["min"]:
|
|
errors.append(f"{field.get('label', field_name)} must be at least {validation['min']}")
|
|
if "max" in validation and float(value) > validation["max"]:
|
|
errors.append(f"{field.get('label', field_name)} must be at most {validation['max']}")
|
|
except (ValueError, TypeError):
|
|
errors.append(f"{field.get('label', field_name)} must be a number")
|
|
elif field_type == "boolean" and value is not None:
|
|
if not isinstance(value, bool) and value not in ("true", "false", "1", "0", "on", "off", ""):
|
|
errors.append(f"{field.get('label', field_name)} must be a boolean")
|
|
elif field_type == "url" and value:
|
|
try:
|
|
from urllib.parse import urlparse
|
|
|
|
parsed = urlparse(value)
|
|
if not parsed.scheme or not parsed.netloc:
|
|
errors.append(f"{field.get('label', field_name)} must be a valid URL")
|
|
except Exception:
|
|
errors.append(f"{field.get('label', field_name)} must be a valid URL")
|
|
elif field_type == "json" and value:
|
|
try:
|
|
import json
|
|
|
|
if isinstance(value, str):
|
|
json.loads(value)
|
|
except json.JSONDecodeError:
|
|
errors.append(f"{field.get('label', field_name)} must be valid JSON")
|
|
|
|
return {"valid": len(errors) == 0, "errors": errors}
|
|
|
|
def get_sync_settings(self) -> Dict[str, Any]:
|
|
"""
|
|
Get current sync settings from integration config.
|
|
|
|
Returns:
|
|
Dict with sync settings
|
|
"""
|
|
if not self.integration or not self.integration.config:
|
|
schema = self.get_config_schema()
|
|
return schema.get("sync_settings", {})
|
|
|
|
config = self.integration.config
|
|
schema = self.get_config_schema()
|
|
default_sync_settings = schema.get("sync_settings", {})
|
|
|
|
return {
|
|
"enabled": config.get("sync_enabled", default_sync_settings.get("enabled", True)),
|
|
"auto_sync": config.get("auto_sync", default_sync_settings.get("auto_sync", False)),
|
|
"sync_interval": config.get("sync_interval", default_sync_settings.get("sync_interval", "manual")),
|
|
"sync_direction": config.get(
|
|
"sync_direction", default_sync_settings.get("sync_direction", "provider_to_timetracker")
|
|
),
|
|
"sync_items": config.get("sync_items", default_sync_settings.get("sync_items", [])),
|
|
}
|
|
|
|
def get_field_mappings(self) -> Dict[str, str]:
|
|
"""
|
|
Get field mappings for data translation.
|
|
|
|
Returns:
|
|
Dict mapping provider fields to TimeTracker fields
|
|
"""
|
|
if not self.integration or not self.integration.config:
|
|
return {}
|
|
return self.integration.config.get("field_mappings", {})
|
|
|
|
def get_status_mappings(self) -> Dict[str, str]:
|
|
"""
|
|
Get status mappings for data translation.
|
|
|
|
Returns:
|
|
Dict mapping provider statuses to TimeTracker statuses
|
|
"""
|
|
if not self.integration or not self.integration.config:
|
|
return {}
|
|
return self.integration.config.get("status_mappings", {})
|