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
987 lines
44 KiB
Python
987 lines
44 KiB
Python
"""
|
|
Google Calendar integration connector.
|
|
Provides two-way sync between TimeTracker and Google Calendar.
|
|
"""
|
|
|
|
import os
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import requests
|
|
from google.auth.transport.requests import Request
|
|
from google.oauth2.credentials import Credentials
|
|
from google_auth_oauthlib.flow import Flow
|
|
from googleapiclient.discovery import build
|
|
from googleapiclient.errors import HttpError
|
|
|
|
from app.integrations.base import BaseConnector
|
|
|
|
|
|
class GoogleCalendarConnector(BaseConnector):
|
|
"""Google Calendar integration connector."""
|
|
|
|
display_name = "Google Calendar"
|
|
description = "Two-way sync with Google Calendar"
|
|
icon = "google"
|
|
|
|
# OAuth 2.0 scopes required
|
|
SCOPES = ["https://www.googleapis.com/auth/calendar", "https://www.googleapis.com/auth/calendar.events"]
|
|
|
|
@property
|
|
def provider_name(self) -> str:
|
|
return "google_calendar"
|
|
|
|
def get_authorization_url(self, redirect_uri: str, state: str = None) -> str:
|
|
"""Get Google OAuth authorization URL."""
|
|
from app.models import Settings
|
|
|
|
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")
|
|
|
|
flow = Flow.from_client_config(
|
|
{
|
|
"web": {
|
|
"client_id": client_id,
|
|
"client_secret": client_secret,
|
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
"token_uri": "https://oauth2.googleapis.com/token",
|
|
"redirect_uris": [redirect_uri],
|
|
}
|
|
},
|
|
scopes=self.SCOPES,
|
|
redirect_uri=redirect_uri,
|
|
)
|
|
|
|
if state:
|
|
flow.state = state
|
|
|
|
authorization_url, _ = flow.authorization_url(
|
|
state=state, # Explicitly pass state parameter
|
|
access_type="offline",
|
|
include_granted_scopes="true",
|
|
prompt="consent", # Force consent to get refresh token
|
|
)
|
|
|
|
return authorization_url
|
|
|
|
def exchange_code_for_tokens(self, code: str, redirect_uri: str) -> Dict[str, Any]:
|
|
"""Exchange authorization code for tokens."""
|
|
from app.models import Settings
|
|
|
|
# Allow scope changes (Google automatically adds openid, userinfo.email, userinfo.profile)
|
|
os.environ.setdefault("OAUTHLIB_RELAX_TOKEN_SCOPE", "1")
|
|
|
|
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")
|
|
|
|
flow = Flow.from_client_config(
|
|
{
|
|
"web": {
|
|
"client_id": client_id,
|
|
"client_secret": client_secret,
|
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
"token_uri": "https://oauth2.googleapis.com/token",
|
|
"redirect_uris": [redirect_uri],
|
|
}
|
|
},
|
|
scopes=self.SCOPES,
|
|
redirect_uri=redirect_uri,
|
|
)
|
|
|
|
flow.fetch_token(code=code)
|
|
|
|
credentials = flow.credentials
|
|
|
|
# Get user info
|
|
user_info = {}
|
|
try:
|
|
service = build("oauth2", "v2", credentials=credentials)
|
|
user_info_response = service.userinfo().get().execute()
|
|
user_info = {
|
|
"email": user_info_response.get("email"),
|
|
"name": user_info_response.get("name"),
|
|
"picture": user_info_response.get("picture"),
|
|
}
|
|
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 Google user info: {e}")
|
|
|
|
return {
|
|
"access_token": credentials.token,
|
|
"refresh_token": credentials.refresh_token,
|
|
"expires_at": credentials.expiry.isoformat() if credentials.expiry else None,
|
|
"token_type": "Bearer",
|
|
"scope": " ".join(credentials.scopes) if credentials.scopes else None,
|
|
"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("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=client_id,
|
|
client_secret=client_secret,
|
|
)
|
|
|
|
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
|
|
safe_commit("refresh_google_calendar_token", {"integration_id": self.integration.id})
|
|
|
|
return {
|
|
"access_token": credentials.token,
|
|
"expires_at": credentials.expiry.isoformat() if credentials.expiry else None,
|
|
}
|
|
|
|
def test_connection(self) -> Dict[str, Any]:
|
|
"""Test connection to Google Calendar."""
|
|
if not self.credentials:
|
|
return {"success": False, "message": "No credentials available. Please connect the integration first."}
|
|
|
|
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(calendars)} calendars.",
|
|
"calendars": calendar_options,
|
|
}
|
|
except Exception as e:
|
|
return {"success": False, "message": f"Connection test failed: {str(e)}"}
|
|
|
|
def _get_calendar_service(self):
|
|
"""Get Google Calendar API service."""
|
|
if not self.credentials:
|
|
raise ValueError("No credentials available. Please connect the integration first.")
|
|
|
|
if not self.credentials.access_token:
|
|
raise ValueError("No access token available. Please reconnect the integration.")
|
|
|
|
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=client_id,
|
|
client_secret=client_secret,
|
|
)
|
|
|
|
# Refresh if needed
|
|
if credentials.expired:
|
|
credentials.refresh(Request())
|
|
self.credentials.access_token = credentials.token
|
|
if credentials.expiry:
|
|
self.credentials.expires_at = credentials.expiry
|
|
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 and calendar events with Google Calendar (bidirectional)."""
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
|
|
from app import db
|
|
from app.models import CalendarEvent, TimeEntry
|
|
from app.models.integration_external_event_link import IntegrationExternalEventLink
|
|
from app.utils.timezone import now_in_app_timezone
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
service = self._get_calendar_service()
|
|
|
|
# Get sync direction from config
|
|
# Default to bidirectional for better user experience (allows both directions)
|
|
sync_direction = self.integration.config.get("sync_direction", "bidirectional")
|
|
calendar_id = self.integration.config.get("calendar_id", "primary")
|
|
|
|
logger.info(
|
|
f"Sync configuration: sync_direction='{sync_direction}', calendar_id='{calendar_id}', sync_type='{sync_type}'"
|
|
)
|
|
|
|
# Initialize counters for both sync directions
|
|
time_tracker_to_calendar_count = 0
|
|
imported = 0
|
|
skipped = 0
|
|
skipped_reasons = {"time_tracker_created": 0, "already_imported": 0, "invalid_time": 0, "other": 0}
|
|
errors = []
|
|
|
|
# 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)
|
|
|
|
logger.info(
|
|
f"TimeTracker→Calendar sync starting: sync_direction='{sync_direction}', sync_type='{sync_type}'"
|
|
)
|
|
logger.info(f" Calendar ID: {calendar_id}")
|
|
logger.info(f" Time range: from {start_date}")
|
|
|
|
# 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()
|
|
|
|
# Get calendar events (like CalDAV does)
|
|
calendar_events = CalendarEvent.query.filter(
|
|
CalendarEvent.user_id == self.integration.user_id,
|
|
CalendarEvent.start_time >= start_date,
|
|
CalendarEvent.all_day == False, # Skip all-day events
|
|
).all()
|
|
|
|
logger.info(
|
|
f"Found {len(time_entries)} time entries and {len(calendar_events)} calendar events to sync to Google Calendar"
|
|
)
|
|
|
|
# Sync time entries
|
|
for entry in time_entries:
|
|
try:
|
|
# Check if already synced using IntegrationExternalEventLink
|
|
existing_link = IntegrationExternalEventLink.query.filter_by(
|
|
integration_id=self.integration.id, time_entry_id=entry.id
|
|
).first()
|
|
existing_event_id = existing_link.external_uid if existing_link else None
|
|
|
|
if existing_event_id:
|
|
# Update existing event
|
|
logger.debug(
|
|
f"Updating existing calendar event {existing_event_id} for time entry {entry.id}"
|
|
)
|
|
self._update_calendar_event(service, calendar_id, existing_event_id, entry)
|
|
else:
|
|
# Create new event
|
|
logger.debug(f"Creating new calendar event for time entry {entry.id}")
|
|
event_id = self._create_calendar_event(service, calendar_id, entry)
|
|
|
|
# Create or update link
|
|
if existing_link:
|
|
existing_link.external_uid = event_id
|
|
else:
|
|
link = IntegrationExternalEventLink(
|
|
integration_id=self.integration.id,
|
|
time_entry_id=entry.id,
|
|
external_uid=event_id,
|
|
external_href=None, # Google Calendar doesn't use hrefs
|
|
)
|
|
db.session.add(link)
|
|
|
|
time_tracker_to_calendar_count += 1
|
|
logger.debug(f"Synced time entry {entry.id} to Google Calendar")
|
|
except Exception as e:
|
|
error_msg = f"Error syncing entry {entry.id}: {str(e)}"
|
|
errors.append(error_msg)
|
|
logger.warning(f"{error_msg}", exc_info=True)
|
|
|
|
# Sync calendar events (like CalDAV does)
|
|
for calendar_event in calendar_events:
|
|
try:
|
|
# Skip calendar events that were imported FROM Google Calendar (to avoid circular sync)
|
|
if calendar_event.description and "[Google Calendar:" in calendar_event.description:
|
|
logger.debug(
|
|
f"Skipping calendar event {calendar_event.id} - it was imported from Google Calendar, avoiding circular sync"
|
|
)
|
|
continue
|
|
|
|
# Check if already synced by querying Google Calendar for events with our marker
|
|
# Note: IntegrationExternalEventLink requires time_entry_id, so we can't use it for CalendarEvent
|
|
marker = f"TimeTracker: Created from calendar event [CalendarEvent: {calendar_event.id}]"
|
|
|
|
# Query Google Calendar to find existing events with this marker
|
|
# Use a time range around the calendar event to find it
|
|
from app.utils.timezone import local_to_utc
|
|
|
|
event_start_utc = local_to_utc(calendar_event.start_time)
|
|
time_min = (event_start_utc - timedelta(days=1)).isoformat() + "Z"
|
|
time_max = (event_start_utc + timedelta(days=1)).isoformat() + "Z"
|
|
|
|
try:
|
|
existing_events_result = (
|
|
service.events()
|
|
.list(
|
|
calendarId=calendar_id,
|
|
timeMin=time_min,
|
|
timeMax=time_max,
|
|
maxResults=50,
|
|
singleEvents=True,
|
|
orderBy="startTime",
|
|
)
|
|
.execute()
|
|
)
|
|
existing_events = existing_events_result.get("items", [])
|
|
|
|
# Find event with our marker
|
|
existing_event_id = None
|
|
for gc_event in existing_events:
|
|
gc_desc = gc_event.get("description", "")
|
|
if marker in gc_desc or f"[CalendarEvent: {calendar_event.id}]" in gc_desc:
|
|
existing_event_id = gc_event.get("id")
|
|
break
|
|
except Exception as query_error:
|
|
logger.debug(f"Could not query Google Calendar for existing event: {query_error}")
|
|
existing_event_id = None
|
|
|
|
if existing_event_id:
|
|
# Update existing event
|
|
logger.debug(
|
|
f"Updating existing Google Calendar event {existing_event_id} for CalendarEvent {calendar_event.id}"
|
|
)
|
|
self._update_calendar_event_from_event(
|
|
service, calendar_id, existing_event_id, calendar_event
|
|
)
|
|
else:
|
|
# Create new event
|
|
logger.debug(f"Creating new Google Calendar event for CalendarEvent {calendar_event.id}")
|
|
event_id = self._create_calendar_event_from_event(service, calendar_id, calendar_event)
|
|
# Note: We can't track this in IntegrationExternalEventLink since it requires time_entry_id
|
|
|
|
time_tracker_to_calendar_count += 1
|
|
logger.debug(f"Synced calendar event {calendar_event.id} to Google Calendar")
|
|
except Exception as e:
|
|
error_msg = f"Error syncing calendar event {calendar_event.id}: {str(e)}"
|
|
errors.append(error_msg)
|
|
logger.warning(f"{error_msg}", exc_info=True)
|
|
|
|
logger.info(
|
|
f"TimeTracker→Calendar sync completed: synced {time_tracker_to_calendar_count} items ({len(time_entries)} time entries + {len(calendar_events)} calendar events)"
|
|
)
|
|
|
|
# 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
|
|
|
|
logger.info(
|
|
f"Google Calendar sync starting: sync_direction='{sync_direction}', sync_type='{sync_type}'"
|
|
)
|
|
logger.info(f" Calendar ID: {calendar_id}")
|
|
logger.info(f" Time range: from {time_min}")
|
|
|
|
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", [])
|
|
logger.info(f"Fetched {len(events)} events from Google Calendar (calendar_id: {calendar_id})")
|
|
|
|
if len(events) == 0:
|
|
logger.info("No events found in Google Calendar for the specified time range")
|
|
|
|
# Reset counters for calendar-to-tracker sync (already initialized above)
|
|
imported = 0
|
|
skipped = 0
|
|
skipped_reasons = {
|
|
"time_tracker_created": 0,
|
|
"already_imported": 0,
|
|
"invalid_time": 0,
|
|
"all_day": 0,
|
|
"other": 0,
|
|
}
|
|
|
|
for event in events:
|
|
try:
|
|
event_id = event.get("id")
|
|
event_summary = event.get("summary", "No title")
|
|
|
|
# Skip events we created (check description for marker)
|
|
description = event.get("description") or ""
|
|
if description.startswith("TimeTracker:"):
|
|
logger.debug(f"Skipping event {event_id} - created by TimeTracker")
|
|
skipped += 1
|
|
skipped_reasons["time_tracker_created"] += 1
|
|
continue
|
|
|
|
# Check if we already have this event (using CalendarEvent marker, like CalDAV)
|
|
from app.models import CalendarEvent
|
|
from app.models.integration_external_event_link import IntegrationExternalEventLink
|
|
|
|
existing_calendar_event = CalendarEvent.query.filter(
|
|
CalendarEvent.user_id == self.integration.user_id,
|
|
CalendarEvent.description.like(f"%[Google Calendar: {event_id}]%"),
|
|
).first()
|
|
|
|
# Also check link table in case it was previously imported as TimeEntry (for backward compatibility)
|
|
existing_link = IntegrationExternalEventLink.query.filter_by(
|
|
integration_id=self.integration.id, external_uid=event_id
|
|
).first()
|
|
|
|
if existing_calendar_event or existing_link:
|
|
logger.debug(f"Event {event_id} ({event_summary}) already imported, skipping")
|
|
skipped += 1
|
|
skipped_reasons["already_imported"] += 1
|
|
continue
|
|
|
|
# Get start and end times - handle both dateTime (timed events) and date (all-day events)
|
|
start_data = event.get("start", {})
|
|
end_data = event.get("end", {})
|
|
|
|
start_str = start_data.get("dateTime")
|
|
end_str = end_data.get("dateTime")
|
|
# Skip all-day events (they only have "date", not "dateTime")
|
|
if not start_str or not end_str:
|
|
logger.debug(
|
|
f"Skipping all-day event {event_id} ({event_summary}) - only timed events are imported"
|
|
)
|
|
skipped += 1
|
|
skipped_reasons["all_day"] += 1
|
|
continue
|
|
|
|
# Parse datetime strings (Google Calendar returns ISO format with timezone)
|
|
try:
|
|
# Handle Z suffix and convert to +00:00 for fromisoformat
|
|
start_str_normalized = start_str.replace("Z", "+00:00")
|
|
end_str_normalized = end_str.replace("Z", "+00:00")
|
|
|
|
start_time_utc = datetime.fromisoformat(start_str_normalized)
|
|
end_time_utc = datetime.fromisoformat(end_str_normalized)
|
|
except (ValueError, AttributeError) as parse_error:
|
|
logger.warning(
|
|
f"Event {event_id} has invalid datetime format: start={start_str}, end={end_str}, error={parse_error}"
|
|
)
|
|
skipped += 1
|
|
skipped_reasons["invalid_time"] += 1
|
|
continue
|
|
|
|
# Ensure timezone-aware (assume UTC if naive)
|
|
if start_time_utc.tzinfo is None:
|
|
start_time_utc = start_time_utc.replace(tzinfo=timezone.utc)
|
|
else:
|
|
start_time_utc = start_time_utc.astimezone(timezone.utc)
|
|
|
|
if end_time_utc.tzinfo is None:
|
|
end_time_utc = end_time_utc.replace(tzinfo=timezone.utc)
|
|
else:
|
|
end_time_utc = end_time_utc.astimezone(timezone.utc)
|
|
|
|
if end_time_utc <= start_time_utc:
|
|
logger.warning(
|
|
f"Event {event_id} has invalid time range: start={start_time_utc}, end={end_time_utc}"
|
|
)
|
|
skipped += 1
|
|
skipped_reasons["invalid_time"] += 1
|
|
continue
|
|
|
|
# Convert UTC to local naive datetime (CalendarEvent stores local naive datetimes)
|
|
from app.utils.timezone import utc_to_local
|
|
|
|
start_time_local = utc_to_local(start_time_utc).replace(tzinfo=None)
|
|
end_time_local = utc_to_local(end_time_utc).replace(tzinfo=None)
|
|
|
|
# Try to match project from event title (optional)
|
|
project_id = None
|
|
title = event_summary
|
|
if not title:
|
|
title = "Imported Calendar Event"
|
|
|
|
# Simple matching: look for project name in title (optional, like CalDAV)
|
|
from app.models import Project
|
|
|
|
projects = Project.query.filter_by(status="active").order_by(Project.name).all()
|
|
for p in projects:
|
|
if p and p.name and p.name in title:
|
|
project_id = p.id
|
|
break
|
|
|
|
# Prepare description with marker (like CalDAV)
|
|
event_description = description if description else ""
|
|
if event_description:
|
|
event_description = f"[Google Calendar: {event_id}]\n\n{event_description}"
|
|
else:
|
|
event_description = f"[Google Calendar: {event_id}]"
|
|
|
|
# Create CalendarEvent instead of TimeEntry (like CalDAV)
|
|
calendar_event = CalendarEvent(
|
|
user_id=self.integration.user_id,
|
|
title=title,
|
|
start_time=start_time_local,
|
|
end_time=end_time_local,
|
|
description=event_description,
|
|
all_day=False,
|
|
location=None,
|
|
event_type="event",
|
|
project_id=project_id,
|
|
)
|
|
|
|
db.session.add(calendar_event)
|
|
db.session.flush()
|
|
|
|
# Note: We don't create IntegrationExternalEventLink for CalendarEvent since it requires time_entry_id
|
|
# We track imports by checking for the [Google Calendar: event_id] marker in the description field
|
|
imported += 1
|
|
logger.info(
|
|
f"Imported event {event_id} ({event_summary}) as CalendarEvent {calendar_event.id} (start: {start_time_local}, end: {end_time_local})"
|
|
)
|
|
except Exception as e:
|
|
error_msg = f"Error syncing calendar event {event.get('id', 'unknown')}: {str(e)}"
|
|
errors.append(error_msg)
|
|
logger.warning(f"{error_msg}", exc_info=True)
|
|
skipped += 1
|
|
skipped_reasons["other"] += 1
|
|
|
|
# Log detailed summary
|
|
logger.info(
|
|
f"Calendar→TimeTracker sync completed: "
|
|
f"total_events={len(events)}, imported={imported}, skipped={skipped} "
|
|
f"(reasons: {dict(skipped_reasons)})"
|
|
)
|
|
|
|
if imported == 0 and len(events) > 0:
|
|
logger.warning(
|
|
f"No events were imported despite {len(events)} events found. "
|
|
f"Check skipped_reasons: {dict(skipped_reasons)}"
|
|
)
|
|
|
|
# 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
|
|
|
|
# Commit all changes in a single transaction (time entries, links, integration status)
|
|
try:
|
|
db.session.commit()
|
|
logger.info(
|
|
f"Committed sync results: TimeTracker→Calendar={time_tracker_to_calendar_count}, Calendar→TimeTracker imported={imported}"
|
|
)
|
|
except Exception as commit_error:
|
|
db.session.rollback()
|
|
logger.error(f"Failed to commit sync results: {commit_error}", exc_info=True)
|
|
errors.append(f"Failed to commit sync: {str(commit_error)}")
|
|
return {
|
|
"success": False,
|
|
"errors": errors,
|
|
"message": f"Sync completed but failed to save results: {str(commit_error)}",
|
|
}
|
|
|
|
# Build detailed message
|
|
message_parts = []
|
|
if sync_direction in ["time_tracker_to_calendar", "bidirectional"]:
|
|
if time_tracker_to_calendar_count > 0:
|
|
message_parts.append(f"TimeTracker→Calendar: synced {time_tracker_to_calendar_count} items")
|
|
if sync_direction in ["calendar_to_time_tracker", "bidirectional"]:
|
|
if imported > 0:
|
|
message_parts.append(f"Calendar→TimeTracker: imported {imported} events")
|
|
if skipped > 0:
|
|
skipped_summary = ", ".join([f"{k}={v}" for k, v in skipped_reasons.items() if v > 0])
|
|
message_parts.append(f"({skipped} skipped: {skipped_summary})")
|
|
|
|
total_synced = time_tracker_to_calendar_count + imported
|
|
message = " | ".join(message_parts) if message_parts else f"Synced {total_synced} items"
|
|
|
|
return {
|
|
"success": True,
|
|
"synced_count": total_synced,
|
|
"imported": imported if sync_direction in ["calendar_to_time_tracker", "bidirectional"] else 0,
|
|
"skipped": skipped if sync_direction in ["calendar_to_time_tracker", "bidirectional"] else 0,
|
|
"errors": errors,
|
|
"message": message,
|
|
}
|
|
|
|
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)}"}
|
|
|
|
def _create_calendar_event(self, service, 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 = []
|
|
# 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"
|
|
|
|
# Convert local naive datetimes to UTC for Google Calendar API
|
|
from app.utils.timezone import local_to_utc
|
|
|
|
start_time_utc = local_to_utc(time_entry.start_time)
|
|
end_time_utc = local_to_utc(time_entry.end_time)
|
|
|
|
event = {
|
|
"summary": title,
|
|
"description": description,
|
|
"start": {
|
|
"dateTime": start_time_utc.isoformat(),
|
|
"timeZone": "UTC",
|
|
},
|
|
"end": {
|
|
"dateTime": end_time_utc.isoformat(),
|
|
"timeZone": "UTC",
|
|
},
|
|
"colorId": "9" if time_entry.billable else "11", # Blue for billable, red for non-billable
|
|
}
|
|
|
|
created_event = service.events().insert(calendarId=calendar_id, body=event).execute()
|
|
|
|
return created_event["id"]
|
|
|
|
def _update_calendar_event(self, service, 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 = []
|
|
# 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"
|
|
|
|
# Get existing event
|
|
event = service.events().get(calendarId=calendar_id, eventId=event_id).execute()
|
|
|
|
# Convert local naive datetimes to UTC for Google Calendar API
|
|
from app.utils.timezone import local_to_utc
|
|
|
|
start_time_utc = local_to_utc(time_entry.start_time)
|
|
end_time_utc = local_to_utc(time_entry.end_time)
|
|
|
|
# Update event
|
|
event["summary"] = title
|
|
event["description"] = description
|
|
event["start"] = {
|
|
"dateTime": start_time_utc.isoformat(),
|
|
"timeZone": "UTC",
|
|
}
|
|
event["end"] = {
|
|
"dateTime": end_time_utc.isoformat(),
|
|
"timeZone": "UTC",
|
|
}
|
|
event["colorId"] = "9" if time_entry.billable else "11"
|
|
|
|
service.events().update(calendarId=calendar_id, eventId=event_id, body=event).execute()
|
|
|
|
def _create_calendar_event_from_event(self, service, calendar_id: str, calendar_event) -> str:
|
|
"""Create a Google Calendar event from a CalendarEvent object."""
|
|
import re
|
|
|
|
# Use calendar event title
|
|
title = calendar_event.title or "Calendar Event"
|
|
|
|
# Build description - remove import markers if present
|
|
description_parts = []
|
|
description_parts.append(f"TimeTracker: Created from calendar event [CalendarEvent: {calendar_event.id}]")
|
|
|
|
if calendar_event.description:
|
|
# Remove the [Google Calendar: event_id] marker if present (it's only for tracking imports)
|
|
desc = calendar_event.description
|
|
desc = re.sub(r"\[Google Calendar: [^\]]+\]\s*\n?\n?", "", desc).strip()
|
|
if desc:
|
|
description_parts.append(desc)
|
|
|
|
if calendar_event.location:
|
|
description_parts.append(f"Location: {calendar_event.location}")
|
|
|
|
if calendar_event.event_type:
|
|
description_parts.append(f"Type: {calendar_event.event_type}")
|
|
|
|
description = (
|
|
"\n\n".join(description_parts)
|
|
if description_parts
|
|
else f"TimeTracker: Created from calendar event [CalendarEvent: {calendar_event.id}]"
|
|
)
|
|
|
|
# Convert local naive datetimes to UTC for Google Calendar API
|
|
from app.utils.timezone import local_to_utc
|
|
|
|
start_time_utc = local_to_utc(calendar_event.start_time)
|
|
end_time_utc = local_to_utc(calendar_event.end_time)
|
|
|
|
event = {
|
|
"summary": title,
|
|
"description": description,
|
|
"start": {
|
|
"dateTime": start_time_utc.isoformat(),
|
|
"timeZone": "UTC",
|
|
},
|
|
"end": {
|
|
"dateTime": end_time_utc.isoformat(),
|
|
"timeZone": "UTC",
|
|
},
|
|
}
|
|
|
|
created_event = service.events().insert(calendarId=calendar_id, body=event).execute()
|
|
|
|
return created_event["id"]
|
|
|
|
def _update_calendar_event_from_event(self, service, calendar_id: str, event_id: str, calendar_event):
|
|
"""Update an existing Google Calendar event from a CalendarEvent object."""
|
|
import re
|
|
|
|
# Use calendar event title
|
|
title = calendar_event.title or "Calendar Event"
|
|
|
|
# Build description - remove import markers if present
|
|
description_parts = []
|
|
description_parts.append(f"TimeTracker: Created from calendar event [CalendarEvent: {calendar_event.id}]")
|
|
|
|
if calendar_event.description:
|
|
# Remove the [Google Calendar: event_id] marker if present (it's only for tracking imports)
|
|
desc = calendar_event.description
|
|
desc = re.sub(r"\[Google Calendar: [^\]]+\]\s*\n?\n?", "", desc).strip()
|
|
if desc:
|
|
description_parts.append(desc)
|
|
|
|
if calendar_event.location:
|
|
description_parts.append(f"Location: {calendar_event.location}")
|
|
|
|
if calendar_event.event_type:
|
|
description_parts.append(f"Type: {calendar_event.event_type}")
|
|
|
|
description = (
|
|
"\n\n".join(description_parts)
|
|
if description_parts
|
|
else f"TimeTracker: Created from calendar event [CalendarEvent: {calendar_event.id}]"
|
|
)
|
|
|
|
# Get existing event
|
|
event = service.events().get(calendarId=calendar_id, eventId=event_id).execute()
|
|
|
|
# Convert local naive datetimes to UTC for Google Calendar API
|
|
from app.utils.timezone import local_to_utc
|
|
|
|
start_time_utc = local_to_utc(calendar_event.start_time)
|
|
end_time_utc = local_to_utc(calendar_event.end_time)
|
|
|
|
# Update event
|
|
event["summary"] = title
|
|
event["description"] = description
|
|
event["start"] = {
|
|
"dateTime": start_time_utc.isoformat(),
|
|
"timeZone": "UTC",
|
|
}
|
|
event["end"] = {
|
|
"dateTime": end_time_utc.isoformat(),
|
|
"timeZone": "UTC",
|
|
}
|
|
|
|
service.events().update(calendarId=calendar_id, eventId=event_id, body=event).execute()
|
|
|
|
def get_config_schema(self) -> Dict[str, Any]:
|
|
"""Get configuration schema."""
|
|
return {
|
|
"fields": [
|
|
{
|
|
"name": "calendar_id",
|
|
"type": "string",
|
|
"label": "Calendar ID",
|
|
"default": "primary",
|
|
"required": False,
|
|
"placeholder": "primary",
|
|
"description": "Google Calendar ID to sync with (default: primary)",
|
|
"help": "Use 'primary' for your main calendar, or enter a specific calendar ID from Google Calendar settings",
|
|
},
|
|
{
|
|
"name": "sync_direction",
|
|
"type": "select",
|
|
"label": "Sync Direction",
|
|
"options": [
|
|
{"value": "time_tracker_to_calendar", "label": "TimeTracker → Calendar (Export only)"},
|
|
{"value": "calendar_to_time_tracker", "label": "Calendar → TimeTracker (Import only)"},
|
|
{"value": "bidirectional", "label": "Bidirectional (Two-way sync)"},
|
|
],
|
|
"default": "time_tracker_to_calendar",
|
|
"description": "Choose how data flows between Google Calendar and TimeTracker",
|
|
},
|
|
{
|
|
"name": "sync_items",
|
|
"type": "array",
|
|
"label": "Items to Sync",
|
|
"options": [
|
|
{"value": "time_entries", "label": "Time Entries"},
|
|
{"value": "events", "label": "Calendar Events"},
|
|
],
|
|
"default": ["time_entries"],
|
|
"description": "Select which items to synchronize",
|
|
},
|
|
{
|
|
"name": "auto_sync",
|
|
"type": "boolean",
|
|
"label": "Auto Sync",
|
|
"default": True,
|
|
"description": "Automatically sync when time entries are created/updated",
|
|
},
|
|
{
|
|
"name": "sync_interval",
|
|
"type": "select",
|
|
"label": "Sync Schedule",
|
|
"options": [
|
|
{"value": "manual", "label": "Manual only"},
|
|
{"value": "hourly", "label": "Every hour"},
|
|
{"value": "daily", "label": "Daily"},
|
|
],
|
|
"default": "hourly",
|
|
"description": "How often to automatically sync data",
|
|
},
|
|
{
|
|
"name": "event_title_format",
|
|
"type": "text",
|
|
"label": "Event Title Format",
|
|
"default": "{project} - {task}",
|
|
"placeholder": "{project} - {task}",
|
|
"description": "Format for calendar event titles. Use {project}, {task}, {notes} as placeholders",
|
|
"help": "Customize how time entries appear as calendar events",
|
|
},
|
|
{
|
|
"name": "sync_past_days",
|
|
"type": "number",
|
|
"label": "Sync Past Days",
|
|
"default": 90,
|
|
"validation": {"min": 1, "max": 365},
|
|
"description": "Number of days in the past to sync (1-365)",
|
|
"help": "How far back to sync calendar events",
|
|
},
|
|
{
|
|
"name": "sync_future_days",
|
|
"type": "number",
|
|
"label": "Sync Future Days",
|
|
"default": 30,
|
|
"validation": {"min": 1, "max": 365},
|
|
"description": "Number of days in the future to sync (1-365)",
|
|
"help": "How far ahead to sync calendar events",
|
|
},
|
|
],
|
|
"required": [],
|
|
"sections": [
|
|
{
|
|
"title": "Calendar Settings",
|
|
"description": "Configure your Google Calendar connection",
|
|
"fields": ["calendar_id"],
|
|
},
|
|
{
|
|
"title": "Sync Settings",
|
|
"description": "Configure what and how to sync",
|
|
"fields": [
|
|
"sync_direction",
|
|
"sync_items",
|
|
"auto_sync",
|
|
"sync_interval",
|
|
"sync_past_days",
|
|
"sync_future_days",
|
|
],
|
|
},
|
|
{
|
|
"title": "Display Settings",
|
|
"description": "Customize how events appear in the calendar",
|
|
"fields": ["event_title_format"],
|
|
},
|
|
],
|
|
"sync_settings": {
|
|
"enabled": True,
|
|
"auto_sync": True,
|
|
"sync_interval": "hourly",
|
|
"sync_direction": "time_tracker_to_calendar",
|
|
"sync_items": ["time_entries"],
|
|
},
|
|
}
|