alwrity chatbot assistant, content scheduler, and content repurposing
This commit is contained in:
201
lib/content_scheduler/utils/date_utils.py
Normal file
201
lib/content_scheduler/utils/date_utils.py
Normal file
@@ -0,0 +1,201 @@
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
import pytz
|
||||
from dateutil import rrule
|
||||
from .error_handling import ScheduleValidationError
|
||||
|
||||
def get_optimal_publish_time(
|
||||
platform: str,
|
||||
content_type: str,
|
||||
target_audience: Optional[Dict[str, Any]] = None
|
||||
) -> datetime:
|
||||
"""Calculate optimal publish time based on platform and content type."""
|
||||
now = datetime.now(pytz.UTC)
|
||||
|
||||
# Default optimal times by platform and content type
|
||||
optimal_times = {
|
||||
'TWITTER': {
|
||||
'POST': {'hour': 12, 'minute': 0}, # Noon UTC
|
||||
'THREAD': {'hour': 15, 'minute': 0}, # 3 PM UTC
|
||||
'POLL': {'hour': 18, 'minute': 0}, # 6 PM UTC
|
||||
},
|
||||
'FACEBOOK': {
|
||||
'POST': {'hour': 15, 'minute': 0}, # 3 PM UTC
|
||||
'LIVE': {'hour': 19, 'minute': 0}, # 7 PM UTC
|
||||
'EVENT': {'hour': 10, 'minute': 0}, # 10 AM UTC
|
||||
},
|
||||
'LINKEDIN': {
|
||||
'POST': {'hour': 9, 'minute': 0}, # 9 AM UTC
|
||||
'ARTICLE': {'hour': 11, 'minute': 0}, # 11 AM UTC
|
||||
'POLL': {'hour': 14, 'minute': 0}, # 2 PM UTC
|
||||
},
|
||||
'INSTAGRAM': {
|
||||
'POST': {'hour': 17, 'minute': 0}, # 5 PM UTC
|
||||
'STORY': {'hour': 20, 'minute': 0}, # 8 PM UTC
|
||||
'REEL': {'hour': 21, 'minute': 0}, # 9 PM UTC
|
||||
}
|
||||
}
|
||||
|
||||
if platform not in optimal_times:
|
||||
raise ScheduleValidationError(
|
||||
f"Unsupported platform: {platform}",
|
||||
{'supported_platforms': list(optimal_times.keys())}
|
||||
)
|
||||
|
||||
if content_type not in optimal_times[platform]:
|
||||
raise ScheduleValidationError(
|
||||
f"Unsupported content type for {platform}: {content_type}",
|
||||
{'supported_types': list(optimal_times[platform].keys())}
|
||||
)
|
||||
|
||||
optimal_time = optimal_times[platform][content_type]
|
||||
publish_time = now.replace(
|
||||
hour=optimal_time['hour'],
|
||||
minute=optimal_time['minute'],
|
||||
second=0,
|
||||
microsecond=0
|
||||
)
|
||||
|
||||
# If the optimal time has passed for today, schedule for tomorrow
|
||||
if publish_time < now:
|
||||
publish_time += timedelta(days=1)
|
||||
|
||||
return publish_time
|
||||
|
||||
def calculate_recurrence_dates(
|
||||
start_date: datetime,
|
||||
frequency: str,
|
||||
interval: int,
|
||||
end_date: Optional[datetime] = None,
|
||||
count: Optional[int] = None
|
||||
) -> List[datetime]:
|
||||
"""Calculate recurrence dates based on frequency and interval."""
|
||||
if not isinstance(start_date, datetime):
|
||||
raise ScheduleValidationError(
|
||||
"Start date must be a datetime object",
|
||||
{'type': type(start_date).__name__}
|
||||
)
|
||||
|
||||
if start_date.tzinfo is None:
|
||||
raise ScheduleValidationError(
|
||||
"Start date must be timezone-aware",
|
||||
{'date': str(start_date)}
|
||||
)
|
||||
|
||||
frequency_map = {
|
||||
'DAILY': rrule.DAILY,
|
||||
'WEEKLY': rrule.WEEKLY,
|
||||
'MONTHLY': rrule.MONTHLY,
|
||||
'YEARLY': rrule.YEARLY
|
||||
}
|
||||
|
||||
if frequency not in frequency_map:
|
||||
raise ScheduleValidationError(
|
||||
f"Invalid frequency: {frequency}",
|
||||
{'valid_frequencies': list(frequency_map.keys())}
|
||||
)
|
||||
|
||||
if not isinstance(interval, int) or interval < 1:
|
||||
raise ScheduleValidationError(
|
||||
"Interval must be a positive integer",
|
||||
{'interval': interval}
|
||||
)
|
||||
|
||||
if end_date is not None and not isinstance(end_date, datetime):
|
||||
raise ScheduleValidationError(
|
||||
"End date must be a datetime object",
|
||||
{'type': type(end_date).__name__}
|
||||
)
|
||||
|
||||
if end_date is not None and end_date.tzinfo is None:
|
||||
raise ScheduleValidationError(
|
||||
"End date must be timezone-aware",
|
||||
{'date': str(end_date)}
|
||||
)
|
||||
|
||||
if count is not None and (not isinstance(count, int) or count < 1):
|
||||
raise ScheduleValidationError(
|
||||
"Count must be a positive integer",
|
||||
{'count': count}
|
||||
)
|
||||
|
||||
rule = rrule.rrule(
|
||||
freq=frequency_map[frequency],
|
||||
interval=interval,
|
||||
dtstart=start_date,
|
||||
until=end_date,
|
||||
count=count
|
||||
)
|
||||
|
||||
return list(rule)
|
||||
|
||||
def adjust_for_timezone(
|
||||
date: datetime,
|
||||
target_timezone: str
|
||||
) -> datetime:
|
||||
"""Adjust datetime to target timezone."""
|
||||
if not isinstance(date, datetime):
|
||||
raise ScheduleValidationError(
|
||||
"Date must be a datetime object",
|
||||
{'type': type(date).__name__}
|
||||
)
|
||||
|
||||
if date.tzinfo is None:
|
||||
raise ScheduleValidationError(
|
||||
"Date must be timezone-aware",
|
||||
{'date': str(date)}
|
||||
)
|
||||
|
||||
try:
|
||||
target_tz = pytz.timezone(target_timezone)
|
||||
except pytz.exceptions.UnknownTimeZoneError:
|
||||
raise ScheduleValidationError(
|
||||
f"Invalid timezone: {target_timezone}",
|
||||
{'timezone': target_timezone}
|
||||
)
|
||||
|
||||
return date.astimezone(target_tz)
|
||||
|
||||
def calculate_time_difference(
|
||||
date1: datetime,
|
||||
date2: datetime
|
||||
) -> timedelta:
|
||||
"""Calculate time difference between two dates."""
|
||||
if not isinstance(date1, datetime) or not isinstance(date2, datetime):
|
||||
raise ScheduleValidationError(
|
||||
"Both dates must be datetime objects",
|
||||
{
|
||||
'date1_type': type(date1).__name__,
|
||||
'date2_type': type(date2).__name__
|
||||
}
|
||||
)
|
||||
|
||||
if date1.tzinfo is None or date2.tzinfo is None:
|
||||
raise ScheduleValidationError(
|
||||
"Both dates must be timezone-aware",
|
||||
{
|
||||
'date1': str(date1),
|
||||
'date2': str(date2)
|
||||
}
|
||||
)
|
||||
|
||||
return date2 - date1
|
||||
|
||||
def format_date_for_display(
|
||||
date: datetime,
|
||||
format_str: str = "%Y-%m-%d %H:%M:%S %Z"
|
||||
) -> str:
|
||||
"""Format datetime for display."""
|
||||
if not isinstance(date, datetime):
|
||||
raise ScheduleValidationError(
|
||||
"Date must be a datetime object",
|
||||
{'type': type(date).__name__}
|
||||
)
|
||||
|
||||
if date.tzinfo is None:
|
||||
raise ScheduleValidationError(
|
||||
"Date must be timezone-aware",
|
||||
{'date': str(date)}
|
||||
)
|
||||
|
||||
return date.strftime(format_str)
|
||||
134
lib/content_scheduler/utils/error_handling.py
Normal file
134
lib/content_scheduler/utils/error_handling.py
Normal file
@@ -0,0 +1,134 @@
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
from functools import wraps
|
||||
import traceback
|
||||
|
||||
logger = logging.getLogger('content_scheduler')
|
||||
|
||||
class SchedulingError(Exception):
|
||||
"""Exception raised for errors in content scheduling."""
|
||||
|
||||
def __init__(self, message: str):
|
||||
"""Initialize the error with a message.
|
||||
|
||||
Args:
|
||||
message: Error message
|
||||
"""
|
||||
self.message = message
|
||||
super().__init__(self.message)
|
||||
|
||||
class JobExecutionError(SchedulingError):
|
||||
"""Exception for job execution errors."""
|
||||
pass
|
||||
|
||||
class ScheduleValidationError(SchedulingError):
|
||||
"""Exception for schedule validation errors."""
|
||||
pass
|
||||
|
||||
class PlatformError(SchedulingError):
|
||||
"""Exception for platform-specific errors."""
|
||||
pass
|
||||
|
||||
class DatabaseError(SchedulingError):
|
||||
"""Exception for database-related errors."""
|
||||
pass
|
||||
|
||||
def handle_scheduler_error(func):
|
||||
"""Decorator for handling scheduler errors."""
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except SchedulingError as e:
|
||||
logger.error(f"Scheduling error in {func.__name__}: {str(e)}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in {func.__name__}: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise SchedulingError(
|
||||
f"Unexpected error in {func.__name__}: {str(e)}",
|
||||
{'traceback': traceback.format_exc()}
|
||||
)
|
||||
return wrapper
|
||||
|
||||
def handle_job_error(func):
|
||||
"""Decorator for handling job execution errors."""
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"Job execution error in {func.__name__}: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise JobExecutionError(
|
||||
f"Job execution failed: {str(e)}",
|
||||
{
|
||||
'function': func.__name__,
|
||||
'traceback': traceback.format_exc()
|
||||
}
|
||||
)
|
||||
return wrapper
|
||||
|
||||
def handle_platform_error(func):
|
||||
"""Decorator for handling platform-specific errors."""
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"Platform error in {func.__name__}: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise PlatformError(
|
||||
f"Platform operation failed: {str(e)}",
|
||||
{
|
||||
'function': func.__name__,
|
||||
'traceback': traceback.format_exc()
|
||||
}
|
||||
)
|
||||
return wrapper
|
||||
|
||||
def handle_database_error(func):
|
||||
"""Decorator for handling database errors."""
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"Database error in {func.__name__}: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise DatabaseError(
|
||||
f"Database operation failed: {str(e)}",
|
||||
{
|
||||
'function': func.__name__,
|
||||
'traceback': traceback.format_exc()
|
||||
}
|
||||
)
|
||||
return wrapper
|
||||
|
||||
def format_error(error: Exception) -> Dict[str, Any]:
|
||||
"""Format error for logging and reporting."""
|
||||
if isinstance(error, SchedulingError):
|
||||
return {
|
||||
'type': error.__class__.__name__,
|
||||
'message': str(error),
|
||||
'details': error.details
|
||||
}
|
||||
else:
|
||||
return {
|
||||
'type': 'UnexpectedError',
|
||||
'message': str(error),
|
||||
'details': {
|
||||
'traceback': traceback.format_exc()
|
||||
}
|
||||
}
|
||||
|
||||
def log_error(error: Exception, context: Optional[Dict[str, Any]] = None):
|
||||
"""Log error with context."""
|
||||
error_data = format_error(error)
|
||||
if context:
|
||||
error_data['context'] = context
|
||||
|
||||
logger.error(
|
||||
f"Error: {error_data['type']} - {error_data['message']}",
|
||||
extra={'error_data': error_data}
|
||||
)
|
||||
11
lib/content_scheduler/utils/logging.py
Normal file
11
lib/content_scheduler/utils/logging.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import logging
|
||||
|
||||
def setup_logger(name: str = "content_scheduler", level=logging.INFO):
|
||||
logger = logging.getLogger(name)
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(level)
|
||||
return logger
|
||||
285
lib/content_scheduler/utils/notification.py
Normal file
285
lib/content_scheduler/utils/notification.py
Normal file
@@ -0,0 +1,285 @@
|
||||
from typing import Dict, Any, List, Optional
|
||||
import logging
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
import aiohttp
|
||||
import json
|
||||
from .error_handling import PlatformError
|
||||
|
||||
logger = logging.getLogger('content_scheduler')
|
||||
|
||||
class NotificationManager:
|
||||
"""Manages notifications for scheduled content."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
"""Initialize notification manager with configuration."""
|
||||
self.config = config
|
||||
self.email_config = config.get('email', {})
|
||||
self.slack_config = config.get('slack', {})
|
||||
self.webhook_config = config.get('webhook', {})
|
||||
|
||||
async def send_notification(
|
||||
self,
|
||||
event_type: str,
|
||||
content: Dict[str, Any],
|
||||
channels: List[str],
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Send notification through specified channels."""
|
||||
results = {}
|
||||
|
||||
for channel in channels:
|
||||
try:
|
||||
if channel == 'EMAIL':
|
||||
results['email'] = await self._send_email_notification(
|
||||
event_type, content, metadata
|
||||
)
|
||||
elif channel == 'SLACK':
|
||||
results['slack'] = await self._send_slack_notification(
|
||||
event_type, content, metadata
|
||||
)
|
||||
elif channel == 'WEBHOOK':
|
||||
results['webhook'] = await self._send_webhook_notification(
|
||||
event_type, content, metadata
|
||||
)
|
||||
else:
|
||||
logger.warning(f"Unsupported notification channel: {channel}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send {channel} notification: {str(e)}")
|
||||
results[channel] = {
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
async def _send_email_notification(
|
||||
self,
|
||||
event_type: str,
|
||||
content: Dict[str, Any],
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Send email notification."""
|
||||
if not self.email_config:
|
||||
raise PlatformError(
|
||||
"Email configuration not found",
|
||||
{'event_type': event_type}
|
||||
)
|
||||
|
||||
try:
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = self.email_config['from_email']
|
||||
msg['To'] = self.email_config['to_email']
|
||||
msg['Subject'] = self._get_email_subject(event_type, content)
|
||||
|
||||
body = self._format_email_body(event_type, content, metadata)
|
||||
msg.attach(MIMEText(body, 'html'))
|
||||
|
||||
with smtplib.SMTP(
|
||||
self.email_config['smtp_server'],
|
||||
self.email_config['smtp_port']
|
||||
) as server:
|
||||
if self.email_config.get('use_tls'):
|
||||
server.starttls()
|
||||
if self.email_config.get('username'):
|
||||
server.login(
|
||||
self.email_config['username'],
|
||||
self.email_config['password']
|
||||
)
|
||||
server.send_message(msg)
|
||||
|
||||
return {'success': True}
|
||||
except Exception as e:
|
||||
raise PlatformError(
|
||||
f"Failed to send email notification: {str(e)}",
|
||||
{
|
||||
'event_type': event_type,
|
||||
'error': str(e)
|
||||
}
|
||||
)
|
||||
|
||||
async def _send_slack_notification(
|
||||
self,
|
||||
event_type: str,
|
||||
content: Dict[str, Any],
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Send Slack notification."""
|
||||
if not self.slack_config:
|
||||
raise PlatformError(
|
||||
"Slack configuration not found",
|
||||
{'event_type': event_type}
|
||||
)
|
||||
|
||||
try:
|
||||
message = self._format_slack_message(event_type, content, metadata)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
self.slack_config['webhook_url'],
|
||||
json=message
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
raise PlatformError(
|
||||
f"Slack API returned status {response.status}",
|
||||
{'response': await response.text()}
|
||||
)
|
||||
return {'success': True}
|
||||
except Exception as e:
|
||||
raise PlatformError(
|
||||
f"Failed to send Slack notification: {str(e)}",
|
||||
{
|
||||
'event_type': event_type,
|
||||
'error': str(e)
|
||||
}
|
||||
)
|
||||
|
||||
async def _send_webhook_notification(
|
||||
self,
|
||||
event_type: str,
|
||||
content: Dict[str, Any],
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Send webhook notification."""
|
||||
if not self.webhook_config:
|
||||
raise PlatformError(
|
||||
"Webhook configuration not found",
|
||||
{'event_type': event_type}
|
||||
)
|
||||
|
||||
try:
|
||||
payload = self._format_webhook_payload(event_type, content, metadata)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
self.webhook_config['url'],
|
||||
json=payload,
|
||||
headers=self.webhook_config.get('headers', {})
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
raise PlatformError(
|
||||
f"Webhook returned status {response.status}",
|
||||
{'response': await response.text()}
|
||||
)
|
||||
return {'success': True}
|
||||
except Exception as e:
|
||||
raise PlatformError(
|
||||
f"Failed to send webhook notification: {str(e)}",
|
||||
{
|
||||
'event_type': event_type,
|
||||
'error': str(e)
|
||||
}
|
||||
)
|
||||
|
||||
def _get_email_subject(
|
||||
self,
|
||||
event_type: str,
|
||||
content: Dict[str, Any]
|
||||
) -> str:
|
||||
"""Generate email subject based on event type."""
|
||||
subjects = {
|
||||
'ON_SUCCESS': f"Content Published Successfully: {content.get('title', 'Untitled')}",
|
||||
'ON_FAILURE': f"Content Publication Failed: {content.get('title', 'Untitled')}",
|
||||
'ON_RETRY': f"Content Publication Retry: {content.get('title', 'Untitled')}",
|
||||
'ON_CANCELLATION': f"Content Publication Cancelled: {content.get('title', 'Untitled')}"
|
||||
}
|
||||
return subjects.get(event_type, f"Content Update: {content.get('title', 'Untitled')}")
|
||||
|
||||
def _format_email_body(
|
||||
self,
|
||||
event_type: str,
|
||||
content: Dict[str, Any],
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""Format email body."""
|
||||
template = f"""
|
||||
<html>
|
||||
<body>
|
||||
<h2>Content Update Notification</h2>
|
||||
<p><strong>Event Type:</strong> {event_type}</p>
|
||||
<p><strong>Content Title:</strong> {content.get('title', 'Untitled')}</p>
|
||||
<p><strong>Platform:</strong> {content.get('platform', 'Unknown')}</p>
|
||||
<p><strong>Status:</strong> {content.get('status', 'Unknown')}</p>
|
||||
"""
|
||||
|
||||
if metadata:
|
||||
template += "<h3>Additional Details:</h3><ul>"
|
||||
for key, value in metadata.items():
|
||||
template += f"<li><strong>{key}:</strong> {value}</li>"
|
||||
template += "</ul>"
|
||||
|
||||
template += """
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return template
|
||||
|
||||
def _format_slack_message(
|
||||
self,
|
||||
event_type: str,
|
||||
content: Dict[str, Any],
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Format Slack message."""
|
||||
message = {
|
||||
"blocks": [
|
||||
{
|
||||
"type": "header",
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": self._get_email_subject(event_type, content)
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"fields": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": f"*Event Type:*\n{event_type}"
|
||||
},
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": f"*Platform:*\n{content.get('platform', 'Unknown')}"
|
||||
},
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": f"*Status:*\n{content.get('status', 'Unknown')}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
if metadata:
|
||||
fields = []
|
||||
for key, value in metadata.items():
|
||||
fields.append({
|
||||
"type": "mrkdwn",
|
||||
"text": f"*{key}:*\n{value}"
|
||||
})
|
||||
message["blocks"].append({
|
||||
"type": "section",
|
||||
"fields": fields
|
||||
})
|
||||
|
||||
return message
|
||||
|
||||
def _format_webhook_payload(
|
||||
self,
|
||||
event_type: str,
|
||||
content: Dict[str, Any],
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Format webhook payload."""
|
||||
payload = {
|
||||
'event_type': event_type,
|
||||
'content': content,
|
||||
'timestamp': datetime.now(pytz.UTC).isoformat()
|
||||
}
|
||||
|
||||
if metadata:
|
||||
payload['metadata'] = metadata
|
||||
|
||||
return payload
|
||||
381
lib/content_scheduler/utils/timeline_utils.py
Normal file
381
lib/content_scheduler/utils/timeline_utils.py
Normal file
@@ -0,0 +1,381 @@
|
||||
"""
|
||||
Timeline utilities for content scheduling.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
import pandas as pd
|
||||
import plotly.express as px
|
||||
import plotly.graph_objects as go
|
||||
from plotly.subplots import make_subplots
|
||||
|
||||
# Use unified database models
|
||||
from lib.database.models import ContentItem, Schedule, ScheduleStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class TimelineAnalyzer:
|
||||
"""Analyze and visualize content scheduling timelines."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the timeline analyzer."""
|
||||
self.logger = logger
|
||||
|
||||
def analyze_schedule_distribution(
|
||||
self,
|
||||
schedules: List[Schedule],
|
||||
time_range: str = "week"
|
||||
) -> Dict[str, Any]:
|
||||
"""Analyze the distribution of schedules over time.
|
||||
|
||||
Args:
|
||||
schedules: List of Schedule objects
|
||||
time_range: Time range for analysis ('day', 'week', 'month')
|
||||
|
||||
Returns:
|
||||
Dictionary containing analysis results
|
||||
"""
|
||||
try:
|
||||
if not schedules:
|
||||
return {
|
||||
'total_schedules': 0,
|
||||
'distribution': {},
|
||||
'peak_times': [],
|
||||
'gaps': []
|
||||
}
|
||||
|
||||
# Group schedules by time period
|
||||
distribution = {}
|
||||
for schedule in schedules:
|
||||
if time_range == "day":
|
||||
key = schedule.scheduled_time.strftime("%Y-%m-%d")
|
||||
elif time_range == "week":
|
||||
# Get week start (Monday)
|
||||
week_start = schedule.scheduled_time - timedelta(days=schedule.scheduled_time.weekday())
|
||||
key = week_start.strftime("%Y-%m-%d")
|
||||
else: # month
|
||||
key = schedule.scheduled_time.strftime("%Y-%m")
|
||||
|
||||
distribution[key] = distribution.get(key, 0) + 1
|
||||
|
||||
# Find peak times
|
||||
peak_times = sorted(distribution.items(), key=lambda x: x[1], reverse=True)[:3]
|
||||
|
||||
# Find gaps (periods with no content)
|
||||
gaps = self._find_gaps(schedules, time_range)
|
||||
|
||||
return {
|
||||
'total_schedules': len(schedules),
|
||||
'distribution': distribution,
|
||||
'peak_times': peak_times,
|
||||
'gaps': gaps
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error analyzing schedule distribution: {str(e)}")
|
||||
return {}
|
||||
|
||||
def _find_gaps(
|
||||
self,
|
||||
schedules: List[Schedule],
|
||||
time_range: str
|
||||
) -> List[str]:
|
||||
"""Find gaps in the schedule timeline.
|
||||
|
||||
Args:
|
||||
schedules: List of Schedule objects
|
||||
time_range: Time range for analysis
|
||||
|
||||
Returns:
|
||||
List of time periods with no scheduled content
|
||||
"""
|
||||
try:
|
||||
if not schedules:
|
||||
return []
|
||||
|
||||
# Get date range
|
||||
dates = [s.scheduled_time.date() for s in schedules]
|
||||
start_date = min(dates)
|
||||
end_date = max(dates)
|
||||
|
||||
# Generate all periods in range
|
||||
current_date = start_date
|
||||
all_periods = set()
|
||||
|
||||
while current_date <= end_date:
|
||||
if time_range == "day":
|
||||
period = current_date.strftime("%Y-%m-%d")
|
||||
current_date += timedelta(days=1)
|
||||
elif time_range == "week":
|
||||
# Get week start (Monday)
|
||||
week_start = current_date - timedelta(days=current_date.weekday())
|
||||
period = week_start.strftime("%Y-%m-%d")
|
||||
current_date += timedelta(weeks=1)
|
||||
else: # month
|
||||
period = current_date.strftime("%Y-%m")
|
||||
# Move to next month
|
||||
if current_date.month == 12:
|
||||
current_date = current_date.replace(year=current_date.year + 1, month=1)
|
||||
else:
|
||||
current_date = current_date.replace(month=current_date.month + 1)
|
||||
|
||||
all_periods.add(period)
|
||||
|
||||
# Find periods with schedules
|
||||
scheduled_periods = set()
|
||||
for schedule in schedules:
|
||||
if time_range == "day":
|
||||
period = schedule.scheduled_time.strftime("%Y-%m-%d")
|
||||
elif time_range == "week":
|
||||
week_start = schedule.scheduled_time - timedelta(days=schedule.scheduled_time.weekday())
|
||||
period = week_start.strftime("%Y-%m-%d")
|
||||
else: # month
|
||||
period = schedule.scheduled_time.strftime("%Y-%m")
|
||||
|
||||
scheduled_periods.add(period)
|
||||
|
||||
# Return gaps
|
||||
gaps = list(all_periods - scheduled_periods)
|
||||
return sorted(gaps)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error finding gaps: {str(e)}")
|
||||
return []
|
||||
|
||||
def create_timeline_chart(
|
||||
self,
|
||||
schedules: List[Schedule],
|
||||
chart_type: str = "gantt"
|
||||
) -> go.Figure:
|
||||
"""Create a timeline visualization chart.
|
||||
|
||||
Args:
|
||||
schedules: List of Schedule objects
|
||||
chart_type: Type of chart ('gantt', 'scatter', 'bar')
|
||||
|
||||
Returns:
|
||||
Plotly figure object
|
||||
"""
|
||||
try:
|
||||
if not schedules:
|
||||
fig = go.Figure()
|
||||
fig.add_annotation(
|
||||
text="No schedules to display",
|
||||
xref="paper", yref="paper",
|
||||
x=0.5, y=0.5,
|
||||
showarrow=False
|
||||
)
|
||||
return fig
|
||||
|
||||
if chart_type == "gantt":
|
||||
return self._create_gantt_chart(schedules)
|
||||
elif chart_type == "scatter":
|
||||
return self._create_scatter_chart(schedules)
|
||||
else: # bar
|
||||
return self._create_bar_chart(schedules)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating timeline chart: {str(e)}")
|
||||
fig = go.Figure()
|
||||
fig.add_annotation(
|
||||
text=f"Error creating chart: {str(e)}",
|
||||
xref="paper", yref="paper",
|
||||
x=0.5, y=0.5,
|
||||
showarrow=False
|
||||
)
|
||||
return fig
|
||||
|
||||
def _create_gantt_chart(self, schedules: List[Schedule]) -> go.Figure:
|
||||
"""Create a Gantt chart for schedules."""
|
||||
try:
|
||||
# Prepare data for Gantt chart
|
||||
data = []
|
||||
for i, schedule in enumerate(schedules):
|
||||
# Estimate duration (default 1 hour)
|
||||
start_time = schedule.scheduled_time
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
|
||||
data.append({
|
||||
'Task': f"Schedule {schedule.id}",
|
||||
'Start': start_time,
|
||||
'Finish': end_time,
|
||||
'Status': schedule.status.value
|
||||
})
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
|
||||
# Create Gantt chart
|
||||
fig = px.timeline(
|
||||
df,
|
||||
x_start="Start",
|
||||
x_end="Finish",
|
||||
y="Task",
|
||||
color="Status",
|
||||
title="Content Schedule Timeline"
|
||||
)
|
||||
|
||||
fig.update_layout(
|
||||
xaxis_title="Time",
|
||||
yaxis_title="Schedules",
|
||||
height=max(400, len(schedules) * 30)
|
||||
)
|
||||
|
||||
return fig
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating Gantt chart: {str(e)}")
|
||||
return go.Figure()
|
||||
|
||||
def _create_scatter_chart(self, schedules: List[Schedule]) -> go.Figure:
|
||||
"""Create a scatter plot for schedules."""
|
||||
try:
|
||||
# Prepare data
|
||||
dates = [s.scheduled_time for s in schedules]
|
||||
statuses = [s.status.value for s in schedules]
|
||||
ids = [s.id for s in schedules]
|
||||
|
||||
# Create scatter plot
|
||||
fig = px.scatter(
|
||||
x=dates,
|
||||
y=statuses,
|
||||
title="Schedule Status Over Time",
|
||||
labels={'x': 'Scheduled Time', 'y': 'Status'},
|
||||
hover_data={'Schedule ID': ids}
|
||||
)
|
||||
|
||||
fig.update_layout(
|
||||
xaxis_title="Scheduled Time",
|
||||
yaxis_title="Status"
|
||||
)
|
||||
|
||||
return fig
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating scatter chart: {str(e)}")
|
||||
return go.Figure()
|
||||
|
||||
def _create_bar_chart(self, schedules: List[Schedule]) -> go.Figure:
|
||||
"""Create a bar chart for schedule distribution."""
|
||||
try:
|
||||
# Group by date
|
||||
date_counts = {}
|
||||
for schedule in schedules:
|
||||
date_key = schedule.scheduled_time.strftime("%Y-%m-%d")
|
||||
date_counts[date_key] = date_counts.get(date_key, 0) + 1
|
||||
|
||||
# Create bar chart
|
||||
fig = px.bar(
|
||||
x=list(date_counts.keys()),
|
||||
y=list(date_counts.values()),
|
||||
title="Scheduled Content by Date",
|
||||
labels={'x': 'Date', 'y': 'Number of Schedules'}
|
||||
)
|
||||
|
||||
fig.update_layout(
|
||||
xaxis_title="Date",
|
||||
yaxis_title="Number of Schedules"
|
||||
)
|
||||
|
||||
return fig
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error creating bar chart: {str(e)}")
|
||||
return go.Figure()
|
||||
|
||||
def get_schedule_conflicts(
|
||||
self,
|
||||
schedules: List[Schedule],
|
||||
time_window: int = 60 # minutes
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Identify potential scheduling conflicts.
|
||||
|
||||
Args:
|
||||
schedules: List of Schedule objects
|
||||
time_window: Time window in minutes to check for conflicts
|
||||
|
||||
Returns:
|
||||
List of conflict information
|
||||
"""
|
||||
try:
|
||||
conflicts = []
|
||||
|
||||
# Sort schedules by time
|
||||
sorted_schedules = sorted(schedules, key=lambda x: x.scheduled_time)
|
||||
|
||||
for i in range(len(sorted_schedules) - 1):
|
||||
current = sorted_schedules[i]
|
||||
next_schedule = sorted_schedules[i + 1]
|
||||
|
||||
# Check if schedules are too close
|
||||
time_diff = (next_schedule.scheduled_time - current.scheduled_time).total_seconds() / 60
|
||||
|
||||
if time_diff < time_window:
|
||||
conflicts.append({
|
||||
'schedule_1': current.id,
|
||||
'schedule_2': next_schedule.id,
|
||||
'time_1': current.scheduled_time,
|
||||
'time_2': next_schedule.scheduled_time,
|
||||
'gap_minutes': time_diff,
|
||||
'severity': 'high' if time_diff < 30 else 'medium'
|
||||
})
|
||||
|
||||
return conflicts
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error finding conflicts: {str(e)}")
|
||||
return []
|
||||
|
||||
def suggest_optimal_times(
|
||||
self,
|
||||
existing_schedules: List[Schedule],
|
||||
target_date: datetime,
|
||||
duration_hours: int = 1
|
||||
) -> List[datetime]:
|
||||
"""Suggest optimal times for new content based on existing schedules.
|
||||
|
||||
Args:
|
||||
existing_schedules: List of existing Schedule objects
|
||||
target_date: Target date for new content
|
||||
duration_hours: Expected duration of content in hours
|
||||
|
||||
Returns:
|
||||
List of suggested optimal times
|
||||
"""
|
||||
try:
|
||||
suggestions = []
|
||||
|
||||
# Get schedules for target date
|
||||
target_schedules = [
|
||||
s for s in existing_schedules
|
||||
if s.scheduled_time.date() == target_date.date()
|
||||
]
|
||||
|
||||
# Define business hours (9 AM to 6 PM)
|
||||
business_start = target_date.replace(hour=9, minute=0, second=0, microsecond=0)
|
||||
business_end = target_date.replace(hour=18, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Generate potential time slots (every 30 minutes)
|
||||
current_time = business_start
|
||||
while current_time < business_end:
|
||||
# Check if this slot conflicts with existing schedules
|
||||
conflict = False
|
||||
for schedule in target_schedules:
|
||||
schedule_end = schedule.scheduled_time + timedelta(hours=duration_hours)
|
||||
slot_end = current_time + timedelta(hours=duration_hours)
|
||||
|
||||
# Check for overlap
|
||||
if (current_time < schedule_end and slot_end > schedule.scheduled_time):
|
||||
conflict = True
|
||||
break
|
||||
|
||||
if not conflict:
|
||||
suggestions.append(current_time)
|
||||
|
||||
current_time += timedelta(minutes=30)
|
||||
|
||||
return suggestions[:5] # Return top 5 suggestions
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error suggesting optimal times: {str(e)}")
|
||||
return []
|
||||
162
lib/content_scheduler/utils/validation.py
Normal file
162
lib/content_scheduler/utils/validation.py
Normal file
@@ -0,0 +1,162 @@
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
import pytz
|
||||
from .error_handling import ScheduleValidationError
|
||||
|
||||
def validate_schedule_data(schedule_data: Dict[str, Any]) -> None:
|
||||
"""Validate schedule data before creation."""
|
||||
required_fields = ['content_id', 'schedule_type', 'platforms', 'publish_date']
|
||||
missing_fields = [field for field in required_fields if field not in schedule_data]
|
||||
|
||||
if missing_fields:
|
||||
raise ScheduleValidationError(
|
||||
f"Missing required fields: {', '.join(missing_fields)}",
|
||||
{'missing_fields': missing_fields}
|
||||
)
|
||||
|
||||
validate_schedule_type(schedule_data['schedule_type'])
|
||||
validate_platforms(schedule_data['platforms'])
|
||||
validate_publish_date(schedule_data['publish_date'])
|
||||
|
||||
if 'recurrence' in schedule_data:
|
||||
validate_recurrence(schedule_data['recurrence'])
|
||||
|
||||
def validate_schedule_type(schedule_type: str) -> None:
|
||||
"""Validate schedule type."""
|
||||
valid_types = ['ONE_TIME', 'RECURRING', 'BATCH']
|
||||
if schedule_type not in valid_types:
|
||||
raise ScheduleValidationError(
|
||||
f"Invalid schedule type: {schedule_type}",
|
||||
{'valid_types': valid_types}
|
||||
)
|
||||
|
||||
def validate_platforms(platforms: List[str]) -> None:
|
||||
"""Validate platform list."""
|
||||
valid_platforms = ['TWITTER', 'FACEBOOK', 'LINKEDIN', 'INSTAGRAM']
|
||||
invalid_platforms = [p for p in platforms if p not in valid_platforms]
|
||||
|
||||
if invalid_platforms:
|
||||
raise ScheduleValidationError(
|
||||
f"Invalid platforms: {', '.join(invalid_platforms)}",
|
||||
{'valid_platforms': valid_platforms}
|
||||
)
|
||||
|
||||
if not platforms:
|
||||
raise ScheduleValidationError(
|
||||
"At least one platform must be specified",
|
||||
{'valid_platforms': valid_platforms}
|
||||
)
|
||||
|
||||
def validate_publish_date(publish_date: datetime) -> None:
|
||||
"""Validate publish date."""
|
||||
if not isinstance(publish_date, datetime):
|
||||
raise ScheduleValidationError(
|
||||
"Publish date must be a datetime object",
|
||||
{'type': type(publish_date).__name__}
|
||||
)
|
||||
|
||||
if publish_date.tzinfo is None:
|
||||
raise ScheduleValidationError(
|
||||
"Publish date must be timezone-aware",
|
||||
{'date': str(publish_date)}
|
||||
)
|
||||
|
||||
if publish_date < datetime.now(pytz.UTC):
|
||||
raise ScheduleValidationError(
|
||||
"Publish date must be in the future",
|
||||
{'date': str(publish_date)}
|
||||
)
|
||||
|
||||
def validate_recurrence(recurrence: Dict[str, Any]) -> None:
|
||||
"""Validate recurrence settings."""
|
||||
required_fields = ['frequency', 'interval']
|
||||
missing_fields = [field for field in required_fields if field not in recurrence]
|
||||
|
||||
if missing_fields:
|
||||
raise ScheduleValidationError(
|
||||
f"Missing required recurrence fields: {', '.join(missing_fields)}",
|
||||
{'missing_fields': missing_fields}
|
||||
)
|
||||
|
||||
valid_frequencies = ['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY']
|
||||
if recurrence['frequency'] not in valid_frequencies:
|
||||
raise ScheduleValidationError(
|
||||
f"Invalid recurrence frequency: {recurrence['frequency']}",
|
||||
{'valid_frequencies': valid_frequencies}
|
||||
)
|
||||
|
||||
if not isinstance(recurrence['interval'], int) or recurrence['interval'] < 1:
|
||||
raise ScheduleValidationError(
|
||||
"Recurrence interval must be a positive integer",
|
||||
{'interval': recurrence['interval']}
|
||||
)
|
||||
|
||||
if 'end_date' in recurrence:
|
||||
if not isinstance(recurrence['end_date'], datetime):
|
||||
raise ScheduleValidationError(
|
||||
"End date must be a datetime object",
|
||||
{'type': type(recurrence['end_date']).__name__}
|
||||
)
|
||||
|
||||
if recurrence['end_date'].tzinfo is None:
|
||||
raise ScheduleValidationError(
|
||||
"End date must be timezone-aware",
|
||||
{'date': str(recurrence['end_date'])}
|
||||
)
|
||||
|
||||
def validate_job_data(job_data: Dict[str, Any]) -> None:
|
||||
"""Validate job data before creation."""
|
||||
required_fields = ['content_id', 'schedule_id', 'platform']
|
||||
missing_fields = [field for field in required_fields if field not in job_data]
|
||||
|
||||
if missing_fields:
|
||||
raise ScheduleValidationError(
|
||||
f"Missing required job fields: {', '.join(missing_fields)}",
|
||||
{'missing_fields': missing_fields}
|
||||
)
|
||||
|
||||
validate_platforms([job_data['platform']])
|
||||
|
||||
def validate_retry_settings(retry_settings: Optional[Dict[str, Any]]) -> None:
|
||||
"""Validate retry settings."""
|
||||
if retry_settings is None:
|
||||
return
|
||||
|
||||
if 'max_retries' in retry_settings:
|
||||
if not isinstance(retry_settings['max_retries'], int) or retry_settings['max_retries'] < 0:
|
||||
raise ScheduleValidationError(
|
||||
"Max retries must be a non-negative integer",
|
||||
{'max_retries': retry_settings['max_retries']}
|
||||
)
|
||||
|
||||
if 'retry_delay' in retry_settings:
|
||||
if not isinstance(retry_settings['retry_delay'], (int, float)) or retry_settings['retry_delay'] < 0:
|
||||
raise ScheduleValidationError(
|
||||
"Retry delay must be a non-negative number",
|
||||
{'retry_delay': retry_settings['retry_delay']}
|
||||
)
|
||||
|
||||
def validate_notification_settings(notification_settings: Optional[Dict[str, Any]]) -> None:
|
||||
"""Validate notification settings."""
|
||||
if notification_settings is None:
|
||||
return
|
||||
|
||||
if 'channels' in notification_settings:
|
||||
valid_channels = ['EMAIL', 'SLACK', 'WEBHOOK']
|
||||
invalid_channels = [c for c in notification_settings['channels'] if c not in valid_channels]
|
||||
|
||||
if invalid_channels:
|
||||
raise ScheduleValidationError(
|
||||
f"Invalid notification channels: {', '.join(invalid_channels)}",
|
||||
{'valid_channels': valid_channels}
|
||||
)
|
||||
|
||||
if 'events' in notification_settings:
|
||||
valid_events = ['ON_SUCCESS', 'ON_FAILURE', 'ON_RETRY', 'ON_CANCELLATION']
|
||||
invalid_events = [e for e in notification_settings['events'] if e not in valid_events]
|
||||
|
||||
if invalid_events:
|
||||
raise ScheduleValidationError(
|
||||
f"Invalid notification events: {', '.join(invalid_events)}",
|
||||
{'valid_events': valid_events}
|
||||
)
|
||||
Reference in New Issue
Block a user