651 lines
26 KiB
Python
651 lines
26 KiB
Python
"""
|
|
Calendar integration for content scheduling.
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
from typing import Dict, List, Any, Optional, Tuple
|
|
from dataclasses import dataclass
|
|
import json
|
|
|
|
# Use unified database models
|
|
from lib.database.models import ContentItem, Schedule, ScheduleStatus, ContentType, Platform, get_session
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
@dataclass
|
|
class CalendarEvent:
|
|
"""Calendar event representation."""
|
|
id: str
|
|
title: str
|
|
description: str
|
|
start_time: datetime
|
|
end_time: datetime
|
|
location: Optional[str] = None
|
|
attendees: List[str] = None
|
|
event_type: str = "content_schedule"
|
|
metadata: Dict[str, Any] = None
|
|
|
|
class CalendarIntegration:
|
|
"""Integration with calendar systems for content scheduling."""
|
|
|
|
def __init__(self, calendar_provider: str = "google"):
|
|
"""Initialize calendar integration.
|
|
|
|
Args:
|
|
calendar_provider: Calendar provider (google, outlook, etc.)
|
|
"""
|
|
self.logger = logger
|
|
self.session = get_session()
|
|
self.calendar_provider = calendar_provider
|
|
|
|
# Calendar provider configurations
|
|
self.provider_configs = {
|
|
'google': {
|
|
'api_endpoint': 'https://www.googleapis.com/calendar/v3',
|
|
'scopes': ['https://www.googleapis.com/auth/calendar'],
|
|
'event_duration_minutes': 30
|
|
},
|
|
'outlook': {
|
|
'api_endpoint': 'https://graph.microsoft.com/v1.0',
|
|
'scopes': ['https://graph.microsoft.com/calendars.readwrite'],
|
|
'event_duration_minutes': 30
|
|
},
|
|
'apple': {
|
|
'api_endpoint': 'https://caldav.icloud.com',
|
|
'scopes': ['calendar'],
|
|
'event_duration_minutes': 30
|
|
}
|
|
}
|
|
|
|
# Event templates for different content types
|
|
self.event_templates = {
|
|
ContentType.ARTICLE: {
|
|
'title_prefix': '📝 Publish Article:',
|
|
'description_template': 'Publish article "{title}" to {platforms}',
|
|
'duration_minutes': 15
|
|
},
|
|
ContentType.VIDEO: {
|
|
'title_prefix': '🎥 Publish Video:',
|
|
'description_template': 'Publish video "{title}" to {platforms}',
|
|
'duration_minutes': 30
|
|
},
|
|
ContentType.IMAGE: {
|
|
'title_prefix': '📸 Publish Image:',
|
|
'description_template': 'Publish image "{title}" to {platforms}',
|
|
'duration_minutes': 10
|
|
},
|
|
ContentType.SOCIAL_POST: {
|
|
'title_prefix': '📱 Social Post:',
|
|
'description_template': 'Publish social post "{title}" to {platforms}',
|
|
'duration_minutes': 5
|
|
}
|
|
}
|
|
|
|
def sync_schedules_to_calendar(self, schedules: List[Schedule] = None) -> Dict[str, Any]:
|
|
"""Sync content schedules to calendar.
|
|
|
|
Args:
|
|
schedules: List of schedules to sync (if None, sync all pending schedules)
|
|
|
|
Returns:
|
|
Dictionary with sync results
|
|
"""
|
|
try:
|
|
if schedules is None:
|
|
schedules = self.session.query(Schedule).filter(
|
|
Schedule.status == ScheduleStatus.PENDING
|
|
).all()
|
|
|
|
sync_results = {
|
|
'total_schedules': len(schedules),
|
|
'synced_successfully': 0,
|
|
'failed_syncs': 0,
|
|
'errors': [],
|
|
'created_events': []
|
|
}
|
|
|
|
for schedule in schedules:
|
|
try:
|
|
# Get content item details
|
|
content_item = self.session.query(ContentItem).filter(
|
|
ContentItem.id == schedule.content_item_id
|
|
).first()
|
|
|
|
if not content_item:
|
|
sync_results['errors'].append(f"Content item not found for schedule {schedule.id}")
|
|
sync_results['failed_syncs'] += 1
|
|
continue
|
|
|
|
# Create calendar event
|
|
event = self._create_calendar_event(schedule, content_item)
|
|
|
|
# Sync to calendar provider
|
|
event_id = self._sync_event_to_provider(event)
|
|
|
|
if event_id:
|
|
# Update schedule with calendar event ID
|
|
schedule.metadata = schedule.metadata or {}
|
|
schedule.metadata['calendar_event_id'] = event_id
|
|
self.session.commit()
|
|
|
|
sync_results['synced_successfully'] += 1
|
|
sync_results['created_events'].append({
|
|
'schedule_id': schedule.id,
|
|
'event_id': event_id,
|
|
'title': event.title
|
|
})
|
|
else:
|
|
sync_results['failed_syncs'] += 1
|
|
sync_results['errors'].append(f"Failed to create calendar event for schedule {schedule.id}")
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error syncing schedule {schedule.id}: {str(e)}")
|
|
sync_results['failed_syncs'] += 1
|
|
sync_results['errors'].append(f"Schedule {schedule.id}: {str(e)}")
|
|
|
|
return sync_results
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error syncing schedules to calendar: {str(e)}")
|
|
return {
|
|
'total_schedules': 0,
|
|
'synced_successfully': 0,
|
|
'failed_syncs': 0,
|
|
'errors': [f"Sync error: {str(e)}"],
|
|
'created_events': []
|
|
}
|
|
|
|
def import_calendar_events(self, calendar_id: str = None, date_range: Tuple[datetime, datetime] = None) -> Dict[str, Any]:
|
|
"""Import events from calendar and suggest content schedules.
|
|
|
|
Args:
|
|
calendar_id: Calendar ID to import from
|
|
date_range: Date range to import events from
|
|
|
|
Returns:
|
|
Dictionary with import results and suggestions
|
|
"""
|
|
try:
|
|
if date_range is None:
|
|
start_date = datetime.now()
|
|
end_date = start_date + timedelta(days=30)
|
|
date_range = (start_date, end_date)
|
|
|
|
# Get events from calendar provider
|
|
events = self._get_events_from_provider(calendar_id, date_range)
|
|
|
|
import_results = {
|
|
'total_events': len(events),
|
|
'content_suggestions': [],
|
|
'scheduling_gaps': [],
|
|
'optimal_times': []
|
|
}
|
|
|
|
# Analyze events for content scheduling opportunities
|
|
for event in events:
|
|
suggestions = self._analyze_event_for_content_opportunities(event)
|
|
import_results['content_suggestions'].extend(suggestions)
|
|
|
|
# Find scheduling gaps
|
|
gaps = self._find_scheduling_gaps(events, date_range)
|
|
import_results['scheduling_gaps'] = gaps
|
|
|
|
# Suggest optimal posting times
|
|
optimal_times = self._suggest_optimal_posting_times(events, date_range)
|
|
import_results['optimal_times'] = optimal_times
|
|
|
|
return import_results
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error importing calendar events: {str(e)}")
|
|
return {
|
|
'total_events': 0,
|
|
'content_suggestions': [],
|
|
'scheduling_gaps': [],
|
|
'optimal_times': [],
|
|
'error': str(e)
|
|
}
|
|
|
|
def create_content_schedule_from_event(self, event: CalendarEvent, content_item_id: int) -> Optional[Schedule]:
|
|
"""Create a content schedule from a calendar event.
|
|
|
|
Args:
|
|
event: Calendar event
|
|
content_item_id: ID of content item to schedule
|
|
|
|
Returns:
|
|
Created schedule or None if failed
|
|
"""
|
|
try:
|
|
# Get content item
|
|
content_item = self.session.query(ContentItem).filter(
|
|
ContentItem.id == content_item_id
|
|
).first()
|
|
|
|
if not content_item:
|
|
self.logger.error(f"Content item {content_item_id} not found")
|
|
return None
|
|
|
|
# Create schedule
|
|
schedule = Schedule(
|
|
content_item_id=content_item_id,
|
|
scheduled_time=event.start_time,
|
|
status=ScheduleStatus.PENDING,
|
|
priority=5, # Default priority
|
|
metadata={
|
|
'calendar_event_id': event.id,
|
|
'created_from_calendar': True,
|
|
'original_event_title': event.title
|
|
}
|
|
)
|
|
|
|
self.session.add(schedule)
|
|
self.session.commit()
|
|
|
|
self.logger.info(f"Created schedule {schedule.id} from calendar event {event.id}")
|
|
return schedule
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error creating schedule from event: {str(e)}")
|
|
self.session.rollback()
|
|
return None
|
|
|
|
def update_calendar_event_from_schedule(self, schedule: Schedule) -> bool:
|
|
"""Update calendar event when schedule changes.
|
|
|
|
Args:
|
|
schedule: Updated schedule
|
|
|
|
Returns:
|
|
True if successful, False otherwise
|
|
"""
|
|
try:
|
|
# Check if schedule has associated calendar event
|
|
if not schedule.metadata or 'calendar_event_id' not in schedule.metadata:
|
|
return False
|
|
|
|
event_id = schedule.metadata['calendar_event_id']
|
|
|
|
# Get content item
|
|
content_item = self.session.query(ContentItem).filter(
|
|
ContentItem.id == schedule.content_item_id
|
|
).first()
|
|
|
|
if not content_item:
|
|
return False
|
|
|
|
# Create updated event
|
|
updated_event = self._create_calendar_event(schedule, content_item)
|
|
updated_event.id = event_id
|
|
|
|
# Update event in calendar provider
|
|
success = self._update_event_in_provider(updated_event)
|
|
|
|
if success:
|
|
self.logger.info(f"Updated calendar event {event_id} for schedule {schedule.id}")
|
|
else:
|
|
self.logger.error(f"Failed to update calendar event {event_id}")
|
|
|
|
return success
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error updating calendar event: {str(e)}")
|
|
return False
|
|
|
|
def delete_calendar_event_from_schedule(self, schedule: Schedule) -> bool:
|
|
"""Delete calendar event when schedule is deleted.
|
|
|
|
Args:
|
|
schedule: Schedule being deleted
|
|
|
|
Returns:
|
|
True if successful, False otherwise
|
|
"""
|
|
try:
|
|
# Check if schedule has associated calendar event
|
|
if not schedule.metadata or 'calendar_event_id' not in schedule.metadata:
|
|
return True # No event to delete
|
|
|
|
event_id = schedule.metadata['calendar_event_id']
|
|
|
|
# Delete event from calendar provider
|
|
success = self._delete_event_from_provider(event_id)
|
|
|
|
if success:
|
|
self.logger.info(f"Deleted calendar event {event_id} for schedule {schedule.id}")
|
|
else:
|
|
self.logger.error(f"Failed to delete calendar event {event_id}")
|
|
|
|
return success
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error deleting calendar event: {str(e)}")
|
|
return False
|
|
|
|
def get_calendar_view(self, date_range: Tuple[datetime, datetime] = None) -> Dict[str, Any]:
|
|
"""Get calendar view of scheduled content.
|
|
|
|
Args:
|
|
date_range: Date range for calendar view
|
|
|
|
Returns:
|
|
Dictionary with calendar view data
|
|
"""
|
|
try:
|
|
if date_range is None:
|
|
start_date = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
|
end_date = start_date + timedelta(days=30)
|
|
date_range = (start_date, end_date)
|
|
|
|
# Get schedules in date range
|
|
schedules = self.session.query(Schedule).filter(
|
|
Schedule.scheduled_time >= date_range[0],
|
|
Schedule.scheduled_time <= date_range[1]
|
|
).all()
|
|
|
|
calendar_events = []
|
|
for schedule in schedules:
|
|
content_item = self.session.query(ContentItem).filter(
|
|
ContentItem.id == schedule.content_item_id
|
|
).first()
|
|
|
|
if content_item:
|
|
event = self._create_calendar_event(schedule, content_item)
|
|
calendar_events.append({
|
|
'id': str(schedule.id),
|
|
'title': event.title,
|
|
'description': event.description,
|
|
'start': event.start_time.isoformat(),
|
|
'end': event.end_time.isoformat(),
|
|
'status': schedule.status.value,
|
|
'priority': schedule.priority,
|
|
'content_type': content_item.content_type.value if content_item.content_type else 'unknown',
|
|
'platforms': schedule.platforms or []
|
|
})
|
|
|
|
# Group events by day
|
|
events_by_day = {}
|
|
for event in calendar_events:
|
|
day = datetime.fromisoformat(event['start']).date()
|
|
if day not in events_by_day:
|
|
events_by_day[day] = []
|
|
events_by_day[day].append(event)
|
|
|
|
return {
|
|
'date_range': {
|
|
'start': date_range[0].isoformat(),
|
|
'end': date_range[1].isoformat()
|
|
},
|
|
'total_events': len(calendar_events),
|
|
'events': calendar_events,
|
|
'events_by_day': {day.isoformat(): events for day, events in events_by_day.items()},
|
|
'summary': self._generate_calendar_summary(calendar_events)
|
|
}
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error getting calendar view: {str(e)}")
|
|
return {
|
|
'date_range': None,
|
|
'total_events': 0,
|
|
'events': [],
|
|
'events_by_day': {},
|
|
'summary': {},
|
|
'error': str(e)
|
|
}
|
|
|
|
def _create_calendar_event(self, schedule: Schedule, content_item: ContentItem) -> CalendarEvent:
|
|
"""Create calendar event from schedule and content item."""
|
|
try:
|
|
# Get event template based on content type
|
|
template = self.event_templates.get(
|
|
content_item.content_type,
|
|
self.event_templates[ContentType.SOCIAL_POST]
|
|
)
|
|
|
|
# Create event title
|
|
title = f"{template['title_prefix']} {content_item.title}"
|
|
|
|
# Create event description
|
|
platforms_str = ', '.join(schedule.platforms) if schedule.platforms else 'Default platforms'
|
|
description = template['description_template'].format(
|
|
title=content_item.title,
|
|
platforms=platforms_str
|
|
)
|
|
|
|
# Add content summary if available
|
|
if content_item.summary:
|
|
description += f"\n\nSummary: {content_item.summary}"
|
|
|
|
# Calculate end time
|
|
duration = timedelta(minutes=template['duration_minutes'])
|
|
end_time = schedule.scheduled_time + duration
|
|
|
|
# Create metadata
|
|
metadata = {
|
|
'schedule_id': schedule.id,
|
|
'content_item_id': content_item.id,
|
|
'content_type': content_item.content_type.value if content_item.content_type else 'unknown',
|
|
'platforms': schedule.platforms or [],
|
|
'priority': schedule.priority,
|
|
'status': schedule.status.value
|
|
}
|
|
|
|
return CalendarEvent(
|
|
id=f"schedule_{schedule.id}",
|
|
title=title,
|
|
description=description,
|
|
start_time=schedule.scheduled_time,
|
|
end_time=end_time,
|
|
metadata=metadata
|
|
)
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error creating calendar event: {str(e)}")
|
|
# Return a basic event as fallback
|
|
return CalendarEvent(
|
|
id=f"schedule_{schedule.id}",
|
|
title=f"Content Schedule: {content_item.title}",
|
|
description="Content publishing schedule",
|
|
start_time=schedule.scheduled_time,
|
|
end_time=schedule.scheduled_time + timedelta(minutes=30)
|
|
)
|
|
|
|
def _sync_event_to_provider(self, event: CalendarEvent) -> Optional[str]:
|
|
"""Sync event to calendar provider (mock implementation)."""
|
|
try:
|
|
# This is a mock implementation
|
|
# In a real system, you would integrate with actual calendar APIs
|
|
|
|
self.logger.info(f"Syncing event to {self.calendar_provider}: {event.title}")
|
|
|
|
# Simulate API call
|
|
event_id = f"{self.calendar_provider}_{event.id}_{int(datetime.now().timestamp())}"
|
|
|
|
return event_id
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error syncing event to provider: {str(e)}")
|
|
return None
|
|
|
|
def _get_events_from_provider(self, calendar_id: str, date_range: Tuple[datetime, datetime]) -> List[CalendarEvent]:
|
|
"""Get events from calendar provider (mock implementation)."""
|
|
try:
|
|
# This is a mock implementation
|
|
# In a real system, you would fetch from actual calendar APIs
|
|
|
|
self.logger.info(f"Fetching events from {self.calendar_provider} calendar {calendar_id}")
|
|
|
|
# Return empty list for mock
|
|
return []
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error fetching events from provider: {str(e)}")
|
|
return []
|
|
|
|
def _update_event_in_provider(self, event: CalendarEvent) -> bool:
|
|
"""Update event in calendar provider (mock implementation)."""
|
|
try:
|
|
# This is a mock implementation
|
|
self.logger.info(f"Updating event in {self.calendar_provider}: {event.id}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error updating event in provider: {str(e)}")
|
|
return False
|
|
|
|
def _delete_event_from_provider(self, event_id: str) -> bool:
|
|
"""Delete event from calendar provider (mock implementation)."""
|
|
try:
|
|
# This is a mock implementation
|
|
self.logger.info(f"Deleting event from {self.calendar_provider}: {event_id}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error deleting event from provider: {str(e)}")
|
|
return False
|
|
|
|
def _analyze_event_for_content_opportunities(self, event: CalendarEvent) -> List[Dict[str, Any]]:
|
|
"""Analyze calendar event for content opportunities."""
|
|
suggestions = []
|
|
|
|
try:
|
|
# Look for keywords that suggest content opportunities
|
|
content_keywords = ['meeting', 'conference', 'launch', 'announcement', 'webinar', 'presentation']
|
|
|
|
event_text = f"{event.title} {event.description}".lower()
|
|
|
|
for keyword in content_keywords:
|
|
if keyword in event_text:
|
|
suggestions.append({
|
|
'type': 'content_opportunity',
|
|
'keyword': keyword,
|
|
'suggested_time': event.end_time, # Suggest posting after the event
|
|
'content_type': self._suggest_content_type_for_keyword(keyword),
|
|
'description': f"Consider creating content about the {keyword}"
|
|
})
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error analyzing event for opportunities: {str(e)}")
|
|
|
|
return suggestions
|
|
|
|
def _find_scheduling_gaps(self, events: List[CalendarEvent], date_range: Tuple[datetime, datetime]) -> List[Dict[str, Any]]:
|
|
"""Find gaps in schedule that could be used for content posting."""
|
|
gaps = []
|
|
|
|
try:
|
|
# Sort events by start time
|
|
sorted_events = sorted(events, key=lambda x: x.start_time)
|
|
|
|
current_time = date_range[0]
|
|
|
|
for event in sorted_events:
|
|
# Check if there's a gap before this event
|
|
if event.start_time > current_time + timedelta(hours=2):
|
|
gaps.append({
|
|
'start': current_time.isoformat(),
|
|
'end': event.start_time.isoformat(),
|
|
'duration_hours': (event.start_time - current_time).total_seconds() / 3600,
|
|
'suggested_use': 'Content posting opportunity'
|
|
})
|
|
|
|
current_time = max(current_time, event.end_time)
|
|
|
|
# Check for gap after last event
|
|
if current_time < date_range[1] - timedelta(hours=2):
|
|
gaps.append({
|
|
'start': current_time.isoformat(),
|
|
'end': date_range[1].isoformat(),
|
|
'duration_hours': (date_range[1] - current_time).total_seconds() / 3600,
|
|
'suggested_use': 'Content posting opportunity'
|
|
})
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error finding scheduling gaps: {str(e)}")
|
|
|
|
return gaps
|
|
|
|
def _suggest_optimal_posting_times(self, events: List[CalendarEvent], date_range: Tuple[datetime, datetime]) -> List[Dict[str, Any]]:
|
|
"""Suggest optimal times for content posting based on calendar."""
|
|
optimal_times = []
|
|
|
|
try:
|
|
# Define optimal posting hours (9 AM, 1 PM, 5 PM)
|
|
optimal_hours = [9, 13, 17]
|
|
|
|
current_date = date_range[0].date()
|
|
end_date = date_range[1].date()
|
|
|
|
while current_date <= end_date:
|
|
for hour in optimal_hours:
|
|
suggested_time = datetime.combine(current_date, datetime.min.time().replace(hour=hour))
|
|
|
|
# Check if this time conflicts with any events
|
|
conflicts = any(
|
|
event.start_time <= suggested_time <= event.end_time
|
|
for event in events
|
|
)
|
|
|
|
if not conflicts:
|
|
optimal_times.append({
|
|
'time': suggested_time.isoformat(),
|
|
'reason': f'Optimal posting time ({hour}:00) with no calendar conflicts',
|
|
'confidence': 0.8
|
|
})
|
|
|
|
current_date += timedelta(days=1)
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error suggesting optimal posting times: {str(e)}")
|
|
|
|
return optimal_times
|
|
|
|
def _suggest_content_type_for_keyword(self, keyword: str) -> str:
|
|
"""Suggest content type based on keyword."""
|
|
keyword_mapping = {
|
|
'meeting': 'social_post',
|
|
'conference': 'article',
|
|
'launch': 'video',
|
|
'announcement': 'social_post',
|
|
'webinar': 'video',
|
|
'presentation': 'article'
|
|
}
|
|
|
|
return keyword_mapping.get(keyword, 'social_post')
|
|
|
|
def _generate_calendar_summary(self, events: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
"""Generate summary statistics for calendar events."""
|
|
try:
|
|
if not events:
|
|
return {}
|
|
|
|
# Count by status
|
|
status_counts = {}
|
|
for event in events:
|
|
status = event.get('status', 'unknown')
|
|
status_counts[status] = status_counts.get(status, 0) + 1
|
|
|
|
# Count by content type
|
|
type_counts = {}
|
|
for event in events:
|
|
content_type = event.get('content_type', 'unknown')
|
|
type_counts[content_type] = type_counts.get(content_type, 0) + 1
|
|
|
|
# Count by day
|
|
daily_counts = {}
|
|
for event in events:
|
|
day = datetime.fromisoformat(event['start']).date().isoformat()
|
|
daily_counts[day] = daily_counts.get(day, 0) + 1
|
|
|
|
return {
|
|
'total_events': len(events),
|
|
'by_status': status_counts,
|
|
'by_content_type': type_counts,
|
|
'by_day': daily_counts,
|
|
'busiest_day': max(daily_counts.items(), key=lambda x: x[1]) if daily_counts else None
|
|
}
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error generating calendar summary: {str(e)}")
|
|
return {} |