Files
TimeTracker/app/integrations/microsoft_teams.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

374 lines
15 KiB
Python

"""
Microsoft Teams integration connector.
Send notifications and sync with Microsoft Teams.
"""
import os
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
import requests
from app.integrations.base import BaseConnector
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 as e:
# Log error but don't fail - user info is optional
import logging
logger = logging.getLogger(__name__)
logger.debug(f"Could not fetch Microsoft Teams user info: {e}")
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",
"required": False,
"placeholder": "19:channel-id@thread.tacv2",
"description": "Default Teams channel ID for notifications",
"help": "Find channel ID in Teams channel settings or API",
},
{
"name": "sync_direction",
"type": "select",
"label": "Sync Direction",
"options": [
{"value": "teams_to_timetracker", "label": "Teams → TimeTracker (Import only)"},
{"value": "timetracker_to_teams", "label": "TimeTracker → Teams (Export only)"},
{"value": "bidirectional", "label": "Bidirectional (Two-way sync)"},
],
"default": "timetracker_to_teams",
"description": "Choose how data flows between Microsoft Teams and TimeTracker",
},
{
"name": "sync_items",
"type": "array",
"label": "Items to Sync",
"options": [
{"value": "channels", "label": "Channels"},
{"value": "teams", "label": "Teams"},
{"value": "messages", "label": "Messages (as tasks)"},
],
"default": [],
"description": "Select which items to synchronize",
},
{
"name": "notify_on_time_entry_start",
"type": "boolean",
"label": "Notify on Time Entry Start",
"default": False,
"description": "Send Teams notification when a time entry starts",
},
{
"name": "notify_on_time_entry_complete",
"type": "boolean",
"label": "Notify on Time Entry Complete",
"default": False,
"description": "Send Teams notification when a time entry is completed",
},
{
"name": "notify_on_task_complete",
"type": "boolean",
"label": "Notify on Task Complete",
"default": False,
"description": "Send Teams notification when a task is completed",
},
{
"name": "notify_on_invoice_sent",
"type": "boolean",
"label": "Notify on Invoice Sent",
"default": True,
"description": "Send Teams notification when an invoice is sent",
},
{
"name": "notify_on_project_create",
"type": "boolean",
"label": "Notify on Project Create",
"default": False,
"description": "Send Teams notification when a project is created",
},
{
"name": "auto_sync",
"type": "boolean",
"label": "Auto Sync",
"default": False,
"description": "Automatically sync when webhooks are received from Teams",
},
{
"name": "sync_interval",
"type": "select",
"label": "Sync Schedule",
"options": [
{"value": "manual", "label": "Manual only"},
{"value": "hourly", "label": "Every hour"},
{"value": "daily", "label": "Daily"},
],
"default": "manual",
"description": "How often to automatically sync data",
},
],
"required": [],
"sections": [
{
"title": "Channel Settings",
"description": "Configure Teams channel for notifications",
"fields": ["default_channel_id"],
},
{
"title": "Sync Settings",
"description": "Configure what and how to sync",
"fields": ["sync_direction", "sync_items", "auto_sync", "sync_interval"],
},
{
"title": "Notification Settings",
"description": "Configure when to send Teams notifications",
"fields": [
"notify_on_time_entry_start",
"notify_on_time_entry_complete",
"notify_on_task_complete",
"notify_on_invoice_sent",
"notify_on_project_create",
],
},
],
"sync_settings": {
"enabled": True,
"auto_sync": False,
"sync_interval": "manual",
"sync_direction": "timetracker_to_teams",
"sync_items": [],
},
}