alwrity chatbot assistant, content scheduler, and content repurposing
This commit is contained in:
804
lib/content_scheduler/README.md
Normal file
804
lib/content_scheduler/README.md
Normal file
@@ -0,0 +1,804 @@
|
||||
# Alwrity Content Scheduler
|
||||
|
||||
A robust, reusable content scheduling system for Alwrity that integrates with existing features and provides comprehensive scheduling capabilities.
|
||||
|
||||
## Overview
|
||||
|
||||
The Content Scheduler is a standalone module that provides advanced scheduling capabilities for content publishing across multiple platforms. It uses APScheduler for reliable task scheduling and includes features for monitoring, error handling, and integration with existing Alwrity features.
|
||||
|
||||
## Features
|
||||
|
||||
### Core Scheduling Features
|
||||
- [x] One-time content scheduling
|
||||
- [x] Recurring content scheduling (cron-based)
|
||||
- [x] Platform-specific scheduling
|
||||
- [x] Batch scheduling
|
||||
- [x] Schedule optimization based on platform analytics
|
||||
- [x] Timezone support
|
||||
- [x] Schedule conflict detection and resolution
|
||||
|
||||
### Monitoring & Management
|
||||
- [x] Real-time schedule monitoring
|
||||
- [x] Job status tracking (pending, running, completed, failed)
|
||||
- [x] Failed job handling and retry mechanisms
|
||||
- [x] Schedule health checks
|
||||
- [x] Performance metrics and analytics
|
||||
- [x] Schedule audit logs
|
||||
|
||||
### Integration Features
|
||||
- [x] Seamless integration with Content Calendar
|
||||
- Bidirectional sync with existing content calendar
|
||||
- Real-time event synchronization
|
||||
- Schedule-to-event conversion
|
||||
- Calendar event management
|
||||
- [x] Platform adapter system for different publishing platforms
|
||||
- [ ] Webhook support for external integrations
|
||||
- [ ] API endpoints for external access
|
||||
- [ ] Event system for custom integrations
|
||||
|
||||
### Safety & Reliability
|
||||
- [x] Persistent job storage
|
||||
- [x] Automatic job recovery on system restart
|
||||
- [x] Missed schedule detection and handling
|
||||
- [x] Schedule validation and verification
|
||||
- [x] Error handling and notification system
|
||||
- [ ] Backup and restore capabilities
|
||||
|
||||
### User Interface
|
||||
- [x] Interactive scheduling dashboard
|
||||
- [x] Schedule visualization (calendar, timeline, list views)
|
||||
- [x] Schedule management interface
|
||||
- [x] Performance analytics dashboard
|
||||
- [x] Schedule health monitoring
|
||||
- [x] Alert and notification center
|
||||
|
||||
### Dashboard Capabilities
|
||||
|
||||
#### Overview Dashboard
|
||||
- Real-time metrics display:
|
||||
- Active schedules count
|
||||
- Pending jobs count
|
||||
- Completed jobs today
|
||||
- Success rate percentage
|
||||
- Upcoming schedules table with:
|
||||
- Schedule title and content preview
|
||||
- Platform information
|
||||
- Scheduled time
|
||||
- Current status
|
||||
- Quick action buttons for common tasks
|
||||
|
||||
#### Schedule Management
|
||||
|
||||
- Create new schedules with:
|
||||
- One-time or recurring options
|
||||
- Multiple platform selection
|
||||
- Content type specification
|
||||
- Priority settings
|
||||
- Advanced scheduling options
|
||||
|
||||
- Manage existing schedules:
|
||||
- Edit schedule details
|
||||
- Delete schedules
|
||||
- Pause/Resume schedules
|
||||
- Clone schedules
|
||||
|
||||
- Schedule visualization:
|
||||
- Calendar view with color-coded status
|
||||
- Timeline view for chronological display
|
||||
- List view with sorting and filtering
|
||||
- Drag-and-drop rescheduling
|
||||
|
||||
|
||||
#### Job Monitor
|
||||
|
||||
- Real-time job status tracking:
|
||||
- Pending jobs
|
||||
- Running jobs
|
||||
- Completed jobs
|
||||
- Failed jobs
|
||||
|
||||
- Advanced filtering:
|
||||
- By status
|
||||
- By platform
|
||||
- By date range
|
||||
- By content type
|
||||
|
||||
- Job timeline visualization:
|
||||
- Interactive timeline chart
|
||||
- Job execution history
|
||||
- Status changes tracking
|
||||
|
||||
- Detailed job information:
|
||||
- Execution time
|
||||
- Platform responses
|
||||
- Error messages
|
||||
- Retry attempts
|
||||
|
||||
#### Analytics Dashboard
|
||||
|
||||
- Performance metrics:
|
||||
- Success rate trends
|
||||
- Average execution time
|
||||
- Error rate analysis
|
||||
- Platform-specific metrics
|
||||
|
||||
- Content distribution:
|
||||
- Platform-wise distribution
|
||||
- Content type distribution
|
||||
- Time-based distribution
|
||||
|
||||
- Schedule optimization insights:
|
||||
- Best posting times
|
||||
- Platform performance comparison
|
||||
- Content type effectiveness
|
||||
|
||||
- Custom reports:
|
||||
- Exportable metrics
|
||||
- Custom date ranges
|
||||
- Platform-specific reports
|
||||
- Performance comparisons
|
||||
|
||||
#### Health Monitoring
|
||||
|
||||
- System health indicators:
|
||||
- Scheduler status
|
||||
- Database connection
|
||||
- Platform connectivity
|
||||
- Resource usage
|
||||
|
||||
- Alert system:
|
||||
- Failed job notifications
|
||||
- Schedule conflicts
|
||||
- System warnings
|
||||
- Performance alerts
|
||||
|
||||
- Health check history:
|
||||
- Status changes
|
||||
- Error logs
|
||||
- Resolution tracking
|
||||
- Maintenance records
|
||||
|
||||
|
||||
#### User Experience Features
|
||||
|
||||
- Responsive design for all devices
|
||||
- Dark/Light theme support
|
||||
- Customizable dashboard layouts
|
||||
- Keyboard shortcuts
|
||||
- Bulk operations support
|
||||
- Export/Import functionality
|
||||
- Multi-language support
|
||||
- Accessibility features
|
||||
|
||||
## Module Structure
|
||||
|
||||
```
|
||||
lib/content_scheduler/
|
||||
├── README.md
|
||||
├── requirements.txt
|
||||
├── core/
|
||||
│ ├── __init__.py
|
||||
│ ├── scheduler.py # Main scheduler implementation
|
||||
│ ├── job_manager.py # Job management and persistence
|
||||
│ ├── schedule_validator.py # Schedule validation and verification
|
||||
│ ├── health_checker.py # Schedule health monitoring
|
||||
│ ├── conflict_resolver.py # Schedule conflict detection and resolution
|
||||
│ └── schedule_optimizer.py # Schedule optimization engine
|
||||
├── models/
|
||||
│ ├── __init__.py
|
||||
│ ├── schedule.py # Schedule data models
|
||||
│ ├── job.py # Job data models
|
||||
│ └── platform.py # Platform-specific models
|
||||
├── integrations/
|
||||
│ ├── __init__.py
|
||||
│ ├── platform_adapters/ # Platform-specific adapters
|
||||
│ ├── calendar_integration.py # Content calendar integration
|
||||
│ └── webhook_handler.py
|
||||
├── ui/
|
||||
│ ├── __init__.py
|
||||
│ ├── dashboard.py # Main scheduling dashboard
|
||||
│ ├── components/ # UI components
|
||||
│ └── views/ # Different view implementations
|
||||
├── utils/
|
||||
│ ├── __init__.py
|
||||
│ ├── date_utils.py
|
||||
│ ├── error_handling.py
|
||||
│ └── logging.py
|
||||
└── tests/
|
||||
├── __init__.py
|
||||
├── test_scheduler.py
|
||||
└── test_integrations.py
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Core Scheduler ✅
|
||||
- [x] Basic scheduler implementation with APScheduler
|
||||
- [x] Job persistence and recovery
|
||||
- [x] Basic error handling
|
||||
- [x] Simple scheduling interface
|
||||
|
||||
### Phase 2: Integration & Platform Support ✅
|
||||
- [x] Platform adapter system
|
||||
- [x] Content Calendar integration
|
||||
- [x] Basic monitoring system
|
||||
- [x] Schedule validation
|
||||
|
||||
### Phase 3: Advanced Features ✅
|
||||
- [x] Schedule optimization
|
||||
- [x] Advanced error handling
|
||||
- [x] Performance metrics
|
||||
- [x] Health monitoring
|
||||
|
||||
### Phase 4: UI & Dashboard ✅
|
||||
- [x] Interactive dashboard
|
||||
- [x] Schedule visualization
|
||||
- [x] Analytics dashboard
|
||||
- [x] Alert system
|
||||
|
||||
## Technical Requirements
|
||||
|
||||
### Dependencies
|
||||
- APScheduler >= 3.9.1
|
||||
- SQLAlchemy (for job persistence)
|
||||
- FastAPI (for API endpoints)
|
||||
- Streamlit >= 1.24.0 (for dashboard)
|
||||
- Pandas >= 1.5.0 (for data handling)
|
||||
- Plotly >= 5.13.0 (for visualizations)
|
||||
- Redis (optional, for distributed scheduling)
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Content Calendar
|
||||
- [x] Direct integration with existing calendar system
|
||||
- [x] Bidirectional sync of schedules
|
||||
- [x] Shared data models
|
||||
- [x] Real-time event synchronization
|
||||
- [x] Calendar event management
|
||||
|
||||
### Platform Adapters
|
||||
- [x] Twitter
|
||||
- [ ] Facebook
|
||||
- [ ] LinkedIn
|
||||
- [ ] Instagram
|
||||
- [ ] WordPress
|
||||
- [ ] Custom platform support
|
||||
|
||||
### External Systems
|
||||
- [ ] Webhook support
|
||||
- [ ] REST API
|
||||
- [ ] Event system
|
||||
- [ ] Notification system
|
||||
|
||||
## Monitoring & Maintenance
|
||||
|
||||
### Health Checks
|
||||
- [x] Schedule validation
|
||||
- [x] Job execution monitoring
|
||||
- [x] System resource monitoring
|
||||
- [x] Integration health checks
|
||||
|
||||
### Maintenance Tasks
|
||||
- [ ] Log rotation
|
||||
- [ ] Database cleanup
|
||||
- [ ] Performance optimization
|
||||
- [ ] Security updates
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- [ ] API authentication
|
||||
- [ ] Job execution security
|
||||
- [ ] Data encryption
|
||||
- [ ] Access control
|
||||
- [ ] Audit logging
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Short-term (Next Release)
|
||||
- [ ] Webhook support for external integrations
|
||||
- [ ] REST API endpoints
|
||||
- [ ] Additional platform adapters
|
||||
- [ ] Backup and restore capabilities
|
||||
|
||||
### Medium-term
|
||||
- [ ] AI-powered schedule optimization
|
||||
- Smart posting time recommendations
|
||||
- Platform-specific optimal posting times
|
||||
- Audience engagement pattern analysis
|
||||
- Content type-specific timing optimization
|
||||
- Content performance prediction
|
||||
- Engagement rate forecasting
|
||||
- Reach and visibility predictions
|
||||
- Viral potential assessment
|
||||
- Automated schedule adjustments
|
||||
- Dynamic rescheduling based on performance
|
||||
- A/B testing of posting times
|
||||
- Real-time optimization based on engagement
|
||||
- Audience behavior analysis
|
||||
- Timezone-based audience activity patterns
|
||||
- Content consumption patterns
|
||||
- Engagement trend analysis
|
||||
- Content type optimization
|
||||
- Best content type for specific times
|
||||
- Platform-specific content recommendations
|
||||
- Content mix optimization
|
||||
- [ ] Advanced analytics with ML insights
|
||||
- Predictive analytics for content performance
|
||||
- Audience growth forecasting
|
||||
- Engagement trend analysis
|
||||
- ROI prediction for scheduled content
|
||||
- [ ] Multi-account support
|
||||
- [ ] Custom scheduling algorithms
|
||||
|
||||
### Long-term
|
||||
- [ ] Distributed scheduling support
|
||||
- [ ] Advanced reporting system
|
||||
- [ ] Machine learning for optimal posting times
|
||||
- Deep learning models for engagement prediction
|
||||
- Reinforcement learning for schedule optimization
|
||||
- Natural language processing for content analysis
|
||||
- Computer vision for visual content optimization
|
||||
- [ ] Integration with external analytics tools
|
||||
- [ ] AI-powered content recommendations
|
||||
- Content type suggestions based on performance
|
||||
- Topic and format recommendations
|
||||
- Platform-specific content optimization
|
||||
- Audience interest prediction
|
||||
- [ ] Smart content repurposing
|
||||
- Automated content adaptation for different platforms
|
||||
- Format optimization based on platform performance
|
||||
- Content refresh recommendations
|
||||
- Cross-platform content strategy optimization
|
||||
- [ ] Automated A/B testing framework
|
||||
- Schedule timing experiments
|
||||
- Content format testing
|
||||
- Platform-specific optimization
|
||||
- Audience segment testing
|
||||
- [ ] Intelligent resource allocation
|
||||
- Automated workload distribution
|
||||
- Resource optimization based on content priority
|
||||
- Smart queue management
|
||||
- Performance-based resource allocation
|
||||
|
||||
### AI-Enhanced User Experience
|
||||
- [ ] Smart scheduling assistant
|
||||
- Natural language schedule creation
|
||||
- Context-aware scheduling suggestions
|
||||
- Automated conflict resolution
|
||||
- Intelligent schedule adjustments
|
||||
- [ ] Predictive maintenance
|
||||
- System health forecasting
|
||||
- Proactive issue detection
|
||||
- Automated recovery suggestions
|
||||
- Performance optimization recommendations
|
||||
- [ ] Personalized dashboard
|
||||
- AI-curated insights
|
||||
- Custom metric recommendations
|
||||
- Automated report generation
|
||||
- Smart alert configuration
|
||||
- [ ] Intelligent automation
|
||||
- Smart schedule templates
|
||||
- Automated content categorization
|
||||
- Platform-specific optimization rules
|
||||
- Dynamic workflow automation
|
||||
- [ ] Advanced analytics visualization
|
||||
- Interactive AI-powered insights
|
||||
- Real-time performance predictions
|
||||
- Trend analysis and forecasting
|
||||
- Custom visualization recommendations
|
||||
|
||||
## Suggested Improvements & Enhancements
|
||||
|
||||
### Performance Optimizations
|
||||
- [ ] Implement caching layer for frequently accessed data
|
||||
- Schedule metadata caching
|
||||
- Platform analytics caching
|
||||
- Calendar event caching
|
||||
- [ ] Optimize database queries
|
||||
- Add database indexes for common queries
|
||||
- Implement query result caching
|
||||
- Optimize join operations
|
||||
- [ ] Enhance job processing
|
||||
- Implement job batching for similar tasks
|
||||
- Add parallel processing for independent jobs
|
||||
- Optimize resource allocation
|
||||
|
||||
### Reliability Enhancements
|
||||
- [ ] Implement advanced error recovery
|
||||
- Automatic retry with exponential backoff
|
||||
- Circuit breaker pattern for external services
|
||||
- Graceful degradation during failures
|
||||
- [ ] Add comprehensive monitoring
|
||||
- Real-time performance metrics
|
||||
- Resource usage tracking
|
||||
- Error rate monitoring
|
||||
- [ ] Enhance data consistency
|
||||
- Implement distributed transactions
|
||||
- Add data validation layers
|
||||
- Implement optimistic locking
|
||||
|
||||
### User Experience Improvements
|
||||
|
||||
#### Enhanced Dashboard Features
|
||||
- [ ] Smart Dashboard Layout
|
||||
- Drag-and-drop widget arrangement
|
||||
- Customizable dashboard themes
|
||||
- Responsive grid layout
|
||||
- Collapsible sections
|
||||
- Quick action toolbar
|
||||
- Keyboard shortcuts support
|
||||
|
||||
- [ ] Advanced Content Management
|
||||
- Bulk content scheduling
|
||||
- Content templates library
|
||||
- Content preview with platform simulation
|
||||
- Content performance predictions
|
||||
- Content recycling suggestions
|
||||
- Content calendar sync status
|
||||
|
||||
- [ ] Intelligent Schedule Management
|
||||
- Smart schedule suggestions
|
||||
- Conflict-free scheduling
|
||||
- Schedule templates
|
||||
- Recurring schedule patterns
|
||||
- Schedule optimization recommendations
|
||||
- Schedule health indicators
|
||||
|
||||
- [ ] Platform-Specific Features
|
||||
- Platform-specific scheduling rules
|
||||
- Platform analytics integration
|
||||
- Platform-specific content guidelines
|
||||
- Platform performance metrics
|
||||
- Platform-specific templates
|
||||
- Platform health status
|
||||
|
||||
#### Improved Visualization
|
||||
|
||||
##### Interactive Calendar Views
|
||||
- [ ] Multi-view Calendar System
|
||||
- Day View
|
||||
- Hour-by-hour schedule display
|
||||
- Time slot availability indicators
|
||||
- Schedule conflict highlighting
|
||||
- Quick schedule creation
|
||||
- Drag-and-drop rescheduling
|
||||
- Schedule details on hover
|
||||
- Week View
|
||||
- 7-day calendar layout
|
||||
- Daily schedule summaries
|
||||
- Cross-day schedule visualization
|
||||
- Week-over-week comparison
|
||||
- Schedule density indicators
|
||||
- Quick navigation controls
|
||||
- Month View
|
||||
- Full month calendar display
|
||||
- Schedule count indicators
|
||||
- Color-coded schedule types
|
||||
- Month navigation
|
||||
- Schedule preview on hover
|
||||
- Bulk schedule management
|
||||
- Year View
|
||||
- Annual schedule overview
|
||||
- Quarter-by-quarter breakdown
|
||||
- Schedule distribution heatmap
|
||||
- Year-over-year comparison
|
||||
- Major milestone markers
|
||||
- Schedule trend visualization
|
||||
|
||||
- [ ] Advanced Calendar Features
|
||||
- Schedule Conflict Management
|
||||
- Real-time conflict detection
|
||||
- Visual conflict indicators
|
||||
- Conflict resolution suggestions
|
||||
- Automatic conflict avoidance
|
||||
- Conflict history tracking
|
||||
- Resolution workflow
|
||||
- Calendar Overlay System
|
||||
- Multiple calendar layers
|
||||
- Platform-specific overlays
|
||||
- Team schedule overlays
|
||||
- Content type overlays
|
||||
- Custom overlay creation
|
||||
- Overlay visibility controls
|
||||
- Interactive Controls
|
||||
- Zoom and pan functionality
|
||||
- Quick date navigation
|
||||
- Schedule filtering
|
||||
- View customization
|
||||
- Export options
|
||||
- Print layouts
|
||||
|
||||
##### Advanced Analytics Visualization
|
||||
- [ ] Real-time Performance Charts
|
||||
- Engagement Metrics
|
||||
- Likes, shares, comments tracking
|
||||
- Engagement rate trends
|
||||
- Audience growth charts
|
||||
- Platform-specific metrics
|
||||
- Custom metric tracking
|
||||
- Real-time updates
|
||||
- Content Performance
|
||||
- Content type effectiveness
|
||||
- Best performing content
|
||||
- Performance predictions
|
||||
- A/B test results
|
||||
- ROI visualization
|
||||
- Trend analysis
|
||||
- Platform Analytics
|
||||
- Platform comparison charts
|
||||
- Platform-specific metrics
|
||||
- Cross-platform analysis
|
||||
- Platform health indicators
|
||||
- Performance benchmarks
|
||||
- Growth tracking
|
||||
|
||||
- [ ] Custom Chart Builder
|
||||
- Chart Types
|
||||
- Line charts for trends
|
||||
- Bar charts for comparisons
|
||||
- Pie charts for distribution
|
||||
- Scatter plots for correlation
|
||||
- Heat maps for patterns
|
||||
- Custom chart types
|
||||
- Data Configuration
|
||||
- Metric selection
|
||||
- Time range control
|
||||
- Data filtering
|
||||
- Aggregation options
|
||||
- Custom calculations
|
||||
- Data export
|
||||
- Visualization Options
|
||||
- Color schemes
|
||||
- Chart layouts
|
||||
- Annotation tools
|
||||
- Interactive elements
|
||||
- Export formats
|
||||
- Sharing options
|
||||
|
||||
##### Schedule Timeline Views
|
||||
- [ ] Interactive Gantt Charts
|
||||
- Schedule Visualization
|
||||
- Task dependencies
|
||||
- Progress tracking
|
||||
- Milestone markers
|
||||
- Resource allocation
|
||||
- Timeline scaling
|
||||
- Critical path highlighting
|
||||
- Dependency Management
|
||||
- Dependency creation
|
||||
- Dependency visualization
|
||||
- Conflict detection
|
||||
- Resolution suggestions
|
||||
- Impact analysis
|
||||
- Dependency history
|
||||
- Timeline Controls
|
||||
- Zoom levels
|
||||
- Pan navigation
|
||||
- Filter options
|
||||
- Group by options
|
||||
- Export capabilities
|
||||
- Print layouts
|
||||
|
||||
- [ ] Progress Tracking
|
||||
- Visual Indicators
|
||||
- Progress bars
|
||||
- Status icons
|
||||
- Completion percentages
|
||||
- Delay indicators
|
||||
- Risk markers
|
||||
- Health status
|
||||
- Milestone Tracking
|
||||
- Milestone creation
|
||||
- Due date tracking
|
||||
- Completion status
|
||||
- Dependency impact
|
||||
- Notification triggers
|
||||
- History tracking
|
||||
|
||||
##### Content Performance Dashboards
|
||||
- [ ] Performance Scorecards
|
||||
- Key Metrics
|
||||
- Engagement rates
|
||||
- Reach metrics
|
||||
- Conversion rates
|
||||
- ROI calculations
|
||||
- Growth indicators
|
||||
- Platform performance
|
||||
- Custom Metrics
|
||||
- Metric creation
|
||||
- Formula builder
|
||||
- Threshold setting
|
||||
- Alert configuration
|
||||
- Trend analysis
|
||||
- Benchmark comparison
|
||||
|
||||
- [ ] ROI Visualization
|
||||
- Financial Metrics
|
||||
- Cost tracking
|
||||
- Revenue attribution
|
||||
- ROI calculations
|
||||
- Budget allocation
|
||||
- Cost efficiency
|
||||
- Profitability analysis
|
||||
- Performance Metrics
|
||||
- Engagement value
|
||||
- Conversion value
|
||||
- Customer lifetime value
|
||||
- Platform value
|
||||
- Content value
|
||||
- Campaign value
|
||||
|
||||
- [ ] Audience Insights
|
||||
- Demographics
|
||||
- Age distribution
|
||||
- Gender breakdown
|
||||
- Location data
|
||||
- Device usage
|
||||
- Platform preference
|
||||
- Engagement patterns
|
||||
- Behavior Analysis
|
||||
- Content preferences
|
||||
- Time patterns
|
||||
- Platform usage
|
||||
- Engagement trends
|
||||
- Conversion paths
|
||||
- Retention metrics
|
||||
|
||||
#### Better Notification System
|
||||
- [ ] Smart Notification Center
|
||||
- Centralized notification hub
|
||||
- Notification categories
|
||||
- Priority-based sorting
|
||||
- Read/unread status
|
||||
- Notification history
|
||||
- Bulk notification actions
|
||||
|
||||
- [ ] Customizable Alert Rules
|
||||
- Schedule status alerts
|
||||
- Performance threshold alerts
|
||||
- Platform-specific alerts
|
||||
- Content engagement alerts
|
||||
- System health alerts
|
||||
- Custom alert conditions
|
||||
|
||||
- [ ] Multi-channel Notifications
|
||||
- Email notifications
|
||||
- In-app notifications
|
||||
- Mobile push notifications
|
||||
- SMS alerts
|
||||
- Slack/Teams integration
|
||||
- Webhook notifications
|
||||
|
||||
- [ ] Intelligent Notification Management
|
||||
- Smart notification grouping
|
||||
- Notification frequency control
|
||||
- Quiet hours setting
|
||||
- Do not disturb mode
|
||||
- Notification preferences
|
||||
- Notification templates
|
||||
|
||||
- [ ] Action-oriented Notifications
|
||||
- One-click actions
|
||||
- Quick response options
|
||||
- Context-aware suggestions
|
||||
- Batch action support
|
||||
- Follow-up reminders
|
||||
- Escalation paths
|
||||
|
||||
- [ ] Notification Analytics
|
||||
- Notification engagement tracking
|
||||
- Response time metrics
|
||||
- Alert effectiveness analysis
|
||||
- User preference insights
|
||||
- Notification optimization
|
||||
- Usage patterns
|
||||
|
||||
### Integration Enhancements
|
||||
- [ ] Extended platform support
|
||||
- Additional social media platforms
|
||||
- Blog platforms integration
|
||||
- Email marketing platforms
|
||||
- Custom platform adapters
|
||||
- [ ] Enhanced API capabilities
|
||||
- GraphQL API support
|
||||
- Webhook event system
|
||||
- API rate limiting
|
||||
- API versioning
|
||||
- [ ] Advanced calendar features
|
||||
- Multiple calendar support
|
||||
- Calendar conflict resolution
|
||||
- Calendar sharing and collaboration
|
||||
- Calendar analytics
|
||||
|
||||
### Security Improvements
|
||||
- [ ] Enhanced authentication
|
||||
- OAuth 2.0 support
|
||||
- Multi-factor authentication
|
||||
- Role-based access control
|
||||
- API key management
|
||||
- [ ] Data protection
|
||||
- End-to-end encryption
|
||||
- Data masking
|
||||
- Audit logging
|
||||
- Compliance features
|
||||
- [ ] Security monitoring
|
||||
- Real-time security alerts
|
||||
- Access pattern analysis
|
||||
- Security audit reports
|
||||
- Vulnerability scanning
|
||||
|
||||
### Scalability Enhancements
|
||||
- [ ] Distributed architecture
|
||||
- Horizontal scaling support
|
||||
- Load balancing
|
||||
- Service discovery
|
||||
- Distributed caching
|
||||
- [ ] High availability
|
||||
- Multi-region deployment
|
||||
- Automatic failover
|
||||
- Data replication
|
||||
- Disaster recovery
|
||||
- [ ] Resource optimization
|
||||
- Dynamic resource allocation
|
||||
- Auto-scaling support
|
||||
- Resource usage optimization
|
||||
- Cost optimization
|
||||
|
||||
### Analytics & Insights
|
||||
- [ ] Advanced analytics
|
||||
- Predictive analytics
|
||||
- Trend analysis
|
||||
- Performance forecasting
|
||||
- ROI tracking
|
||||
- [ ] Custom reporting
|
||||
- Report builder
|
||||
- Custom metrics
|
||||
- Export capabilities
|
||||
- Scheduled reports
|
||||
- [ ] Business intelligence
|
||||
- KPI tracking
|
||||
- Goal setting
|
||||
- Performance benchmarking
|
||||
- Competitive analysis
|
||||
|
||||
### Development & Maintenance
|
||||
- [ ] Code quality improvements
|
||||
- Enhanced test coverage
|
||||
- Code documentation
|
||||
- Performance profiling
|
||||
- Code analysis tools
|
||||
- [ ] Development workflow
|
||||
- CI/CD pipeline
|
||||
- Automated testing
|
||||
- Code review process
|
||||
- Release management
|
||||
- [ ] Maintenance tools
|
||||
- Automated backups
|
||||
- Database maintenance
|
||||
- System health checks
|
||||
- Performance monitoring
|
||||
|
||||
### Future-Proofing
|
||||
- [ ] Technology updates
|
||||
- Framework upgrades
|
||||
- Dependency updates
|
||||
- Security patches
|
||||
- Performance optimizations
|
||||
- [ ] Feature extensibility
|
||||
- Plugin system
|
||||
- Custom integrations
|
||||
- Extension points
|
||||
- API evolution
|
||||
- [ ] Innovation opportunities
|
||||
- AI/ML integration
|
||||
- Blockchain integration
|
||||
- IoT integration
|
||||
- Emerging technologies
|
||||
|
||||
## Contributing
|
||||
|
||||
Please read CONTRIBUTING.md for details on our code of conduct and the process for submitting pull requests.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
403
lib/content_scheduler/core/conflict_resolver.py
Normal file
403
lib/content_scheduler/core/conflict_resolver.py
Normal file
@@ -0,0 +1,403 @@
|
||||
"""
|
||||
Conflict resolution system for content scheduling.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
|
||||
# Use unified database models
|
||||
from lib.database.models import ContentItem, Schedule, ScheduleStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class ConflictInfo:
|
||||
"""Information about a scheduling conflict."""
|
||||
schedule_1: Schedule
|
||||
schedule_2: Schedule
|
||||
conflict_type: str
|
||||
severity: str
|
||||
description: str
|
||||
suggested_resolution: str
|
||||
|
||||
class ConflictResolver:
|
||||
"""Resolve scheduling conflicts automatically."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the conflict resolver."""
|
||||
self.logger = logger
|
||||
self.resolution_strategies = {
|
||||
'time_overlap': self._resolve_time_overlap,
|
||||
'platform_conflict': self._resolve_platform_conflict,
|
||||
'resource_conflict': self._resolve_resource_conflict,
|
||||
'priority_conflict': self._resolve_priority_conflict
|
||||
}
|
||||
|
||||
def detect_conflicts(self, schedules: List[Schedule]) -> List[ConflictInfo]:
|
||||
"""Detect conflicts between schedules.
|
||||
|
||||
Args:
|
||||
schedules: List of Schedule objects to check
|
||||
|
||||
Returns:
|
||||
List of detected conflicts
|
||||
"""
|
||||
try:
|
||||
conflicts = []
|
||||
|
||||
# Sort schedules by time
|
||||
sorted_schedules = sorted(schedules, key=lambda x: x.scheduled_time)
|
||||
|
||||
for i in range(len(sorted_schedules)):
|
||||
for j in range(i + 1, len(sorted_schedules)):
|
||||
schedule_1 = sorted_schedules[i]
|
||||
schedule_2 = sorted_schedules[j]
|
||||
|
||||
# Check for time overlap conflicts
|
||||
time_conflicts = self._check_time_overlap(schedule_1, schedule_2)
|
||||
conflicts.extend(time_conflicts)
|
||||
|
||||
# Check for platform conflicts
|
||||
platform_conflicts = self._check_platform_conflict(schedule_1, schedule_2)
|
||||
conflicts.extend(platform_conflicts)
|
||||
|
||||
# Check for priority conflicts
|
||||
priority_conflicts = self._check_priority_conflict(schedule_1, schedule_2)
|
||||
conflicts.extend(priority_conflicts)
|
||||
|
||||
return conflicts
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error detecting conflicts: {str(e)}")
|
||||
return []
|
||||
|
||||
def _check_time_overlap(self, schedule_1: Schedule, schedule_2: Schedule) -> List[ConflictInfo]:
|
||||
"""Check for time overlap conflicts."""
|
||||
conflicts = []
|
||||
|
||||
try:
|
||||
# Assume each schedule takes 1 hour (can be made configurable)
|
||||
duration = timedelta(hours=1)
|
||||
|
||||
end_1 = schedule_1.scheduled_time + duration
|
||||
end_2 = schedule_2.scheduled_time + duration
|
||||
|
||||
# Check for overlap
|
||||
if (schedule_1.scheduled_time < end_2 and end_1 > schedule_2.scheduled_time):
|
||||
time_diff = abs((schedule_2.scheduled_time - schedule_1.scheduled_time).total_seconds() / 60)
|
||||
|
||||
severity = 'high' if time_diff < 30 else 'medium'
|
||||
|
||||
conflicts.append(ConflictInfo(
|
||||
schedule_1=schedule_1,
|
||||
schedule_2=schedule_2,
|
||||
conflict_type='time_overlap',
|
||||
severity=severity,
|
||||
description=f"Schedules overlap by {60 - time_diff:.0f} minutes",
|
||||
suggested_resolution=f"Move one schedule by at least {60 - time_diff + 15:.0f} minutes"
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error checking time overlap: {str(e)}")
|
||||
|
||||
return conflicts
|
||||
|
||||
def _check_platform_conflict(self, schedule_1: Schedule, schedule_2: Schedule) -> List[ConflictInfo]:
|
||||
"""Check for platform conflicts."""
|
||||
conflicts = []
|
||||
|
||||
try:
|
||||
# This is a placeholder - platform conflicts would depend on specific platform limitations
|
||||
# For now, we'll check if schedules are too close on the same platform
|
||||
|
||||
time_diff = abs((schedule_2.scheduled_time - schedule_1.scheduled_time).total_seconds() / 60)
|
||||
|
||||
# If schedules are within 15 minutes, it might be a platform conflict
|
||||
if time_diff < 15:
|
||||
conflicts.append(ConflictInfo(
|
||||
schedule_1=schedule_1,
|
||||
schedule_2=schedule_2,
|
||||
conflict_type='platform_conflict',
|
||||
severity='medium',
|
||||
description=f"Schedules too close for optimal platform performance",
|
||||
suggested_resolution="Space schedules at least 15 minutes apart"
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error checking platform conflict: {str(e)}")
|
||||
|
||||
return conflicts
|
||||
|
||||
def _check_priority_conflict(self, schedule_1: Schedule, schedule_2: Schedule) -> List[ConflictInfo]:
|
||||
"""Check for priority conflicts."""
|
||||
conflicts = []
|
||||
|
||||
try:
|
||||
# Check if high priority items are scheduled too close to low priority items
|
||||
if schedule_1.priority > 7 and schedule_2.priority < 4:
|
||||
time_diff = abs((schedule_2.scheduled_time - schedule_1.scheduled_time).total_seconds() / 60)
|
||||
|
||||
if time_diff < 60: # Within 1 hour
|
||||
conflicts.append(ConflictInfo(
|
||||
schedule_1=schedule_1,
|
||||
schedule_2=schedule_2,
|
||||
conflict_type='priority_conflict',
|
||||
severity='low',
|
||||
description="High priority content scheduled close to low priority content",
|
||||
suggested_resolution="Consider spacing high and low priority content further apart"
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error checking priority conflict: {str(e)}")
|
||||
|
||||
return conflicts
|
||||
|
||||
def resolve_conflicts(self, conflicts: List[ConflictInfo]) -> Dict[str, Any]:
|
||||
"""Resolve detected conflicts automatically.
|
||||
|
||||
Args:
|
||||
conflicts: List of conflicts to resolve
|
||||
|
||||
Returns:
|
||||
Dictionary containing resolution results
|
||||
"""
|
||||
try:
|
||||
resolved_conflicts = []
|
||||
unresolved_conflicts = []
|
||||
schedule_adjustments = {}
|
||||
|
||||
for conflict in conflicts:
|
||||
try:
|
||||
# Get resolution strategy
|
||||
strategy = self.resolution_strategies.get(conflict.conflict_type)
|
||||
|
||||
if strategy:
|
||||
resolution = strategy(conflict)
|
||||
|
||||
if resolution['success']:
|
||||
resolved_conflicts.append({
|
||||
'conflict': conflict,
|
||||
'resolution': resolution
|
||||
})
|
||||
|
||||
# Track schedule adjustments
|
||||
for schedule_id, adjustments in resolution.get('adjustments', {}).items():
|
||||
if schedule_id not in schedule_adjustments:
|
||||
schedule_adjustments[schedule_id] = {}
|
||||
schedule_adjustments[schedule_id].update(adjustments)
|
||||
else:
|
||||
unresolved_conflicts.append(conflict)
|
||||
else:
|
||||
unresolved_conflicts.append(conflict)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error resolving conflict: {str(e)}")
|
||||
unresolved_conflicts.append(conflict)
|
||||
|
||||
return {
|
||||
'resolved_conflicts': resolved_conflicts,
|
||||
'unresolved_conflicts': unresolved_conflicts,
|
||||
'schedule_adjustments': schedule_adjustments,
|
||||
'success_rate': len(resolved_conflicts) / len(conflicts) if conflicts else 1.0
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error resolving conflicts: {str(e)}")
|
||||
return {
|
||||
'resolved_conflicts': [],
|
||||
'unresolved_conflicts': conflicts,
|
||||
'schedule_adjustments': {},
|
||||
'success_rate': 0.0
|
||||
}
|
||||
|
||||
def _resolve_time_overlap(self, conflict: ConflictInfo) -> Dict[str, Any]:
|
||||
"""Resolve time overlap conflicts."""
|
||||
try:
|
||||
# Strategy: Move the lower priority schedule
|
||||
schedule_1 = conflict.schedule_1
|
||||
schedule_2 = conflict.schedule_2
|
||||
|
||||
# Determine which schedule to move
|
||||
if schedule_1.priority >= schedule_2.priority:
|
||||
schedule_to_move = schedule_2
|
||||
anchor_schedule = schedule_1
|
||||
else:
|
||||
schedule_to_move = schedule_1
|
||||
anchor_schedule = schedule_2
|
||||
|
||||
# Calculate new time (move 1.5 hours after anchor)
|
||||
new_time = anchor_schedule.scheduled_time + timedelta(hours=1.5)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'strategy': 'move_lower_priority',
|
||||
'adjustments': {
|
||||
str(schedule_to_move.id): {
|
||||
'new_scheduled_time': new_time,
|
||||
'reason': 'Resolved time overlap conflict'
|
||||
}
|
||||
},
|
||||
'description': f"Moved schedule {schedule_to_move.id} to {new_time}"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error resolving time overlap: {str(e)}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def _resolve_platform_conflict(self, conflict: ConflictInfo) -> Dict[str, Any]:
|
||||
"""Resolve platform conflicts."""
|
||||
try:
|
||||
# Strategy: Space schedules 20 minutes apart
|
||||
schedule_1 = conflict.schedule_1
|
||||
schedule_2 = conflict.schedule_2
|
||||
|
||||
# Move the later schedule
|
||||
if schedule_1.scheduled_time < schedule_2.scheduled_time:
|
||||
schedule_to_move = schedule_2
|
||||
anchor_time = schedule_1.scheduled_time
|
||||
else:
|
||||
schedule_to_move = schedule_1
|
||||
anchor_time = schedule_2.scheduled_time
|
||||
|
||||
new_time = anchor_time + timedelta(minutes=20)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'strategy': 'space_schedules',
|
||||
'adjustments': {
|
||||
str(schedule_to_move.id): {
|
||||
'new_scheduled_time': new_time,
|
||||
'reason': 'Resolved platform conflict'
|
||||
}
|
||||
},
|
||||
'description': f"Spaced schedule {schedule_to_move.id} to {new_time}"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error resolving platform conflict: {str(e)}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def _resolve_resource_conflict(self, conflict: ConflictInfo) -> Dict[str, Any]:
|
||||
"""Resolve resource conflicts."""
|
||||
try:
|
||||
# This is a placeholder for resource conflict resolution
|
||||
return {
|
||||
'success': False,
|
||||
'reason': 'Resource conflict resolution not implemented'
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error resolving resource conflict: {str(e)}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def _resolve_priority_conflict(self, conflict: ConflictInfo) -> Dict[str, Any]:
|
||||
"""Resolve priority conflicts."""
|
||||
try:
|
||||
# Strategy: Move low priority content away from high priority content
|
||||
schedule_1 = conflict.schedule_1
|
||||
schedule_2 = conflict.schedule_2
|
||||
|
||||
# Identify high and low priority schedules
|
||||
if schedule_1.priority > schedule_2.priority:
|
||||
high_priority = schedule_1
|
||||
low_priority = schedule_2
|
||||
else:
|
||||
high_priority = schedule_2
|
||||
low_priority = schedule_1
|
||||
|
||||
# Move low priority content 2 hours away
|
||||
new_time = high_priority.scheduled_time + timedelta(hours=2)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'strategy': 'separate_priorities',
|
||||
'adjustments': {
|
||||
str(low_priority.id): {
|
||||
'new_scheduled_time': new_time,
|
||||
'reason': 'Resolved priority conflict'
|
||||
}
|
||||
},
|
||||
'description': f"Moved low priority schedule {low_priority.id} to {new_time}"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error resolving priority conflict: {str(e)}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def suggest_optimal_schedule(
|
||||
self,
|
||||
new_schedule: Schedule,
|
||||
existing_schedules: List[Schedule]
|
||||
) -> Dict[str, Any]:
|
||||
"""Suggest optimal scheduling for new content.
|
||||
|
||||
Args:
|
||||
new_schedule: New schedule to optimize
|
||||
existing_schedules: List of existing schedules
|
||||
|
||||
Returns:
|
||||
Dictionary containing optimization suggestions
|
||||
"""
|
||||
try:
|
||||
suggestions = []
|
||||
|
||||
# Check for conflicts with proposed time
|
||||
all_schedules = existing_schedules + [new_schedule]
|
||||
conflicts = self.detect_conflicts(all_schedules)
|
||||
|
||||
if not conflicts:
|
||||
return {
|
||||
'optimal_time': new_schedule.scheduled_time,
|
||||
'conflicts': [],
|
||||
'suggestions': ['Current time is optimal']
|
||||
}
|
||||
|
||||
# Generate alternative times
|
||||
base_time = new_schedule.scheduled_time
|
||||
alternative_times = []
|
||||
|
||||
# Try different time slots
|
||||
for hours_offset in [1, 2, 3, -1, -2, -3]:
|
||||
alt_time = base_time + timedelta(hours=hours_offset)
|
||||
alt_schedule = Schedule(
|
||||
content_item_id=new_schedule.content_item_id,
|
||||
scheduled_time=alt_time,
|
||||
status=new_schedule.status,
|
||||
recurrence=new_schedule.recurrence,
|
||||
priority=new_schedule.priority
|
||||
)
|
||||
|
||||
# Check conflicts for this alternative
|
||||
alt_conflicts = self.detect_conflicts(existing_schedules + [alt_schedule])
|
||||
|
||||
alternative_times.append({
|
||||
'time': alt_time,
|
||||
'conflicts': len(alt_conflicts),
|
||||
'severity': max([c.severity for c in alt_conflicts], default='none')
|
||||
})
|
||||
|
||||
# Sort by number of conflicts and severity
|
||||
alternative_times.sort(key=lambda x: (x['conflicts'], x['severity']))
|
||||
|
||||
optimal_time = alternative_times[0]['time'] if alternative_times else new_schedule.scheduled_time
|
||||
|
||||
return {
|
||||
'optimal_time': optimal_time,
|
||||
'conflicts': conflicts,
|
||||
'alternatives': alternative_times[:3], # Top 3 alternatives
|
||||
'suggestions': [
|
||||
f"Consider scheduling at {optimal_time}",
|
||||
f"Current time has {len(conflicts)} conflicts",
|
||||
"Review alternative times for better optimization"
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error suggesting optimal schedule: {str(e)}")
|
||||
return {
|
||||
'optimal_time': new_schedule.scheduled_time,
|
||||
'conflicts': [],
|
||||
'suggestions': ['Error occurred during optimization']
|
||||
}
|
||||
584
lib/content_scheduler/core/health_checker.py
Normal file
584
lib/content_scheduler/core/health_checker.py
Normal file
@@ -0,0 +1,584 @@
|
||||
"""
|
||||
Schedule health monitoring system.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
from ..utils.error_handling import SchedulingError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
class HealthStatus(Enum):
|
||||
"""Health check status."""
|
||||
HEALTHY = "healthy"
|
||||
WARNING = "warning"
|
||||
CRITICAL = "critical"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
@dataclass
|
||||
class HealthCheck:
|
||||
"""Health check result."""
|
||||
component: str
|
||||
status: HealthStatus
|
||||
message: str
|
||||
details: Dict[str, Any]
|
||||
timestamp: datetime
|
||||
|
||||
class ScheduleHealthChecker:
|
||||
"""Schedule health monitoring system."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scheduler,
|
||||
check_interval: int = 300, # 5 minutes
|
||||
warning_threshold: int = 3,
|
||||
critical_threshold: int = 5
|
||||
):
|
||||
"""Initialize the health checker.
|
||||
|
||||
Args:
|
||||
scheduler: ContentScheduler instance
|
||||
check_interval: Health check interval in seconds
|
||||
warning_threshold: Number of failures before warning
|
||||
critical_threshold: Number of failures before critical
|
||||
"""
|
||||
self.logger = logger
|
||||
self.scheduler = scheduler
|
||||
self.check_interval = check_interval
|
||||
self.warning_threshold = warning_threshold
|
||||
self.critical_threshold = critical_threshold
|
||||
|
||||
# Initialize health check history
|
||||
self.health_history = []
|
||||
|
||||
# Initialize failure counters
|
||||
self.failure_counts = {
|
||||
'job_execution': 0,
|
||||
'platform_publish': 0,
|
||||
'schedule_conflicts': 0,
|
||||
'resource_usage': 0
|
||||
}
|
||||
|
||||
# Initialize monitoring task
|
||||
self.monitoring_task = None
|
||||
|
||||
async def start_monitoring(self):
|
||||
"""Start the health monitoring system."""
|
||||
try:
|
||||
if not self.monitoring_task:
|
||||
self.monitoring_task = asyncio.create_task(self._monitor_health())
|
||||
self.logger.info("Health monitoring started")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to start health monitoring: {str(e)}")
|
||||
raise SchedulingError(f"Health monitoring start failed: {str(e)}")
|
||||
|
||||
async def stop_monitoring(self):
|
||||
"""Stop the health monitoring system."""
|
||||
try:
|
||||
if self.monitoring_task:
|
||||
self.monitoring_task.cancel()
|
||||
self.monitoring_task = None
|
||||
self.logger.info("Health monitoring stopped")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to stop health monitoring: {str(e)}")
|
||||
raise SchedulingError(f"Health monitoring stop failed: {str(e)}")
|
||||
|
||||
async def _monitor_health(self):
|
||||
"""Monitor system health periodically."""
|
||||
while True:
|
||||
try:
|
||||
# Perform health checks
|
||||
health_checks = await self._perform_health_checks()
|
||||
|
||||
# Update health history
|
||||
self.health_history.extend(health_checks)
|
||||
|
||||
# Trim history if too long
|
||||
if len(self.health_history) > 1000:
|
||||
self.health_history = self.health_history[-1000:]
|
||||
|
||||
# Check for critical issues
|
||||
critical_checks = [
|
||||
check for check in health_checks
|
||||
if check.status == HealthStatus.CRITICAL
|
||||
]
|
||||
|
||||
if critical_checks:
|
||||
await self._handle_critical_issues(critical_checks)
|
||||
|
||||
# Wait for next check
|
||||
await asyncio.sleep(self.check_interval)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
self.logger.error(f"Health monitoring error: {str(e)}")
|
||||
await asyncio.sleep(self.check_interval)
|
||||
|
||||
async def _perform_health_checks(self) -> List[HealthCheck]:
|
||||
"""Perform all health checks.
|
||||
|
||||
Returns:
|
||||
List of health check results
|
||||
"""
|
||||
checks = []
|
||||
|
||||
try:
|
||||
# Check scheduler status
|
||||
checks.append(await self._check_scheduler_status())
|
||||
|
||||
# Check job execution
|
||||
checks.append(await self._check_job_execution())
|
||||
|
||||
# Check platform connectivity
|
||||
checks.append(await self._check_platform_connectivity())
|
||||
|
||||
# Check resource usage
|
||||
checks.append(await self._check_resource_usage())
|
||||
|
||||
# Check schedule conflicts
|
||||
checks.append(await self._check_schedule_conflicts())
|
||||
|
||||
# Check database connection
|
||||
checks.append(await self._check_database_connection())
|
||||
|
||||
# Check job store
|
||||
checks.append(await self._check_job_store())
|
||||
|
||||
return checks
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Health check failed: {str(e)}")
|
||||
return [
|
||||
HealthCheck(
|
||||
component="health_checker",
|
||||
status=HealthStatus.CRITICAL,
|
||||
message=f"Health check system error: {str(e)}",
|
||||
details={'error': str(e)},
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
]
|
||||
|
||||
async def _check_scheduler_status(self) -> HealthCheck:
|
||||
"""Check scheduler status.
|
||||
|
||||
Returns:
|
||||
Health check result
|
||||
"""
|
||||
try:
|
||||
is_running = self.scheduler.scheduler.running
|
||||
job_count = len(self.scheduler.scheduler.get_jobs())
|
||||
|
||||
if not is_running:
|
||||
return HealthCheck(
|
||||
component="scheduler",
|
||||
status=HealthStatus.CRITICAL,
|
||||
message="Scheduler is not running",
|
||||
details={'job_count': job_count},
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
|
||||
return HealthCheck(
|
||||
component="scheduler",
|
||||
status=HealthStatus.HEALTHY,
|
||||
message="Scheduler is running",
|
||||
details={'job_count': job_count},
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return HealthCheck(
|
||||
component="scheduler",
|
||||
status=HealthStatus.CRITICAL,
|
||||
message=f"Scheduler check failed: {str(e)}",
|
||||
details={'error': str(e)},
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
|
||||
async def _check_job_execution(self) -> HealthCheck:
|
||||
"""Check job execution health.
|
||||
|
||||
Returns:
|
||||
Health check result
|
||||
"""
|
||||
try:
|
||||
# Get recent job history
|
||||
recent_jobs = [
|
||||
job for job in self.scheduler.job_status.values()
|
||||
if datetime.utcnow() - job['created_at'] < timedelta(hours=24)
|
||||
]
|
||||
|
||||
# Calculate failure rate
|
||||
total_jobs = len(recent_jobs)
|
||||
failed_jobs = len([
|
||||
job for job in recent_jobs
|
||||
if job['status'] == 'FAILED'
|
||||
])
|
||||
|
||||
failure_rate = failed_jobs / total_jobs if total_jobs > 0 else 0
|
||||
|
||||
# Update failure counter
|
||||
self.failure_counts['job_execution'] = failed_jobs
|
||||
|
||||
if failure_rate >= 0.2: # 20% failure rate
|
||||
return HealthCheck(
|
||||
component="job_execution",
|
||||
status=HealthStatus.CRITICAL,
|
||||
message="High job failure rate detected",
|
||||
details={
|
||||
'total_jobs': total_jobs,
|
||||
'failed_jobs': failed_jobs,
|
||||
'failure_rate': failure_rate
|
||||
},
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
elif failure_rate >= 0.1: # 10% failure rate
|
||||
return HealthCheck(
|
||||
component="job_execution",
|
||||
status=HealthStatus.WARNING,
|
||||
message="Elevated job failure rate",
|
||||
details={
|
||||
'total_jobs': total_jobs,
|
||||
'failed_jobs': failed_jobs,
|
||||
'failure_rate': failure_rate
|
||||
},
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
|
||||
return HealthCheck(
|
||||
component="job_execution",
|
||||
status=HealthStatus.HEALTHY,
|
||||
message="Job execution is healthy",
|
||||
details={
|
||||
'total_jobs': total_jobs,
|
||||
'failed_jobs': failed_jobs,
|
||||
'failure_rate': failure_rate
|
||||
},
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return HealthCheck(
|
||||
component="job_execution",
|
||||
status=HealthStatus.CRITICAL,
|
||||
message=f"Job execution check failed: {str(e)}",
|
||||
details={'error': str(e)},
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
|
||||
async def _check_platform_connectivity(self) -> HealthCheck:
|
||||
"""Check platform connectivity.
|
||||
|
||||
Returns:
|
||||
Health check result
|
||||
"""
|
||||
try:
|
||||
# Get unique platforms from recent jobs
|
||||
platforms = set()
|
||||
for job in self.scheduler.job_status.values():
|
||||
if 'schedule' in job:
|
||||
platforms.update(job['schedule'].platforms)
|
||||
|
||||
# Check each platform
|
||||
platform_status = {}
|
||||
for platform in platforms:
|
||||
try:
|
||||
adapter = self.scheduler._get_platform_adapter(platform)
|
||||
# Try to get platform status
|
||||
status = await adapter.get_platform_status()
|
||||
platform_status[platform] = status['status']
|
||||
except Exception as e:
|
||||
platform_status[platform] = 'error'
|
||||
self.failure_counts['platform_publish'] += 1
|
||||
|
||||
# Check overall status
|
||||
if any(status == 'error' for status in platform_status.values()):
|
||||
return HealthCheck(
|
||||
component="platform_connectivity",
|
||||
status=HealthStatus.CRITICAL,
|
||||
message="Platform connectivity issues detected",
|
||||
details={'platform_status': platform_status},
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
|
||||
return HealthCheck(
|
||||
component="platform_connectivity",
|
||||
status=HealthStatus.HEALTHY,
|
||||
message="Platform connectivity is healthy",
|
||||
details={'platform_status': platform_status},
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return HealthCheck(
|
||||
component="platform_connectivity",
|
||||
status=HealthStatus.CRITICAL,
|
||||
message=f"Platform connectivity check failed: {str(e)}",
|
||||
details={'error': str(e)},
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
|
||||
async def _check_resource_usage(self) -> HealthCheck:
|
||||
"""Check system resource usage.
|
||||
|
||||
Returns:
|
||||
Health check result
|
||||
"""
|
||||
try:
|
||||
import psutil
|
||||
|
||||
# Get system metrics
|
||||
cpu_percent = psutil.cpu_percent()
|
||||
memory_percent = psutil.virtual_memory().percent
|
||||
disk_percent = psutil.disk_usage('/').percent
|
||||
|
||||
# Check thresholds
|
||||
if cpu_percent > 90 or memory_percent > 90 or disk_percent > 90:
|
||||
self.failure_counts['resource_usage'] += 1
|
||||
return HealthCheck(
|
||||
component="resource_usage",
|
||||
status=HealthStatus.CRITICAL,
|
||||
message="High resource usage detected",
|
||||
details={
|
||||
'cpu_percent': cpu_percent,
|
||||
'memory_percent': memory_percent,
|
||||
'disk_percent': disk_percent
|
||||
},
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
elif cpu_percent > 70 or memory_percent > 70 or disk_percent > 70:
|
||||
return HealthCheck(
|
||||
component="resource_usage",
|
||||
status=HealthStatus.WARNING,
|
||||
message="Elevated resource usage",
|
||||
details={
|
||||
'cpu_percent': cpu_percent,
|
||||
'memory_percent': memory_percent,
|
||||
'disk_percent': disk_percent
|
||||
},
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
|
||||
return HealthCheck(
|
||||
component="resource_usage",
|
||||
status=HealthStatus.HEALTHY,
|
||||
message="Resource usage is healthy",
|
||||
details={
|
||||
'cpu_percent': cpu_percent,
|
||||
'memory_percent': memory_percent,
|
||||
'disk_percent': disk_percent
|
||||
},
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return HealthCheck(
|
||||
component="resource_usage",
|
||||
status=HealthStatus.CRITICAL,
|
||||
message=f"Resource usage check failed: {str(e)}",
|
||||
details={'error': str(e)},
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
|
||||
async def _check_schedule_conflicts(self) -> HealthCheck:
|
||||
"""Check for schedule conflicts.
|
||||
|
||||
Returns:
|
||||
Health check result
|
||||
"""
|
||||
try:
|
||||
# Get all pending schedules
|
||||
pending_schedules = [
|
||||
job['schedule'] for job in self.scheduler.job_status.values()
|
||||
if job['status'] == 'PENDING'
|
||||
]
|
||||
|
||||
# Check for conflicts
|
||||
conflicts = await self.scheduler.conflict_resolver.detect_conflicts(
|
||||
pending_schedules
|
||||
)
|
||||
|
||||
if conflicts:
|
||||
self.failure_counts['schedule_conflicts'] += len(conflicts)
|
||||
return HealthCheck(
|
||||
component="schedule_conflicts",
|
||||
status=HealthStatus.WARNING,
|
||||
message="Schedule conflicts detected",
|
||||
details={
|
||||
'conflict_count': len(conflicts),
|
||||
'conflicts': [c.dict() for c in conflicts]
|
||||
},
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
|
||||
return HealthCheck(
|
||||
component="schedule_conflicts",
|
||||
status=HealthStatus.HEALTHY,
|
||||
message="No schedule conflicts detected",
|
||||
details={'conflict_count': 0},
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return HealthCheck(
|
||||
component="schedule_conflicts",
|
||||
status=HealthStatus.CRITICAL,
|
||||
message=f"Schedule conflict check failed: {str(e)}",
|
||||
details={'error': str(e)},
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
|
||||
async def _check_database_connection(self) -> HealthCheck:
|
||||
"""Check database connection health.
|
||||
|
||||
Returns:
|
||||
Health check result
|
||||
"""
|
||||
try:
|
||||
session = self.scheduler.Session()
|
||||
session.execute("SELECT 1")
|
||||
session.close()
|
||||
|
||||
return HealthCheck(
|
||||
component="database",
|
||||
status=HealthStatus.HEALTHY,
|
||||
message="Database connection is healthy",
|
||||
details={},
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return HealthCheck(
|
||||
component="database",
|
||||
status=HealthStatus.CRITICAL,
|
||||
message=f"Database connection failed: {str(e)}",
|
||||
details={'error': str(e)},
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
|
||||
async def _check_job_store(self) -> HealthCheck:
|
||||
"""Check job store health.
|
||||
|
||||
Returns:
|
||||
Health check result
|
||||
"""
|
||||
try:
|
||||
# Get job store statistics
|
||||
job_count = len(self.scheduler.scheduler.get_jobs())
|
||||
store_size = len(self.scheduler.job_status)
|
||||
|
||||
if job_count != store_size:
|
||||
return HealthCheck(
|
||||
component="job_store",
|
||||
status=HealthStatus.WARNING,
|
||||
message="Job store inconsistency detected",
|
||||
details={
|
||||
'job_count': job_count,
|
||||
'store_size': store_size
|
||||
},
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
|
||||
return HealthCheck(
|
||||
component="job_store",
|
||||
status=HealthStatus.HEALTHY,
|
||||
message="Job store is healthy",
|
||||
details={
|
||||
'job_count': job_count,
|
||||
'store_size': store_size
|
||||
},
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return HealthCheck(
|
||||
component="job_store",
|
||||
status=HealthStatus.CRITICAL,
|
||||
message=f"Job store check failed: {str(e)}",
|
||||
details={'error': str(e)},
|
||||
timestamp=datetime.utcnow()
|
||||
)
|
||||
|
||||
async def _handle_critical_issues(self, critical_checks: List[HealthCheck]):
|
||||
"""Handle critical health issues.
|
||||
|
||||
Args:
|
||||
critical_checks: List of critical health checks
|
||||
"""
|
||||
try:
|
||||
# Log critical issues
|
||||
for check in critical_checks:
|
||||
self.logger.error(
|
||||
f"Critical health issue in {check.component}: {check.message}"
|
||||
)
|
||||
|
||||
# Attempt recovery actions
|
||||
for check in critical_checks:
|
||||
if check.component == "scheduler" and not self.scheduler.scheduler.running:
|
||||
await self.scheduler.start()
|
||||
|
||||
elif check.component == "database":
|
||||
# Attempt to reconnect
|
||||
self.scheduler.engine.dispose()
|
||||
self.scheduler.engine = create_engine(self.scheduler.db_url)
|
||||
self.scheduler.Session = sessionmaker(bind=self.scheduler.engine)
|
||||
|
||||
elif check.component == "job_store":
|
||||
# Attempt to recover job store
|
||||
await self.scheduler._recover_jobs()
|
||||
|
||||
# Reset failure counters if recovery successful
|
||||
self.failure_counts = {k: 0 for k in self.failure_counts}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to handle critical issues: {str(e)}")
|
||||
|
||||
def get_health_summary(self) -> Dict[str, Any]:
|
||||
"""Get health check summary.
|
||||
|
||||
Returns:
|
||||
Dictionary containing health summary
|
||||
"""
|
||||
try:
|
||||
# Get latest health checks
|
||||
latest_checks = {
|
||||
check.component: check
|
||||
for check in self.health_history[-len(self.health_history):]
|
||||
}
|
||||
|
||||
# Calculate overall status
|
||||
if any(check.status == HealthStatus.CRITICAL for check in latest_checks.values()):
|
||||
overall_status = HealthStatus.CRITICAL
|
||||
elif any(check.status == HealthStatus.WARNING for check in latest_checks.values()):
|
||||
overall_status = HealthStatus.WARNING
|
||||
else:
|
||||
overall_status = HealthStatus.HEALTHY
|
||||
|
||||
return {
|
||||
'status': overall_status.value,
|
||||
'components': {
|
||||
component: {
|
||||
'status': check.status.value,
|
||||
'message': check.message,
|
||||
'details': check.details,
|
||||
'timestamp': check.timestamp.isoformat()
|
||||
}
|
||||
for component, check in latest_checks.items()
|
||||
},
|
||||
'failure_counts': self.failure_counts,
|
||||
'last_check': datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to get health summary: {str(e)}")
|
||||
return {
|
||||
'status': HealthStatus.UNKNOWN.value,
|
||||
'error': str(e),
|
||||
'last_check': datetime.utcnow().isoformat()
|
||||
}
|
||||
597
lib/content_scheduler/core/schedule_optimizer.py
Normal file
597
lib/content_scheduler/core/schedule_optimizer.py
Normal file
@@ -0,0 +1,597 @@
|
||||
"""
|
||||
Schedule optimization system for content scheduling.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
import numpy as np
|
||||
from collections import defaultdict
|
||||
|
||||
# Use unified database models
|
||||
from lib.database.models import ContentItem, Schedule, ScheduleStatus, ContentType, Platform, get_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class OptimizationResult:
|
||||
"""Result of schedule optimization."""
|
||||
original_schedule: Schedule
|
||||
optimized_time: datetime
|
||||
improvement_score: float
|
||||
optimization_reason: str
|
||||
confidence: float
|
||||
|
||||
class ScheduleOptimizer:
|
||||
"""Optimize content scheduling for maximum engagement."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the schedule optimizer."""
|
||||
self.logger = logger
|
||||
self.session = get_session()
|
||||
|
||||
# Platform-specific optimal times (can be made configurable)
|
||||
self.platform_optimal_times = {
|
||||
Platform.TWITTER: [9, 12, 15, 18], # Hours of day
|
||||
Platform.FACEBOOK: [9, 13, 15],
|
||||
Platform.LINKEDIN: [8, 12, 17],
|
||||
Platform.INSTAGRAM: [11, 14, 17, 19],
|
||||
Platform.YOUTUBE: [14, 16, 18, 20]
|
||||
}
|
||||
|
||||
# Content type engagement patterns
|
||||
self.content_type_patterns = {
|
||||
ContentType.ARTICLE: {'peak_hours': [9, 14, 16], 'duration': 2},
|
||||
ContentType.VIDEO: {'peak_hours': [12, 18, 20], 'duration': 3},
|
||||
ContentType.IMAGE: {'peak_hours': [11, 15, 19], 'duration': 1},
|
||||
ContentType.SOCIAL_POST: {'peak_hours': [8, 12, 17, 21], 'duration': 1}
|
||||
}
|
||||
|
||||
def optimize_schedule(self, schedule: Schedule) -> OptimizationResult:
|
||||
"""Optimize a single schedule for better engagement.
|
||||
|
||||
Args:
|
||||
schedule: Schedule to optimize
|
||||
|
||||
Returns:
|
||||
OptimizationResult with optimization details
|
||||
"""
|
||||
try:
|
||||
# Get content item details
|
||||
content_item = self.session.query(ContentItem).filter(
|
||||
ContentItem.id == schedule.content_item_id
|
||||
).first()
|
||||
|
||||
if not content_item:
|
||||
return OptimizationResult(
|
||||
original_schedule=schedule,
|
||||
optimized_time=schedule.scheduled_time,
|
||||
improvement_score=0.0,
|
||||
optimization_reason="Content item not found",
|
||||
confidence=0.0
|
||||
)
|
||||
|
||||
# Calculate current engagement score
|
||||
current_score = self._calculate_engagement_score(
|
||||
schedule.scheduled_time,
|
||||
content_item.content_type,
|
||||
schedule.priority
|
||||
)
|
||||
|
||||
# Find optimal time
|
||||
optimal_time, optimal_score = self._find_optimal_time(
|
||||
schedule,
|
||||
content_item
|
||||
)
|
||||
|
||||
# Calculate improvement
|
||||
improvement_score = optimal_score - current_score
|
||||
confidence = min(improvement_score / current_score, 1.0) if current_score > 0 else 0.0
|
||||
|
||||
# Generate optimization reason
|
||||
reason = self._generate_optimization_reason(
|
||||
schedule.scheduled_time,
|
||||
optimal_time,
|
||||
content_item.content_type,
|
||||
improvement_score
|
||||
)
|
||||
|
||||
return OptimizationResult(
|
||||
original_schedule=schedule,
|
||||
optimized_time=optimal_time,
|
||||
improvement_score=improvement_score,
|
||||
optimization_reason=reason,
|
||||
confidence=confidence
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error optimizing schedule: {str(e)}")
|
||||
return OptimizationResult(
|
||||
original_schedule=schedule,
|
||||
optimized_time=schedule.scheduled_time,
|
||||
improvement_score=0.0,
|
||||
optimization_reason=f"Optimization error: {str(e)}",
|
||||
confidence=0.0
|
||||
)
|
||||
|
||||
def optimize_multiple_schedules(
|
||||
self,
|
||||
schedules: List[Schedule],
|
||||
avoid_conflicts: bool = True
|
||||
) -> List[OptimizationResult]:
|
||||
"""Optimize multiple schedules considering conflicts.
|
||||
|
||||
Args:
|
||||
schedules: List of schedules to optimize
|
||||
avoid_conflicts: Whether to avoid scheduling conflicts
|
||||
|
||||
Returns:
|
||||
List of optimization results
|
||||
"""
|
||||
try:
|
||||
results = []
|
||||
optimized_times = []
|
||||
|
||||
# Sort schedules by priority (high priority first)
|
||||
sorted_schedules = sorted(schedules, key=lambda x: x.priority, reverse=True)
|
||||
|
||||
for schedule in sorted_schedules:
|
||||
# Optimize individual schedule
|
||||
result = self.optimize_schedule(schedule)
|
||||
|
||||
if avoid_conflicts:
|
||||
# Check for conflicts with already optimized schedules
|
||||
conflict_free_time = self._find_conflict_free_time(
|
||||
result.optimized_time,
|
||||
optimized_times,
|
||||
schedule
|
||||
)
|
||||
|
||||
if conflict_free_time != result.optimized_time:
|
||||
# Recalculate scores for conflict-free time
|
||||
content_item = self.session.query(ContentItem).filter(
|
||||
ContentItem.id == schedule.content_item_id
|
||||
).first()
|
||||
|
||||
if content_item:
|
||||
new_score = self._calculate_engagement_score(
|
||||
conflict_free_time,
|
||||
content_item.content_type,
|
||||
schedule.priority
|
||||
)
|
||||
|
||||
original_score = self._calculate_engagement_score(
|
||||
schedule.scheduled_time,
|
||||
content_item.content_type,
|
||||
schedule.priority
|
||||
)
|
||||
|
||||
result.optimized_time = conflict_free_time
|
||||
result.improvement_score = new_score - original_score
|
||||
result.optimization_reason += " (adjusted to avoid conflicts)"
|
||||
|
||||
results.append(result)
|
||||
optimized_times.append(result.optimized_time)
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error optimizing multiple schedules: {str(e)}")
|
||||
return []
|
||||
|
||||
def suggest_optimal_times(
|
||||
self,
|
||||
content_type: ContentType,
|
||||
date_range: Tuple[datetime, datetime],
|
||||
count: int = 5
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Suggest optimal times for new content.
|
||||
|
||||
Args:
|
||||
content_type: Type of content to schedule
|
||||
date_range: Date range to consider
|
||||
count: Number of suggestions to return
|
||||
|
||||
Returns:
|
||||
List of suggested optimal times with scores
|
||||
"""
|
||||
try:
|
||||
suggestions = []
|
||||
start_date, end_date = date_range
|
||||
|
||||
# Generate candidate times
|
||||
current_date = start_date
|
||||
while current_date <= end_date:
|
||||
# Get optimal hours for this content type
|
||||
if content_type in self.content_type_patterns:
|
||||
optimal_hours = self.content_type_patterns[content_type]['peak_hours']
|
||||
else:
|
||||
optimal_hours = [9, 12, 15, 18] # Default hours
|
||||
|
||||
for hour in optimal_hours:
|
||||
candidate_time = current_date.replace(
|
||||
hour=hour,
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0
|
||||
)
|
||||
|
||||
if start_date <= candidate_time <= end_date:
|
||||
score = self._calculate_engagement_score(
|
||||
candidate_time,
|
||||
content_type,
|
||||
priority=5 # Default priority
|
||||
)
|
||||
|
||||
suggestions.append({
|
||||
'time': candidate_time,
|
||||
'score': score,
|
||||
'day_of_week': candidate_time.strftime('%A'),
|
||||
'hour': hour,
|
||||
'reason': self._get_time_suggestion_reason(candidate_time, content_type)
|
||||
})
|
||||
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
# Sort by score and return top suggestions
|
||||
suggestions.sort(key=lambda x: x['score'], reverse=True)
|
||||
return suggestions[:count]
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error suggesting optimal times: {str(e)}")
|
||||
return []
|
||||
|
||||
def _calculate_engagement_score(
|
||||
self,
|
||||
scheduled_time: datetime,
|
||||
content_type: ContentType,
|
||||
priority: int
|
||||
) -> float:
|
||||
"""Calculate engagement score for a given time and content type."""
|
||||
try:
|
||||
score = 0.0
|
||||
|
||||
# Base score from priority
|
||||
score += priority * 10
|
||||
|
||||
# Hour of day factor
|
||||
hour = scheduled_time.hour
|
||||
if content_type in self.content_type_patterns:
|
||||
optimal_hours = self.content_type_patterns[content_type]['peak_hours']
|
||||
if hour in optimal_hours:
|
||||
score += 50
|
||||
else:
|
||||
# Penalty for non-optimal hours
|
||||
min_distance = min(abs(hour - oh) for oh in optimal_hours)
|
||||
score += max(0, 30 - min_distance * 5)
|
||||
|
||||
# Day of week factor
|
||||
day_of_week = scheduled_time.weekday() # 0 = Monday, 6 = Sunday
|
||||
|
||||
if content_type == ContentType.ARTICLE:
|
||||
# Articles perform better on weekdays
|
||||
if day_of_week < 5: # Monday to Friday
|
||||
score += 20
|
||||
else:
|
||||
score += 5
|
||||
elif content_type == ContentType.VIDEO:
|
||||
# Videos perform better on weekends and evenings
|
||||
if day_of_week >= 5 or hour >= 18:
|
||||
score += 25
|
||||
else:
|
||||
score += 10
|
||||
elif content_type == ContentType.SOCIAL_POST:
|
||||
# Social posts are consistent throughout the week
|
||||
score += 15
|
||||
|
||||
# Time spacing factor (avoid clustering)
|
||||
existing_schedules = self.session.query(Schedule).filter(
|
||||
Schedule.scheduled_time.between(
|
||||
scheduled_time - timedelta(hours=2),
|
||||
scheduled_time + timedelta(hours=2)
|
||||
)
|
||||
).all()
|
||||
|
||||
if len(existing_schedules) > 3:
|
||||
score -= len(existing_schedules) * 5
|
||||
|
||||
return max(score, 0.0)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error calculating engagement score: {str(e)}")
|
||||
return 0.0
|
||||
|
||||
def _find_optimal_time(
|
||||
self,
|
||||
schedule: Schedule,
|
||||
content_item: ContentItem
|
||||
) -> Tuple[datetime, float]:
|
||||
"""Find the optimal time for a schedule."""
|
||||
try:
|
||||
best_time = schedule.scheduled_time
|
||||
best_score = self._calculate_engagement_score(
|
||||
schedule.scheduled_time,
|
||||
content_item.content_type,
|
||||
schedule.priority
|
||||
)
|
||||
|
||||
# Search within a week of the original time
|
||||
base_date = schedule.scheduled_time.date()
|
||||
|
||||
for day_offset in range(-3, 4): # ±3 days
|
||||
candidate_date = base_date + timedelta(days=day_offset)
|
||||
|
||||
# Get optimal hours for this content type
|
||||
if content_item.content_type in self.content_type_patterns:
|
||||
optimal_hours = self.content_type_patterns[content_item.content_type]['peak_hours']
|
||||
else:
|
||||
optimal_hours = [9, 12, 15, 18]
|
||||
|
||||
for hour in optimal_hours:
|
||||
candidate_time = datetime.combine(candidate_date, datetime.min.time()).replace(hour=hour)
|
||||
|
||||
score = self._calculate_engagement_score(
|
||||
candidate_time,
|
||||
content_item.content_type,
|
||||
schedule.priority
|
||||
)
|
||||
|
||||
if score > best_score:
|
||||
best_time = candidate_time
|
||||
best_score = score
|
||||
|
||||
return best_time, best_score
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error finding optimal time: {str(e)}")
|
||||
return schedule.scheduled_time, 0.0
|
||||
|
||||
def _find_conflict_free_time(
|
||||
self,
|
||||
preferred_time: datetime,
|
||||
existing_times: List[datetime],
|
||||
schedule: Schedule,
|
||||
min_gap: timedelta = timedelta(minutes=30)
|
||||
) -> datetime:
|
||||
"""Find a conflict-free time close to the preferred time."""
|
||||
try:
|
||||
# Check if preferred time has conflicts
|
||||
has_conflict = any(
|
||||
abs((preferred_time - existing_time).total_seconds()) < min_gap.total_seconds()
|
||||
for existing_time in existing_times
|
||||
)
|
||||
|
||||
if not has_conflict:
|
||||
return preferred_time
|
||||
|
||||
# Search for nearby conflict-free times
|
||||
for offset_minutes in [30, 60, 90, 120, -30, -60, -90, -120]:
|
||||
candidate_time = preferred_time + timedelta(minutes=offset_minutes)
|
||||
|
||||
has_conflict = any(
|
||||
abs((candidate_time - existing_time).total_seconds()) < min_gap.total_seconds()
|
||||
for existing_time in existing_times
|
||||
)
|
||||
|
||||
if not has_conflict:
|
||||
return candidate_time
|
||||
|
||||
# If no conflict-free time found nearby, return preferred time
|
||||
return preferred_time
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error finding conflict-free time: {str(e)}")
|
||||
return preferred_time
|
||||
|
||||
def _generate_optimization_reason(
|
||||
self,
|
||||
original_time: datetime,
|
||||
optimized_time: datetime,
|
||||
content_type: ContentType,
|
||||
improvement_score: float
|
||||
) -> str:
|
||||
"""Generate a human-readable optimization reason."""
|
||||
try:
|
||||
if improvement_score <= 0:
|
||||
return "Current time is already optimal"
|
||||
|
||||
reasons = []
|
||||
|
||||
# Time difference
|
||||
time_diff = optimized_time - original_time
|
||||
if abs(time_diff.total_seconds()) > 3600: # More than 1 hour
|
||||
if time_diff.total_seconds() > 0:
|
||||
reasons.append(f"Moved {time_diff.total_seconds() / 3600:.1f} hours later")
|
||||
else:
|
||||
reasons.append(f"Moved {abs(time_diff.total_seconds()) / 3600:.1f} hours earlier")
|
||||
|
||||
# Hour optimization
|
||||
original_hour = original_time.hour
|
||||
optimized_hour = optimized_time.hour
|
||||
|
||||
if content_type in self.content_type_patterns:
|
||||
optimal_hours = self.content_type_patterns[content_type]['peak_hours']
|
||||
if optimized_hour in optimal_hours and original_hour not in optimal_hours:
|
||||
reasons.append(f"Moved to peak engagement hour ({optimized_hour}:00)")
|
||||
|
||||
# Day optimization
|
||||
original_day = original_time.strftime('%A')
|
||||
optimized_day = optimized_time.strftime('%A')
|
||||
|
||||
if original_day != optimized_day:
|
||||
reasons.append(f"Moved from {original_day} to {optimized_day}")
|
||||
|
||||
# Improvement score
|
||||
reasons.append(f"Expected {improvement_score:.1f}% engagement improvement")
|
||||
|
||||
return "; ".join(reasons) if reasons else "Optimized for better engagement"
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error generating optimization reason: {str(e)}")
|
||||
return "Optimized for better engagement"
|
||||
|
||||
def _get_time_suggestion_reason(self, time: datetime, content_type: ContentType) -> str:
|
||||
"""Get reason for suggesting a specific time."""
|
||||
try:
|
||||
reasons = []
|
||||
|
||||
hour = time.hour
|
||||
day_name = time.strftime('%A')
|
||||
|
||||
# Hour-based reasons
|
||||
if content_type in self.content_type_patterns:
|
||||
optimal_hours = self.content_type_patterns[content_type]['peak_hours']
|
||||
if hour in optimal_hours:
|
||||
reasons.append(f"Peak engagement hour for {content_type.value}")
|
||||
|
||||
# Day-based reasons
|
||||
if content_type == ContentType.ARTICLE and time.weekday() < 5:
|
||||
reasons.append("Weekday optimal for articles")
|
||||
elif content_type == ContentType.VIDEO and (time.weekday() >= 5 or hour >= 18):
|
||||
reasons.append("Evening/weekend optimal for videos")
|
||||
|
||||
return "; ".join(reasons) if reasons else f"Good time for {content_type.value}"
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting suggestion reason: {str(e)}")
|
||||
return "Recommended time"
|
||||
|
||||
def analyze_schedule_performance(self, days_back: int = 30) -> Dict[str, Any]:
|
||||
"""Analyze historical schedule performance."""
|
||||
try:
|
||||
# Get schedules from the last N days
|
||||
cutoff_date = datetime.now() - timedelta(days=days_back)
|
||||
|
||||
schedules = self.session.query(Schedule).filter(
|
||||
Schedule.created_at >= cutoff_date
|
||||
).all()
|
||||
|
||||
if not schedules:
|
||||
return {'error': 'No schedules found for analysis'}
|
||||
|
||||
# Analyze by hour
|
||||
hour_performance = defaultdict(list)
|
||||
day_performance = defaultdict(list)
|
||||
content_type_performance = defaultdict(list)
|
||||
|
||||
for schedule in schedules:
|
||||
content_item = self.session.query(ContentItem).filter(
|
||||
ContentItem.id == schedule.content_item_id
|
||||
).first()
|
||||
|
||||
if content_item:
|
||||
hour = schedule.scheduled_time.hour
|
||||
day = schedule.scheduled_time.strftime('%A')
|
||||
|
||||
# Calculate performance score (simplified)
|
||||
performance_score = self._calculate_performance_score(schedule)
|
||||
|
||||
hour_performance[hour].append(performance_score)
|
||||
day_performance[day].append(performance_score)
|
||||
content_type_performance[content_item.content_type.value].append(performance_score)
|
||||
|
||||
# Calculate averages
|
||||
analysis = {
|
||||
'total_schedules': len(schedules),
|
||||
'analysis_period_days': days_back,
|
||||
'best_hours': self._get_top_performers(hour_performance),
|
||||
'best_days': self._get_top_performers(day_performance),
|
||||
'content_type_performance': self._get_top_performers(content_type_performance),
|
||||
'recommendations': self._generate_performance_recommendations(
|
||||
hour_performance,
|
||||
day_performance,
|
||||
content_type_performance
|
||||
)
|
||||
}
|
||||
|
||||
return analysis
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error analyzing schedule performance: {str(e)}")
|
||||
return {'error': str(e)}
|
||||
|
||||
def _calculate_performance_score(self, schedule: Schedule) -> float:
|
||||
"""Calculate a performance score for a schedule (simplified)."""
|
||||
try:
|
||||
# This is a simplified performance calculation
|
||||
# In a real implementation, this would use actual engagement metrics
|
||||
|
||||
base_score = 50.0 # Base performance
|
||||
|
||||
# Status-based scoring
|
||||
if schedule.status == ScheduleStatus.COMPLETED:
|
||||
base_score += 30
|
||||
elif schedule.status == ScheduleStatus.RUNNING:
|
||||
base_score += 15
|
||||
elif schedule.status == ScheduleStatus.FAILED:
|
||||
base_score -= 20
|
||||
|
||||
# Priority-based scoring
|
||||
base_score += schedule.priority * 2
|
||||
|
||||
return max(base_score, 0.0)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error calculating performance score: {str(e)}")
|
||||
return 0.0
|
||||
|
||||
def _get_top_performers(self, performance_data: Dict[str, List[float]]) -> List[Dict[str, Any]]:
|
||||
"""Get top performing items from performance data."""
|
||||
try:
|
||||
performers = []
|
||||
|
||||
for key, scores in performance_data.items():
|
||||
if scores:
|
||||
avg_score = np.mean(scores)
|
||||
performers.append({
|
||||
'key': key,
|
||||
'average_score': avg_score,
|
||||
'sample_count': len(scores)
|
||||
})
|
||||
|
||||
# Sort by average score
|
||||
performers.sort(key=lambda x: x['average_score'], reverse=True)
|
||||
|
||||
return performers[:5] # Top 5
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting top performers: {str(e)}")
|
||||
return []
|
||||
|
||||
def _generate_performance_recommendations(
|
||||
self,
|
||||
hour_performance: Dict[int, List[float]],
|
||||
day_performance: Dict[str, List[float]],
|
||||
content_type_performance: Dict[str, List[float]]
|
||||
) -> List[str]:
|
||||
"""Generate performance-based recommendations."""
|
||||
try:
|
||||
recommendations = []
|
||||
|
||||
# Hour recommendations
|
||||
if hour_performance:
|
||||
best_hours = self._get_top_performers(hour_performance)
|
||||
if best_hours:
|
||||
best_hour = best_hours[0]['key']
|
||||
recommendations.append(f"Schedule more content around {best_hour}:00 for better performance")
|
||||
|
||||
# Day recommendations
|
||||
if day_performance:
|
||||
best_days = self._get_top_performers(day_performance)
|
||||
if best_days:
|
||||
best_day = best_days[0]['key']
|
||||
recommendations.append(f"Consider scheduling more content on {best_day}s")
|
||||
|
||||
# Content type recommendations
|
||||
if content_type_performance:
|
||||
best_types = self._get_top_performers(content_type_performance)
|
||||
if best_types:
|
||||
best_type = best_types[0]['key']
|
||||
recommendations.append(f"{best_type} content shows the best performance")
|
||||
|
||||
return recommendations
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error generating recommendations: {str(e)}")
|
||||
return []
|
||||
611
lib/content_scheduler/core/schedule_validator.py
Normal file
611
lib/content_scheduler/core/schedule_validator.py
Normal file
@@ -0,0 +1,611 @@
|
||||
"""
|
||||
Schedule validation system for content scheduling.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
|
||||
# Use unified database models
|
||||
from lib.database.models import ContentItem, Schedule, ScheduleStatus, ContentType, Platform, get_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Result of schedule validation."""
|
||||
is_valid: bool
|
||||
errors: List[str]
|
||||
warnings: List[str]
|
||||
suggestions: List[str]
|
||||
confidence: float
|
||||
|
||||
class ScheduleValidator:
|
||||
"""Validate content schedules for compliance and optimization."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the schedule validator."""
|
||||
self.logger = logger
|
||||
self.session = get_session()
|
||||
|
||||
# Platform-specific validation rules
|
||||
self.platform_rules = {
|
||||
Platform.TWITTER: {
|
||||
'max_text_length': 280,
|
||||
'max_images': 4,
|
||||
'max_videos': 1,
|
||||
'allowed_formats': ['jpg', 'png', 'gif', 'mp4'],
|
||||
'max_file_size_mb': 5,
|
||||
'posting_frequency_limit': {'per_hour': 10, 'per_day': 100}
|
||||
},
|
||||
Platform.FACEBOOK: {
|
||||
'max_text_length': 63206,
|
||||
'max_images': 10,
|
||||
'max_videos': 1,
|
||||
'allowed_formats': ['jpg', 'png', 'gif', 'mp4', 'mov'],
|
||||
'max_file_size_mb': 100,
|
||||
'posting_frequency_limit': {'per_hour': 5, 'per_day': 25}
|
||||
},
|
||||
Platform.LINKEDIN: {
|
||||
'max_text_length': 3000,
|
||||
'max_images': 9,
|
||||
'max_videos': 1,
|
||||
'allowed_formats': ['jpg', 'png', 'gif', 'mp4'],
|
||||
'max_file_size_mb': 200,
|
||||
'posting_frequency_limit': {'per_hour': 3, 'per_day': 20}
|
||||
},
|
||||
Platform.INSTAGRAM: {
|
||||
'max_text_length': 2200,
|
||||
'max_images': 10,
|
||||
'max_videos': 1,
|
||||
'allowed_formats': ['jpg', 'png', 'mp4'],
|
||||
'max_file_size_mb': 100,
|
||||
'posting_frequency_limit': {'per_hour': 2, 'per_day': 10}
|
||||
}
|
||||
}
|
||||
|
||||
# Content type validation rules
|
||||
self.content_type_rules = {
|
||||
ContentType.ARTICLE: {
|
||||
'min_title_length': 10,
|
||||
'max_title_length': 200,
|
||||
'min_content_length': 100,
|
||||
'required_fields': ['title', 'content', 'summary']
|
||||
},
|
||||
ContentType.VIDEO: {
|
||||
'min_duration_sec': 5,
|
||||
'max_duration_sec': 3600,
|
||||
'required_fields': ['title', 'description'],
|
||||
'recommended_formats': ['mp4', 'mov']
|
||||
},
|
||||
ContentType.IMAGE: {
|
||||
'min_width': 400,
|
||||
'min_height': 400,
|
||||
'max_width': 4096,
|
||||
'max_height': 4096,
|
||||
'required_fields': ['title', 'alt_text']
|
||||
},
|
||||
ContentType.SOCIAL_POST: {
|
||||
'min_length': 10,
|
||||
'max_length': 500,
|
||||
'required_fields': ['content']
|
||||
}
|
||||
}
|
||||
|
||||
def validate_schedule(self, schedule: Schedule) -> ValidationResult:
|
||||
"""Validate a single schedule.
|
||||
|
||||
Args:
|
||||
schedule: Schedule to validate
|
||||
|
||||
Returns:
|
||||
ValidationResult with validation details
|
||||
"""
|
||||
try:
|
||||
errors = []
|
||||
warnings = []
|
||||
suggestions = []
|
||||
|
||||
# Get content item details
|
||||
content_item = self.session.query(ContentItem).filter(
|
||||
ContentItem.id == schedule.content_item_id
|
||||
).first()
|
||||
|
||||
if not content_item:
|
||||
return ValidationResult(
|
||||
is_valid=False,
|
||||
errors=["Content item not found"],
|
||||
warnings=[],
|
||||
suggestions=[],
|
||||
confidence=0.0
|
||||
)
|
||||
|
||||
# Validate basic schedule properties
|
||||
basic_validation = self._validate_basic_properties(schedule)
|
||||
errors.extend(basic_validation['errors'])
|
||||
warnings.extend(basic_validation['warnings'])
|
||||
suggestions.extend(basic_validation['suggestions'])
|
||||
|
||||
# Validate content properties
|
||||
content_validation = self._validate_content_properties(content_item)
|
||||
errors.extend(content_validation['errors'])
|
||||
warnings.extend(content_validation['warnings'])
|
||||
suggestions.extend(content_validation['suggestions'])
|
||||
|
||||
# Validate timing
|
||||
timing_validation = self._validate_timing(schedule)
|
||||
errors.extend(timing_validation['errors'])
|
||||
warnings.extend(timing_validation['warnings'])
|
||||
suggestions.extend(timing_validation['suggestions'])
|
||||
|
||||
# Validate conflicts
|
||||
conflict_validation = self._validate_conflicts(schedule)
|
||||
errors.extend(conflict_validation['errors'])
|
||||
warnings.extend(conflict_validation['warnings'])
|
||||
suggestions.extend(conflict_validation['suggestions'])
|
||||
|
||||
# Calculate confidence
|
||||
confidence = self._calculate_validation_confidence(errors, warnings)
|
||||
|
||||
return ValidationResult(
|
||||
is_valid=len(errors) == 0,
|
||||
errors=errors,
|
||||
warnings=warnings,
|
||||
suggestions=suggestions,
|
||||
confidence=confidence
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error validating schedule: {str(e)}")
|
||||
return ValidationResult(
|
||||
is_valid=False,
|
||||
errors=[f"Validation error: {str(e)}"],
|
||||
warnings=[],
|
||||
suggestions=[],
|
||||
confidence=0.0
|
||||
)
|
||||
|
||||
def validate_multiple_schedules(self, schedules: List[Schedule]) -> Dict[str, ValidationResult]:
|
||||
"""Validate multiple schedules and check for cross-schedule issues.
|
||||
|
||||
Args:
|
||||
schedules: List of schedules to validate
|
||||
|
||||
Returns:
|
||||
Dictionary mapping schedule IDs to validation results
|
||||
"""
|
||||
try:
|
||||
results = {}
|
||||
|
||||
# Validate individual schedules
|
||||
for schedule in schedules:
|
||||
results[str(schedule.id)] = self.validate_schedule(schedule)
|
||||
|
||||
# Check for cross-schedule conflicts
|
||||
cross_validation = self._validate_cross_schedule_conflicts(schedules)
|
||||
|
||||
# Add cross-validation issues to individual results
|
||||
for schedule_id, issues in cross_validation.items():
|
||||
if schedule_id in results:
|
||||
results[schedule_id].warnings.extend(issues.get('warnings', []))
|
||||
results[schedule_id].suggestions.extend(issues.get('suggestions', []))
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error validating multiple schedules: {str(e)}")
|
||||
return {}
|
||||
|
||||
def _validate_basic_properties(self, schedule: Schedule) -> Dict[str, List[str]]:
|
||||
"""Validate basic schedule properties."""
|
||||
errors = []
|
||||
warnings = []
|
||||
suggestions = []
|
||||
|
||||
try:
|
||||
# Check required fields
|
||||
if not schedule.content_item_id:
|
||||
errors.append("Content item ID is required")
|
||||
|
||||
if not schedule.scheduled_time:
|
||||
errors.append("Scheduled time is required")
|
||||
|
||||
if not schedule.status:
|
||||
errors.append("Schedule status is required")
|
||||
|
||||
# Check priority range
|
||||
if schedule.priority < 1 or schedule.priority > 10:
|
||||
warnings.append(f"Priority {schedule.priority} is outside recommended range (1-10)")
|
||||
|
||||
# Check if schedule is in the past
|
||||
if schedule.scheduled_time < datetime.now():
|
||||
if schedule.status == ScheduleStatus.PENDING:
|
||||
errors.append("Cannot schedule content in the past")
|
||||
else:
|
||||
warnings.append("Schedule time is in the past")
|
||||
|
||||
# Check if schedule is too far in the future
|
||||
max_future_days = 365 # 1 year
|
||||
if schedule.scheduled_time > datetime.now() + timedelta(days=max_future_days):
|
||||
warnings.append(f"Schedule is more than {max_future_days} days in the future")
|
||||
suggestions.append("Consider scheduling closer to the current date for better relevance")
|
||||
|
||||
# Validate recurrence pattern
|
||||
if schedule.recurrence:
|
||||
recurrence_validation = self._validate_recurrence_pattern(schedule.recurrence)
|
||||
errors.extend(recurrence_validation['errors'])
|
||||
warnings.extend(recurrence_validation['warnings'])
|
||||
suggestions.extend(recurrence_validation['suggestions'])
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error validating basic properties: {str(e)}")
|
||||
errors.append(f"Basic validation error: {str(e)}")
|
||||
|
||||
return {'errors': errors, 'warnings': warnings, 'suggestions': suggestions}
|
||||
|
||||
def _validate_content_properties(self, content_item: ContentItem) -> Dict[str, List[str]]:
|
||||
"""Validate content item properties."""
|
||||
errors = []
|
||||
warnings = []
|
||||
suggestions = []
|
||||
|
||||
try:
|
||||
# Check required fields
|
||||
if not content_item.title or len(content_item.title.strip()) == 0:
|
||||
errors.append("Content title is required")
|
||||
|
||||
if not content_item.content or len(content_item.content.strip()) == 0:
|
||||
errors.append("Content body is required")
|
||||
|
||||
# Validate based on content type
|
||||
if content_item.content_type:
|
||||
type_rules = self.content_type_rules.get(content_item.content_type)
|
||||
if type_rules:
|
||||
type_validation = self._validate_content_type_rules(content_item, type_rules)
|
||||
errors.extend(type_validation['errors'])
|
||||
warnings.extend(type_validation['warnings'])
|
||||
suggestions.extend(type_validation['suggestions'])
|
||||
|
||||
# Check for potentially problematic content
|
||||
content_check = self._check_content_quality(content_item)
|
||||
warnings.extend(content_check['warnings'])
|
||||
suggestions.extend(content_check['suggestions'])
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error validating content properties: {str(e)}")
|
||||
errors.append(f"Content validation error: {str(e)}")
|
||||
|
||||
return {'errors': errors, 'warnings': warnings, 'suggestions': suggestions}
|
||||
|
||||
def _validate_timing(self, schedule: Schedule) -> Dict[str, List[str]]:
|
||||
"""Validate schedule timing."""
|
||||
errors = []
|
||||
warnings = []
|
||||
suggestions = []
|
||||
|
||||
try:
|
||||
scheduled_time = schedule.scheduled_time
|
||||
|
||||
# Check if it's a reasonable time to post
|
||||
hour = scheduled_time.hour
|
||||
day_of_week = scheduled_time.weekday() # 0 = Monday, 6 = Sunday
|
||||
|
||||
# Check for very early or very late hours
|
||||
if hour < 6 or hour > 23:
|
||||
warnings.append(f"Scheduled for {hour}:00 - consider posting during peak hours (6 AM - 11 PM)")
|
||||
suggestions.append("Peak engagement typically occurs between 9 AM and 9 PM")
|
||||
|
||||
# Check for weekend posting (depending on content type)
|
||||
content_item = self.session.query(ContentItem).filter(
|
||||
ContentItem.id == schedule.content_item_id
|
||||
).first()
|
||||
|
||||
if content_item and content_item.content_type == ContentType.ARTICLE:
|
||||
if day_of_week >= 5: # Weekend
|
||||
warnings.append("Business content typically performs better on weekdays")
|
||||
suggestions.append("Consider rescheduling to Monday-Friday for better engagement")
|
||||
|
||||
# Check for holidays or special dates (simplified)
|
||||
if self._is_holiday(scheduled_time.date()):
|
||||
warnings.append("Scheduled for a holiday - engagement may be lower")
|
||||
suggestions.append("Consider rescheduling to avoid holidays for better reach")
|
||||
|
||||
# Check frequency limits
|
||||
frequency_check = self._check_posting_frequency(schedule)
|
||||
warnings.extend(frequency_check['warnings'])
|
||||
suggestions.extend(frequency_check['suggestions'])
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error validating timing: {str(e)}")
|
||||
errors.append(f"Timing validation error: {str(e)}")
|
||||
|
||||
return {'errors': errors, 'warnings': warnings, 'suggestions': suggestions}
|
||||
|
||||
def _validate_conflicts(self, schedule: Schedule) -> Dict[str, List[str]]:
|
||||
"""Validate for scheduling conflicts."""
|
||||
errors = []
|
||||
warnings = []
|
||||
suggestions = []
|
||||
|
||||
try:
|
||||
# Check for nearby schedules
|
||||
time_window = timedelta(minutes=30)
|
||||
nearby_schedules = self.session.query(Schedule).filter(
|
||||
Schedule.id != schedule.id,
|
||||
Schedule.scheduled_time.between(
|
||||
schedule.scheduled_time - time_window,
|
||||
schedule.scheduled_time + time_window
|
||||
)
|
||||
).all()
|
||||
|
||||
if nearby_schedules:
|
||||
warnings.append(f"Found {len(nearby_schedules)} other schedule(s) within 30 minutes")
|
||||
suggestions.append("Consider spacing schedules at least 30 minutes apart for better visibility")
|
||||
|
||||
# Check for same-day content overload
|
||||
same_day_schedules = self.session.query(Schedule).filter(
|
||||
Schedule.id != schedule.id,
|
||||
Schedule.scheduled_time >= schedule.scheduled_time.replace(hour=0, minute=0, second=0),
|
||||
Schedule.scheduled_time < schedule.scheduled_time.replace(hour=0, minute=0, second=0) + timedelta(days=1)
|
||||
).all()
|
||||
|
||||
if len(same_day_schedules) > 5:
|
||||
warnings.append(f"Found {len(same_day_schedules)} other schedules on the same day")
|
||||
suggestions.append("Consider distributing content across multiple days to avoid overwhelming your audience")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error validating conflicts: {str(e)}")
|
||||
errors.append(f"Conflict validation error: {str(e)}")
|
||||
|
||||
return {'errors': errors, 'warnings': warnings, 'suggestions': suggestions}
|
||||
|
||||
def _validate_recurrence_pattern(self, recurrence: str) -> Dict[str, List[str]]:
|
||||
"""Validate recurrence pattern."""
|
||||
errors = []
|
||||
warnings = []
|
||||
suggestions = []
|
||||
|
||||
try:
|
||||
# Define valid recurrence patterns
|
||||
valid_patterns = [
|
||||
'daily', 'weekly', 'monthly', 'yearly',
|
||||
'weekdays', 'weekends',
|
||||
'every 2 days', 'every 3 days', 'every 7 days',
|
||||
'every 2 weeks', 'every 2 months'
|
||||
]
|
||||
|
||||
if recurrence.lower() not in valid_patterns:
|
||||
# Check if it's a cron-like pattern
|
||||
if not self._is_valid_cron_pattern(recurrence):
|
||||
errors.append(f"Invalid recurrence pattern: {recurrence}")
|
||||
suggestions.append(f"Valid patterns include: {', '.join(valid_patterns[:5])}")
|
||||
|
||||
# Check for overly frequent recurrence
|
||||
if 'hour' in recurrence.lower():
|
||||
warnings.append("Hourly recurrence may overwhelm your audience")
|
||||
suggestions.append("Consider daily or weekly recurrence for better engagement")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error validating recurrence: {str(e)}")
|
||||
errors.append(f"Recurrence validation error: {str(e)}")
|
||||
|
||||
return {'errors': errors, 'warnings': warnings, 'suggestions': suggestions}
|
||||
|
||||
def _validate_content_type_rules(self, content_item: ContentItem, rules: Dict[str, Any]) -> Dict[str, List[str]]:
|
||||
"""Validate content against type-specific rules."""
|
||||
errors = []
|
||||
warnings = []
|
||||
suggestions = []
|
||||
|
||||
try:
|
||||
# Check title length
|
||||
if 'min_title_length' in rules and len(content_item.title) < rules['min_title_length']:
|
||||
errors.append(f"Title too short (minimum {rules['min_title_length']} characters)")
|
||||
|
||||
if 'max_title_length' in rules and len(content_item.title) > rules['max_title_length']:
|
||||
errors.append(f"Title too long (maximum {rules['max_title_length']} characters)")
|
||||
|
||||
# Check content length
|
||||
if 'min_content_length' in rules and len(content_item.content) < rules['min_content_length']:
|
||||
errors.append(f"Content too short (minimum {rules['min_content_length']} characters)")
|
||||
|
||||
if 'max_length' in rules and len(content_item.content) > rules['max_length']:
|
||||
errors.append(f"Content too long (maximum {rules['max_length']} characters)")
|
||||
|
||||
# Check required fields
|
||||
if 'required_fields' in rules:
|
||||
for field in rules['required_fields']:
|
||||
if not hasattr(content_item, field) or not getattr(content_item, field):
|
||||
errors.append(f"Required field missing: {field}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error validating content type rules: {str(e)}")
|
||||
errors.append(f"Content type validation error: {str(e)}")
|
||||
|
||||
return {'errors': errors, 'warnings': warnings, 'suggestions': suggestions}
|
||||
|
||||
def _check_content_quality(self, content_item: ContentItem) -> Dict[str, List[str]]:
|
||||
"""Check content quality and provide suggestions."""
|
||||
warnings = []
|
||||
suggestions = []
|
||||
|
||||
try:
|
||||
content = content_item.content
|
||||
title = content_item.title
|
||||
|
||||
# Check for excessive capitalization
|
||||
if title and title.isupper():
|
||||
warnings.append("Title is in all caps")
|
||||
suggestions.append("Consider using proper capitalization for better readability")
|
||||
|
||||
# Check for excessive punctuation
|
||||
if content and content.count('!') > 3:
|
||||
warnings.append("Excessive exclamation marks detected")
|
||||
suggestions.append("Reduce exclamation marks for more professional tone")
|
||||
|
||||
# Check for spelling/grammar (simplified)
|
||||
if content:
|
||||
# Simple checks for common issues
|
||||
if ' ' in content: # Double spaces
|
||||
suggestions.append("Remove extra spaces for cleaner formatting")
|
||||
|
||||
if content.count('?') > 5:
|
||||
warnings.append("Many question marks detected")
|
||||
suggestions.append("Consider reducing questions for clearer messaging")
|
||||
|
||||
# Check for hashtag usage
|
||||
hashtag_count = len(re.findall(r'#\w+', content)) if content else 0
|
||||
if hashtag_count > 10:
|
||||
warnings.append(f"High number of hashtags ({hashtag_count})")
|
||||
suggestions.append("Consider using 3-5 relevant hashtags for optimal reach")
|
||||
|
||||
# Check for URL presence
|
||||
url_count = len(re.findall(r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\\(\\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', content)) if content else 0
|
||||
if url_count > 2:
|
||||
warnings.append(f"Multiple URLs detected ({url_count})")
|
||||
suggestions.append("Consider limiting to 1-2 URLs to avoid appearing spammy")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error checking content quality: {str(e)}")
|
||||
|
||||
return {'warnings': warnings, 'suggestions': suggestions}
|
||||
|
||||
def _check_posting_frequency(self, schedule: Schedule) -> Dict[str, List[str]]:
|
||||
"""Check posting frequency limits."""
|
||||
warnings = []
|
||||
suggestions = []
|
||||
|
||||
try:
|
||||
# Check hourly frequency
|
||||
hour_start = schedule.scheduled_time.replace(minute=0, second=0, microsecond=0)
|
||||
hour_end = hour_start + timedelta(hours=1)
|
||||
|
||||
hourly_schedules = self.session.query(Schedule).filter(
|
||||
Schedule.scheduled_time >= hour_start,
|
||||
Schedule.scheduled_time < hour_end
|
||||
).count()
|
||||
|
||||
if hourly_schedules > 3:
|
||||
warnings.append(f"High posting frequency: {hourly_schedules} posts in the same hour")
|
||||
suggestions.append("Consider spacing posts throughout the day for better engagement")
|
||||
|
||||
# Check daily frequency
|
||||
day_start = schedule.scheduled_time.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
day_end = day_start + timedelta(days=1)
|
||||
|
||||
daily_schedules = self.session.query(Schedule).filter(
|
||||
Schedule.scheduled_time >= day_start,
|
||||
Schedule.scheduled_time < day_end
|
||||
).count()
|
||||
|
||||
if daily_schedules > 10:
|
||||
warnings.append(f"High daily posting frequency: {daily_schedules} posts")
|
||||
suggestions.append("Consider reducing daily posts to 3-5 for optimal audience engagement")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error checking posting frequency: {str(e)}")
|
||||
|
||||
return {'warnings': warnings, 'suggestions': suggestions}
|
||||
|
||||
def _validate_cross_schedule_conflicts(self, schedules: List[Schedule]) -> Dict[str, Dict[str, List[str]]]:
|
||||
"""Validate conflicts across multiple schedules."""
|
||||
conflicts = {}
|
||||
|
||||
try:
|
||||
# Sort schedules by time
|
||||
sorted_schedules = sorted(schedules, key=lambda x: x.scheduled_time)
|
||||
|
||||
for i, schedule in enumerate(sorted_schedules):
|
||||
schedule_id = str(schedule.id)
|
||||
conflicts[schedule_id] = {'warnings': [], 'suggestions': []}
|
||||
|
||||
# Check with subsequent schedules
|
||||
for j in range(i + 1, len(sorted_schedules)):
|
||||
other_schedule = sorted_schedules[j]
|
||||
time_diff = other_schedule.scheduled_time - schedule.scheduled_time
|
||||
|
||||
# Check if schedules are too close
|
||||
if time_diff < timedelta(minutes=15):
|
||||
conflicts[schedule_id]['warnings'].append(
|
||||
f"Schedule conflicts with another schedule {time_diff.total_seconds() / 60:.0f} minutes later"
|
||||
)
|
||||
conflicts[schedule_id]['suggestions'].append(
|
||||
"Consider spacing schedules at least 15 minutes apart"
|
||||
)
|
||||
|
||||
# Stop checking if schedules are more than 2 hours apart
|
||||
if time_diff > timedelta(hours=2):
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error validating cross-schedule conflicts: {str(e)}")
|
||||
|
||||
return conflicts
|
||||
|
||||
def _calculate_validation_confidence(self, errors: List[str], warnings: List[str]) -> float:
|
||||
"""Calculate confidence in validation results."""
|
||||
try:
|
||||
# Start with full confidence
|
||||
confidence = 1.0
|
||||
|
||||
# Reduce confidence based on errors and warnings
|
||||
confidence -= len(errors) * 0.2 # Each error reduces confidence by 20%
|
||||
confidence -= len(warnings) * 0.05 # Each warning reduces confidence by 5%
|
||||
|
||||
# Ensure confidence is between 0 and 1
|
||||
return max(0.0, min(1.0, confidence))
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error calculating validation confidence: {str(e)}")
|
||||
return 0.0
|
||||
|
||||
def _is_holiday(self, date) -> bool:
|
||||
"""Check if a date is a holiday (simplified implementation)."""
|
||||
try:
|
||||
# This is a simplified implementation
|
||||
# In a real system, you would use a proper holiday library
|
||||
|
||||
# Check for some common holidays
|
||||
month = date.month
|
||||
day = date.day
|
||||
|
||||
# New Year's Day
|
||||
if month == 1 and day == 1:
|
||||
return True
|
||||
|
||||
# Christmas
|
||||
if month == 12 and day == 25:
|
||||
return True
|
||||
|
||||
# Independence Day (US)
|
||||
if month == 7 and day == 4:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error checking holiday: {str(e)}")
|
||||
return False
|
||||
|
||||
def _is_valid_cron_pattern(self, pattern: str) -> bool:
|
||||
"""Check if a string is a valid cron pattern (simplified)."""
|
||||
try:
|
||||
# This is a very simplified cron validation
|
||||
# A proper implementation would use a cron parsing library
|
||||
|
||||
parts = pattern.split()
|
||||
if len(parts) != 5:
|
||||
return False
|
||||
|
||||
# Basic validation for each part
|
||||
for part in parts:
|
||||
if not (part.isdigit() or part == '*' or '/' in part or '-' in part or ',' in part):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error validating cron pattern: {str(e)}")
|
||||
return False
|
||||
402
lib/content_scheduler/core/scheduler.py
Normal file
402
lib/content_scheduler/core/scheduler.py
Normal file
@@ -0,0 +1,402 @@
|
||||
"""
|
||||
Core scheduler implementation using APScheduler.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
from datetime import datetime, timedelta
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
|
||||
from apscheduler.executors.pool import ThreadPoolExecutor, ProcessPoolExecutor
|
||||
from apscheduler.triggers.date import DateTrigger
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED, EVENT_JOB_MISSED
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# Use unified database models
|
||||
from lib.database.models import ContentItem, Schedule, ScheduleStatus, get_engine, get_session, init_db
|
||||
from ..utils.error_handling import SchedulingError
|
||||
from .conflict_resolver import ConflictResolver
|
||||
from .health_checker import ScheduleHealthChecker
|
||||
from .schedule_validator import ScheduleValidator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
class ContentScheduler:
|
||||
"""Core content scheduler implementation."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_url: str = "sqlite:///content_scheduler.db",
|
||||
max_workers: int = 10,
|
||||
job_timeout: int = 300,
|
||||
max_retries: int = 3,
|
||||
retry_delay: int = 300,
|
||||
health_check_interval: int = 300,
|
||||
validation_config: Dict[str, Any] = None
|
||||
):
|
||||
"""Initialize the content scheduler.
|
||||
|
||||
Args:
|
||||
db_url: Database URL for job persistence
|
||||
max_workers: Maximum number of worker threads
|
||||
job_timeout: Job execution timeout in seconds
|
||||
max_retries: Maximum number of retry attempts
|
||||
retry_delay: Delay between retries in seconds
|
||||
health_check_interval: Health check interval in seconds
|
||||
validation_config: Configuration for schedule validation
|
||||
"""
|
||||
self.logger = logger
|
||||
self.db_url = db_url
|
||||
self.max_workers = max_workers
|
||||
self.job_timeout = job_timeout
|
||||
self.max_retries = max_retries
|
||||
self.retry_delay = retry_delay
|
||||
|
||||
# Use unified database connection
|
||||
self.engine = get_engine(db_url)
|
||||
init_db(self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
|
||||
# Initialize job stores
|
||||
self.jobstores = {
|
||||
'default': SQLAlchemyJobStore(url=db_url)
|
||||
}
|
||||
|
||||
# Initialize executors
|
||||
self.executors = {
|
||||
'default': ThreadPoolExecutor(max_workers),
|
||||
'processpool': ProcessPoolExecutor(max_workers)
|
||||
}
|
||||
|
||||
# Initialize scheduler
|
||||
self.scheduler = AsyncIOScheduler(
|
||||
jobstores=self.jobstores,
|
||||
executors=self.executors,
|
||||
timezone='UTC',
|
||||
job_defaults={
|
||||
'coalesce': True,
|
||||
'max_instances': 1,
|
||||
'misfire_grace_time': 60
|
||||
}
|
||||
)
|
||||
|
||||
# Initialize conflict resolver
|
||||
self.conflict_resolver = ConflictResolver()
|
||||
|
||||
# Initialize health checker
|
||||
self.health_checker = ScheduleHealthChecker(
|
||||
scheduler=self,
|
||||
check_interval=health_check_interval
|
||||
)
|
||||
|
||||
# Initialize validator
|
||||
self.validator = ScheduleValidator(validation_config or {})
|
||||
|
||||
# Add event listeners
|
||||
self.scheduler.add_listener(
|
||||
self._handle_job_event,
|
||||
EVENT_JOB_EXECUTED | EVENT_JOB_ERROR | EVENT_JOB_MISSED
|
||||
)
|
||||
|
||||
# Track active jobs
|
||||
self.active_jobs = {}
|
||||
self.job_stats = {
|
||||
'total_scheduled': 0,
|
||||
'successful': 0,
|
||||
'failed': 0,
|
||||
'retries': 0
|
||||
}
|
||||
|
||||
async def start(self):
|
||||
"""Start the scheduler."""
|
||||
try:
|
||||
if not self.scheduler.running:
|
||||
self.scheduler.start()
|
||||
await self._recover_jobs()
|
||||
await self.health_checker.start()
|
||||
self.logger.info("Content scheduler started successfully")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to start scheduler: {str(e)}")
|
||||
raise SchedulingError(f"Scheduler startup failed: {str(e)}")
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the scheduler."""
|
||||
try:
|
||||
if self.scheduler.running:
|
||||
self.scheduler.shutdown(wait=True)
|
||||
await self.health_checker.stop()
|
||||
self.logger.info("Content scheduler stopped successfully")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to stop scheduler: {str(e)}")
|
||||
raise SchedulingError(f"Scheduler shutdown failed: {str(e)}")
|
||||
|
||||
async def schedule_content(self, content_item: ContentItem, schedule_time: datetime,
|
||||
platforms: List[str], recurrence: str = None,
|
||||
validate: bool = True) -> str:
|
||||
"""Schedule content for publishing.
|
||||
|
||||
Args:
|
||||
content_item: ContentItem to schedule
|
||||
schedule_time: When to publish
|
||||
platforms: List of platforms to publish to
|
||||
recurrence: Recurrence pattern (optional)
|
||||
validate: Whether to validate the schedule
|
||||
|
||||
Returns:
|
||||
Schedule ID
|
||||
"""
|
||||
try:
|
||||
session = self.Session()
|
||||
|
||||
# Create schedule record
|
||||
schedule = Schedule(
|
||||
content_item_id=content_item.id,
|
||||
scheduled_time=schedule_time,
|
||||
status=ScheduleStatus.SCHEDULED,
|
||||
recurrence=recurrence,
|
||||
priority=1
|
||||
)
|
||||
|
||||
session.add(schedule)
|
||||
session.commit()
|
||||
|
||||
# Schedule the job
|
||||
if recurrence:
|
||||
job_id = await self._schedule_recurring(schedule, platforms)
|
||||
else:
|
||||
job_id = await self._schedule_one_time(schedule, platforms)
|
||||
|
||||
# Update schedule with job ID
|
||||
schedule.result = f"job_id:{job_id}"
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
self.job_stats['total_scheduled'] += 1
|
||||
self.logger.info(f"Scheduled content {content_item.id} for {schedule_time}")
|
||||
|
||||
return str(schedule.id)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to schedule content: {str(e)}")
|
||||
if 'session' in locals():
|
||||
session.rollback()
|
||||
session.close()
|
||||
raise SchedulingError(f"Content scheduling failed: {str(e)}")
|
||||
|
||||
async def _schedule_one_time(self, schedule: Schedule, platforms: List[str]) -> str:
|
||||
"""Schedule a one-time content publish.
|
||||
|
||||
Args:
|
||||
schedule: Schedule object
|
||||
platforms: List of platforms
|
||||
|
||||
Returns:
|
||||
Job ID
|
||||
"""
|
||||
try:
|
||||
job_id = f"one_time_{schedule.content_item_id}_{int(schedule.scheduled_time.timestamp())}"
|
||||
|
||||
self.scheduler.add_job(
|
||||
self._run_async_job,
|
||||
trigger=DateTrigger(run_date=schedule.scheduled_time),
|
||||
args=[schedule, platforms],
|
||||
id=job_id,
|
||||
replace_existing=True,
|
||||
misfire_grace_time=self.job_timeout
|
||||
)
|
||||
|
||||
return job_id
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to schedule one-time job: {str(e)}")
|
||||
raise SchedulingError(f"One-time scheduling failed: {str(e)}")
|
||||
|
||||
async def _schedule_recurring(self, schedule: Schedule, platforms: List[str]) -> str:
|
||||
"""Schedule a recurring content publish.
|
||||
|
||||
Args:
|
||||
schedule: Schedule object
|
||||
platforms: List of platforms
|
||||
|
||||
Returns:
|
||||
Job ID
|
||||
"""
|
||||
try:
|
||||
job_id = f"recurring_{schedule.content_item_id}_{int(datetime.utcnow().timestamp())}"
|
||||
|
||||
# Parse recurrence pattern (simplified)
|
||||
if schedule.recurrence == "daily":
|
||||
trigger = CronTrigger(hour=schedule.scheduled_time.hour, minute=schedule.scheduled_time.minute)
|
||||
elif schedule.recurrence == "weekly":
|
||||
trigger = CronTrigger(day_of_week=schedule.scheduled_time.weekday(),
|
||||
hour=schedule.scheduled_time.hour,
|
||||
minute=schedule.scheduled_time.minute)
|
||||
else:
|
||||
# Default to daily
|
||||
trigger = CronTrigger(hour=schedule.scheduled_time.hour, minute=schedule.scheduled_time.minute)
|
||||
|
||||
self.scheduler.add_job(
|
||||
self._run_async_job,
|
||||
trigger=trigger,
|
||||
args=[schedule, platforms],
|
||||
id=job_id,
|
||||
replace_existing=True,
|
||||
misfire_grace_time=self.job_timeout
|
||||
)
|
||||
|
||||
return job_id
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to schedule recurring job: {str(e)}")
|
||||
raise SchedulingError(f"Recurring scheduling failed: {str(e)}")
|
||||
|
||||
async def _run_async_job(self, schedule: Schedule, platforms: List[str]):
|
||||
"""Run an async job in the event loop.
|
||||
|
||||
Args:
|
||||
schedule: Schedule object
|
||||
platforms: List of platforms
|
||||
"""
|
||||
try:
|
||||
await self._publish_content(schedule, platforms)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Job execution failed: {str(e)}")
|
||||
await self._handle_job_failure(schedule, str(e))
|
||||
|
||||
async def _publish_content(self, schedule: Schedule, platforms: List[str]):
|
||||
"""Publish content to specified platforms.
|
||||
|
||||
Args:
|
||||
schedule: Schedule object
|
||||
platforms: List of platforms
|
||||
"""
|
||||
try:
|
||||
session = self.Session()
|
||||
content_item = session.query(ContentItem).get(schedule.content_item_id)
|
||||
|
||||
if not content_item:
|
||||
raise SchedulingError(f"Content item {schedule.content_item_id} not found")
|
||||
|
||||
# Update schedule status
|
||||
schedule.status = ScheduleStatus.RUNNING
|
||||
session.commit()
|
||||
|
||||
# Simulate content publishing (replace with actual platform publishing logic)
|
||||
self.logger.info(f"Publishing content '{content_item.title}' to platforms: {platforms}")
|
||||
|
||||
# Mark as completed
|
||||
schedule.status = ScheduleStatus.COMPLETED
|
||||
schedule.result = f"Published to {', '.join(platforms)} at {datetime.utcnow()}"
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
self.job_stats['successful'] += 1
|
||||
|
||||
except Exception as e:
|
||||
session = self.Session()
|
||||
schedule.status = ScheduleStatus.FAILED
|
||||
schedule.result = f"Failed: {str(e)}"
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
self.job_stats['failed'] += 1
|
||||
raise
|
||||
|
||||
async def _handle_job_failure(self, schedule: Schedule, error: str):
|
||||
"""Handle job failure and retry logic.
|
||||
|
||||
Args:
|
||||
schedule: Schedule object
|
||||
error: Error message
|
||||
"""
|
||||
try:
|
||||
session = self.Session()
|
||||
schedule.status = ScheduleStatus.FAILED
|
||||
schedule.result = f"Failed: {error}"
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
self.job_stats['failed'] += 1
|
||||
self.logger.error(f"Job failed for schedule {schedule.id}: {error}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling job failure: {str(e)}")
|
||||
|
||||
def _handle_job_event(self, event):
|
||||
"""Handle scheduler events.
|
||||
|
||||
Args:
|
||||
event: Scheduler event
|
||||
"""
|
||||
try:
|
||||
job_id = event.job_id
|
||||
|
||||
if event.code == EVENT_JOB_EXECUTED:
|
||||
self.logger.info(f"Job {job_id} executed successfully")
|
||||
|
||||
elif event.code == EVENT_JOB_ERROR:
|
||||
self.logger.error(f"Job {job_id} failed: {str(event.exception)}")
|
||||
|
||||
elif event.code == EVENT_JOB_MISSED:
|
||||
self.logger.warning(f"Job {job_id} missed execution time")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling job event: {str(e)}")
|
||||
|
||||
async def _recover_jobs(self):
|
||||
"""Recover pending jobs from the database."""
|
||||
try:
|
||||
session = self.Session()
|
||||
|
||||
# Get all scheduled jobs
|
||||
pending_schedules = session.query(Schedule).filter(
|
||||
Schedule.status == ScheduleStatus.SCHEDULED
|
||||
).all()
|
||||
|
||||
# Reschedule each job
|
||||
for schedule in pending_schedules:
|
||||
try:
|
||||
content_item = session.query(ContentItem).get(schedule.content_item_id)
|
||||
if content_item:
|
||||
platforms = content_item.platforms if isinstance(content_item.platforms, list) else []
|
||||
await self.schedule_content(content_item, schedule.scheduled_time, platforms,
|
||||
schedule.recurrence, validate=False)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to recover schedule {schedule.id}: {str(e)}")
|
||||
|
||||
session.close()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Job recovery failed: {str(e)}")
|
||||
raise SchedulingError(f"Job recovery failed: {str(e)}")
|
||||
|
||||
def get_job_stats(self) -> Dict[str, int]:
|
||||
"""Get job statistics.
|
||||
|
||||
Returns:
|
||||
Dictionary with job statistics
|
||||
"""
|
||||
return self.job_stats.copy()
|
||||
|
||||
def get_active_jobs(self) -> List[Dict[str, Any]]:
|
||||
"""Get list of active jobs.
|
||||
|
||||
Returns:
|
||||
List of active job information
|
||||
"""
|
||||
try:
|
||||
jobs = []
|
||||
for job in self.scheduler.get_jobs():
|
||||
jobs.append({
|
||||
'id': job.id,
|
||||
'next_run_time': job.next_run_time.isoformat() if job.next_run_time else None,
|
||||
'trigger': str(job.trigger)
|
||||
})
|
||||
return jobs
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting active jobs: {str(e)}")
|
||||
return []
|
||||
651
lib/content_scheduler/integrations/calendar_integration.py
Normal file
651
lib/content_scheduler/integrations/calendar_integration.py
Normal file
@@ -0,0 +1,651 @@
|
||||
"""
|
||||
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 {}
|
||||
112
lib/content_scheduler/models/job.py
Normal file
112
lib/content_scheduler/models/job.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional, List
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass, field
|
||||
from pydantic import BaseModel
|
||||
|
||||
class JobStatus(str, Enum):
|
||||
"""Status of a scheduled job."""
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
MISSED = "missed"
|
||||
|
||||
class JobType(str, Enum):
|
||||
"""Type of scheduled job."""
|
||||
ONE_TIME = "one_time"
|
||||
RECURRING = "recurring"
|
||||
BATCH = "batch"
|
||||
|
||||
class JobPriority(int, Enum):
|
||||
"""Priority of a scheduled job."""
|
||||
LOW = 0
|
||||
MEDIUM = 1
|
||||
HIGH = 2
|
||||
CRITICAL = 3
|
||||
|
||||
@dataclass
|
||||
class JobMetadata:
|
||||
"""Metadata for a scheduled job."""
|
||||
retry_count: int = 0
|
||||
max_retries: int = 3
|
||||
retry_delay: int = 300 # seconds
|
||||
priority: JobPriority = JobPriority.MEDIUM
|
||||
tags: List[str] = field(default_factory=list)
|
||||
custom_data: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
class ScheduledJob(BaseModel):
|
||||
"""Model for a scheduled job."""
|
||||
job_id: str
|
||||
content_id: str
|
||||
schedule_type: JobType
|
||||
status: JobStatus
|
||||
platforms: List[str]
|
||||
publish_date: datetime
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
updated_at: datetime = field(default_factory=datetime.now)
|
||||
cron_expression: Optional[str] = None
|
||||
end_date: Optional[datetime] = None
|
||||
metadata: JobMetadata = field(default_factory=JobMetadata)
|
||||
error: Optional[str] = None
|
||||
last_run: Optional[datetime] = None
|
||||
next_run: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert job to dictionary."""
|
||||
return {
|
||||
'job_id': self.job_id,
|
||||
'content_id': self.content_id,
|
||||
'schedule_type': self.schedule_type,
|
||||
'status': self.status,
|
||||
'platforms': self.platforms,
|
||||
'publish_date': self.publish_date.isoformat(),
|
||||
'created_at': self.created_at.isoformat(),
|
||||
'updated_at': self.updated_at.isoformat(),
|
||||
'cron_expression': self.cron_expression,
|
||||
'end_date': self.end_date.isoformat() if self.end_date else None,
|
||||
'metadata': {
|
||||
'retry_count': self.metadata.retry_count,
|
||||
'max_retries': self.metadata.max_retries,
|
||||
'retry_delay': self.metadata.retry_delay,
|
||||
'priority': self.metadata.priority,
|
||||
'tags': self.metadata.tags,
|
||||
'custom_data': self.metadata.custom_data
|
||||
},
|
||||
'error': self.error,
|
||||
'last_run': self.last_run.isoformat() if self.last_run else None,
|
||||
'next_run': self.next_run.isoformat() if self.next_run else None
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'ScheduledJob':
|
||||
"""Create job from dictionary."""
|
||||
metadata = JobMetadata(
|
||||
retry_count=data['metadata']['retry_count'],
|
||||
max_retries=data['metadata']['max_retries'],
|
||||
retry_delay=data['metadata']['retry_delay'],
|
||||
priority=data['metadata']['priority'],
|
||||
tags=data['metadata']['tags'],
|
||||
custom_data=data['metadata']['custom_data']
|
||||
)
|
||||
|
||||
return cls(
|
||||
job_id=data['job_id'],
|
||||
content_id=data['content_id'],
|
||||
schedule_type=data['schedule_type'],
|
||||
status=data['status'],
|
||||
platforms=data['platforms'],
|
||||
publish_date=datetime.fromisoformat(data['publish_date']),
|
||||
created_at=datetime.fromisoformat(data['created_at']),
|
||||
updated_at=datetime.fromisoformat(data['updated_at']),
|
||||
cron_expression=data.get('cron_expression'),
|
||||
end_date=datetime.fromisoformat(data['end_date']) if data.get('end_date') else None,
|
||||
metadata=metadata,
|
||||
error=data.get('error'),
|
||||
last_run=datetime.fromisoformat(data['last_run']) if data.get('last_run') else None,
|
||||
next_run=datetime.fromisoformat(data['next_run']) if data.get('next_run') else None
|
||||
)
|
||||
15
lib/content_scheduler/models/job_status.py
Normal file
15
lib/content_scheduler/models/job_status.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Job status model for content scheduling.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
class JobStatus(str, Enum):
|
||||
"""Enum representing the status of a scheduled job."""
|
||||
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
RETRYING = "retrying"
|
||||
153
lib/content_scheduler/models/schedule.py
Normal file
153
lib/content_scheduler/models/schedule.py
Normal file
@@ -0,0 +1,153 @@
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional, List
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass, field
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class ScheduleType(str, Enum):
|
||||
"""Type of schedule."""
|
||||
ONE_TIME = "one_time"
|
||||
RECURRING = "recurring"
|
||||
BATCH = "batch"
|
||||
|
||||
class ScheduleStatus(str, Enum):
|
||||
"""Status of a schedule."""
|
||||
ACTIVE = "active"
|
||||
PAUSED = "paused"
|
||||
COMPLETED = "completed"
|
||||
CANCELLED = "cancelled"
|
||||
ERROR = "error"
|
||||
|
||||
@dataclass
|
||||
class ScheduleMetadata:
|
||||
"""Metadata for a schedule."""
|
||||
description: Optional[str] = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
priority: int = 0
|
||||
custom_data: Dict[str, Any] = field(default_factory=dict)
|
||||
notification_settings: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
class Schedule(BaseModel):
|
||||
"""Model representing a content publishing schedule."""
|
||||
|
||||
content_id: str = Field(..., description="ID of the content to be published")
|
||||
content: Dict[str, Any] = Field(..., description="Content to be published")
|
||||
publish_date: datetime = Field(..., description="When to publish the content")
|
||||
platforms: List[str] = Field(..., description="List of platforms to publish to")
|
||||
schedule_type: str = Field(default="one_time", description="Type of schedule ('one_time' or 'recurring')")
|
||||
cron_expression: Optional[str] = Field(None, description="Cron expression for recurring schedules")
|
||||
end_date: Optional[datetime] = Field(None, description="End date for recurring schedules")
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata for the schedule")
|
||||
|
||||
class Config:
|
||||
"""Pydantic model configuration."""
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert schedule to dictionary."""
|
||||
return {
|
||||
'schedule_id': self.schedule_id,
|
||||
'content_id': self.content_id,
|
||||
'schedule_type': self.schedule_type,
|
||||
'status': self.status,
|
||||
'platforms': self.platforms,
|
||||
'publish_date': self.publish_date.isoformat(),
|
||||
'created_at': self.created_at.isoformat(),
|
||||
'updated_at': self.updated_at.isoformat(),
|
||||
'cron_expression': self.cron_expression,
|
||||
'end_date': self.end_date.isoformat() if self.end_date else None,
|
||||
'metadata': {
|
||||
'description': self.metadata.description,
|
||||
'tags': self.metadata.tags,
|
||||
'priority': self.metadata.priority,
|
||||
'custom_data': self.metadata.custom_data,
|
||||
'notification_settings': self.metadata.notification_settings
|
||||
},
|
||||
'error': self.error,
|
||||
'last_run': self.last_run.isoformat() if self.last_run else None,
|
||||
'next_run': self.next_run.isoformat() if self.next_run else None,
|
||||
'job_ids': self.job_ids
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'Schedule':
|
||||
"""Create schedule from dictionary."""
|
||||
metadata = ScheduleMetadata(
|
||||
description=data['metadata'].get('description'),
|
||||
tags=data['metadata'].get('tags', []),
|
||||
priority=data['metadata'].get('priority', 0),
|
||||
custom_data=data['metadata'].get('custom_data', {}),
|
||||
notification_settings=data['metadata'].get('notification_settings', {})
|
||||
)
|
||||
|
||||
return cls(
|
||||
schedule_id=data['schedule_id'],
|
||||
content_id=data['content_id'],
|
||||
schedule_type=data['schedule_type'],
|
||||
status=data['status'],
|
||||
platforms=data['platforms'],
|
||||
publish_date=datetime.fromisoformat(data['publish_date']),
|
||||
created_at=datetime.fromisoformat(data['created_at']),
|
||||
updated_at=datetime.fromisoformat(data['updated_at']),
|
||||
cron_expression=data.get('cron_expression'),
|
||||
end_date=datetime.fromisoformat(data['end_date']) if data.get('end_date') else None,
|
||||
metadata=metadata,
|
||||
error=data.get('error'),
|
||||
last_run=datetime.fromisoformat(data['last_run']) if data.get('last_run') else None,
|
||||
next_run=datetime.fromisoformat(data['next_run']) if data.get('next_run') else None,
|
||||
job_ids=data.get('job_ids', [])
|
||||
)
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""Check if schedule is active."""
|
||||
return self.status == ScheduleStatus.ACTIVE
|
||||
|
||||
def is_completed(self) -> bool:
|
||||
"""Check if schedule is completed."""
|
||||
return self.status == ScheduleStatus.COMPLETED
|
||||
|
||||
def is_cancelled(self) -> bool:
|
||||
"""Check if schedule is cancelled."""
|
||||
return self.status == ScheduleStatus.CANCELLED
|
||||
|
||||
def is_error(self) -> bool:
|
||||
"""Check if schedule has error."""
|
||||
return self.status == ScheduleStatus.ERROR
|
||||
|
||||
def is_recurring(self) -> bool:
|
||||
"""Check if schedule is recurring."""
|
||||
return self.schedule_type == ScheduleType.RECURRING
|
||||
|
||||
def is_one_time(self) -> bool:
|
||||
"""Check if schedule is one-time."""
|
||||
return self.schedule_type == ScheduleType.ONE_TIME
|
||||
|
||||
def is_batch(self) -> bool:
|
||||
"""Check if schedule is batch."""
|
||||
return self.schedule_type == ScheduleType.BATCH
|
||||
|
||||
def add_job_id(self, job_id: str):
|
||||
"""Add a job ID to the schedule."""
|
||||
if job_id not in self.job_ids:
|
||||
self.job_ids.append(job_id)
|
||||
|
||||
def remove_job_id(self, job_id: str):
|
||||
"""Remove a job ID from the schedule."""
|
||||
if job_id in self.job_ids:
|
||||
self.job_ids.remove(job_id)
|
||||
|
||||
def update_status(self, status: ScheduleStatus, error: Optional[str] = None):
|
||||
"""Update schedule status."""
|
||||
self.status = status
|
||||
self.error = error
|
||||
self.updated_at = datetime.now()
|
||||
|
||||
def update_next_run(self, next_run: datetime):
|
||||
"""Update next run time."""
|
||||
self.next_run = next_run
|
||||
self.updated_at = datetime.now()
|
||||
|
||||
def update_last_run(self, last_run: datetime):
|
||||
"""Update last run time."""
|
||||
self.last_run = last_run
|
||||
self.updated_at = datetime.now()
|
||||
75
lib/content_scheduler/models/timeline.py
Normal file
75
lib/content_scheduler/models/timeline.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Timeline models for the Content Scheduler.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
from enum import Enum
|
||||
|
||||
class TimelineViewType(Enum):
|
||||
"""Types of timeline views."""
|
||||
GANTT = "gantt"
|
||||
TIMELINE = "timeline"
|
||||
LIST = "list"
|
||||
|
||||
class TimelineDependencyType(Enum):
|
||||
"""Types of timeline dependencies."""
|
||||
FINISH_TO_START = "finish_to_start"
|
||||
START_TO_START = "start_to_start"
|
||||
FINISH_TO_FINISH = "finish_to_finish"
|
||||
START_TO_FINISH = "start_to_finish"
|
||||
|
||||
@dataclass
|
||||
class TimelineDependency:
|
||||
"""Timeline dependency model."""
|
||||
source_id: str
|
||||
target_id: str
|
||||
dependency_type: TimelineDependencyType
|
||||
lag: Optional[int] = None # Lag time in minutes
|
||||
|
||||
@dataclass
|
||||
class TimelineTask:
|
||||
"""Timeline task model."""
|
||||
id: str
|
||||
title: str
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
platform: str
|
||||
status: str
|
||||
progress: float
|
||||
dependencies: List[TimelineDependency]
|
||||
metadata: Dict[str, Any]
|
||||
|
||||
@dataclass
|
||||
class TimelineMilestone:
|
||||
"""Timeline milestone model."""
|
||||
id: str
|
||||
title: str
|
||||
date: datetime
|
||||
description: Optional[str] = None
|
||||
status: str = "pending"
|
||||
metadata: Dict[str, Any] = None
|
||||
|
||||
@dataclass
|
||||
class TimelineView:
|
||||
"""Timeline view model."""
|
||||
view_type: TimelineViewType
|
||||
start_date: datetime
|
||||
end_date: datetime
|
||||
tasks: List[TimelineTask]
|
||||
milestones: List[TimelineMilestone]
|
||||
dependencies: List[TimelineDependency]
|
||||
metadata: Dict[str, Any]
|
||||
|
||||
@dataclass
|
||||
class TimelineProgress:
|
||||
"""Timeline progress model."""
|
||||
total_tasks: int
|
||||
completed_tasks: int
|
||||
in_progress_tasks: int
|
||||
pending_tasks: int
|
||||
progress_percentage: float
|
||||
by_platform: Dict[str, float]
|
||||
by_date: Dict[str, float]
|
||||
metadata: Dict[str, Any]
|
||||
26
lib/content_scheduler/requirements.txt
Normal file
26
lib/content_scheduler/requirements.txt
Normal file
@@ -0,0 +1,26 @@
|
||||
APScheduler>=3.9.1
|
||||
SQLAlchemy>=1.4.0
|
||||
FastAPI>=0.68.0
|
||||
Streamlit>=1.24.0
|
||||
Pandas>=1.5.0
|
||||
Plotly>=5.13.0
|
||||
python-dateutil>=2.8.2
|
||||
pytz>=2021.3
|
||||
redis>=4.0.0
|
||||
pydantic>=1.8.2
|
||||
python-multipart>=0.0.5
|
||||
aiohttp>=3.8.1
|
||||
asyncio>=3.4.3
|
||||
typing-extensions>=4.0.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
pytest>=6.2.5
|
||||
pytest-asyncio>=0.16.0
|
||||
pytest-cov>=2.12.1
|
||||
black>=21.9b0
|
||||
isort>=5.9.3
|
||||
flake8>=3.9.2
|
||||
mypy>=0.910
|
||||
google-auth-oauthlib>=0.4.6
|
||||
google-auth-httplib2>=0.1.0
|
||||
google-api-python-client>=2.0.0
|
||||
7
lib/content_scheduler/ui/__init__.py
Normal file
7
lib/content_scheduler/ui/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
UI module for the Content Scheduler dashboard.
|
||||
"""
|
||||
|
||||
from .dashboard import run_dashboard
|
||||
|
||||
__all__ = ['run_dashboard']
|
||||
386
lib/content_scheduler/ui/dashboard.py
Normal file
386
lib/content_scheduler/ui/dashboard.py
Normal file
@@ -0,0 +1,386 @@
|
||||
"""
|
||||
Main dashboard implementation for the Content Scheduler.
|
||||
"""
|
||||
|
||||
import streamlit as st
|
||||
import pandas as pd
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Any
|
||||
import plotly.express as px
|
||||
import plotly.graph_objects as go
|
||||
from lib.database.models import ContentItem, Schedule, ScheduleStatus, ContentType, Platform, get_engine, get_session, init_db
|
||||
|
||||
engine = get_engine()
|
||||
init_db(engine)
|
||||
session = get_session(engine)
|
||||
|
||||
def run_dashboard():
|
||||
"""Run the Streamlit dashboard."""
|
||||
|
||||
st.title("📅 Alwrity Content Scheduler Dashboard")
|
||||
|
||||
# Sidebar navigation
|
||||
st.sidebar.title("Navigation")
|
||||
page = st.sidebar.radio(
|
||||
"Go to",
|
||||
["Overview", "Schedule Management", "Create Schedule", "Job Monitor", "Analytics"]
|
||||
)
|
||||
|
||||
if page == "Overview":
|
||||
show_overview()
|
||||
elif page == "Schedule Management":
|
||||
show_schedule_management()
|
||||
elif page == "Create Schedule":
|
||||
show_create_schedule()
|
||||
elif page == "Job Monitor":
|
||||
show_job_monitor()
|
||||
else:
|
||||
show_analytics()
|
||||
|
||||
def show_overview():
|
||||
"""Display the overview dashboard."""
|
||||
st.header("📊 Overview")
|
||||
|
||||
# Get data from unified database
|
||||
all_content = session.query(ContentItem).all()
|
||||
all_schedules = session.query(Schedule).all()
|
||||
|
||||
# Display metrics
|
||||
col1, col2, col3, col4 = st.columns(4)
|
||||
|
||||
with col1:
|
||||
st.metric("Total Content Items", len(all_content))
|
||||
|
||||
with col2:
|
||||
scheduled_count = len([s for s in all_schedules if s.status == ScheduleStatus.SCHEDULED])
|
||||
st.metric("Scheduled Items", scheduled_count)
|
||||
|
||||
with col3:
|
||||
completed_count = len([s for s in all_schedules if s.status == ScheduleStatus.COMPLETED])
|
||||
st.metric("Completed", completed_count)
|
||||
|
||||
with col4:
|
||||
failed_count = len([s for s in all_schedules if s.status == ScheduleStatus.FAILED])
|
||||
st.metric("Failed", failed_count)
|
||||
|
||||
# Recent content
|
||||
st.subheader("📝 Recent Content Items")
|
||||
if all_content:
|
||||
recent_content = sorted(all_content, key=lambda x: x.created_at, reverse=True)[:5]
|
||||
for item in recent_content:
|
||||
with st.expander(f"{item.title} ({item.content_type.value})"):
|
||||
st.write(f"**Description:** {item.description or 'No description'}")
|
||||
st.write(f"**Platforms:** {', '.join(item.platforms) if isinstance(item.platforms, list) else item.platforms}")
|
||||
st.write(f"**Status:** {item.status}")
|
||||
st.write(f"**Created:** {item.created_at}")
|
||||
|
||||
# Show associated schedules
|
||||
item_schedules = [s for s in all_schedules if s.content_item_id == item.id]
|
||||
if item_schedules:
|
||||
st.write("**Schedules:**")
|
||||
for schedule in item_schedules:
|
||||
st.write(f" - {schedule.scheduled_time} ({schedule.status.value})")
|
||||
else:
|
||||
st.info("No content items found. Create some content in the Content Calendar first!")
|
||||
|
||||
def show_schedule_management():
|
||||
"""Display the schedule management interface."""
|
||||
st.header("📅 Schedule Management")
|
||||
|
||||
# Get all schedules
|
||||
all_schedules = session.query(Schedule).all()
|
||||
|
||||
if not all_schedules:
|
||||
st.info("No schedules found. Create schedules from the 'Create Schedule' tab.")
|
||||
return
|
||||
|
||||
# Filter options
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
status_filter = st.selectbox(
|
||||
"Filter by Status",
|
||||
options=["All"] + [status.value for status in ScheduleStatus],
|
||||
key="schedule_status_filter"
|
||||
)
|
||||
|
||||
with col2:
|
||||
date_filter = st.date_input(
|
||||
"Filter by Date (from)",
|
||||
value=datetime.now().date() - timedelta(days=30),
|
||||
key="schedule_date_filter"
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
filtered_schedules = all_schedules
|
||||
if status_filter != "All":
|
||||
filtered_schedules = [s for s in filtered_schedules if s.status.value == status_filter]
|
||||
|
||||
filtered_schedules = [s for s in filtered_schedules if s.scheduled_time.date() >= date_filter]
|
||||
|
||||
# Display schedules
|
||||
st.subheader(f"📋 Schedules ({len(filtered_schedules)} items)")
|
||||
|
||||
for schedule in sorted(filtered_schedules, key=lambda x: x.scheduled_time, reverse=True):
|
||||
content_item = session.query(ContentItem).get(schedule.content_item_id)
|
||||
|
||||
if content_item:
|
||||
with st.expander(f"{content_item.title} - {schedule.scheduled_time.strftime('%Y-%m-%d %H:%M')} ({schedule.status.value})"):
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
with col1:
|
||||
st.write(f"**Content:** {content_item.title}")
|
||||
st.write(f"**Type:** {content_item.content_type.value}")
|
||||
st.write(f"**Platforms:** {', '.join(content_item.platforms) if isinstance(content_item.platforms, list) else content_item.platforms}")
|
||||
st.write(f"**Scheduled Time:** {schedule.scheduled_time}")
|
||||
st.write(f"**Status:** {schedule.status.value}")
|
||||
|
||||
with col2:
|
||||
st.write(f"**Recurrence:** {schedule.recurrence or 'One-time'}")
|
||||
st.write(f"**Priority:** {schedule.priority}")
|
||||
st.write(f"**Created:** {schedule.created_at}")
|
||||
if schedule.result:
|
||||
st.write(f"**Result:** {schedule.result}")
|
||||
|
||||
# Action buttons
|
||||
col1, col2, col3 = st.columns(3)
|
||||
|
||||
with col1:
|
||||
if st.button(f"Edit Schedule", key=f"edit_{schedule.id}"):
|
||||
st.session_state.edit_schedule_id = schedule.id
|
||||
st.rerun()
|
||||
|
||||
with col2:
|
||||
if schedule.status == ScheduleStatus.SCHEDULED:
|
||||
if st.button(f"Cancel", key=f"cancel_{schedule.id}"):
|
||||
schedule.status = ScheduleStatus.CANCELLED
|
||||
session.commit()
|
||||
st.success("Schedule cancelled!")
|
||||
st.rerun()
|
||||
|
||||
with col3:
|
||||
if st.button(f"Delete", key=f"delete_{schedule.id}"):
|
||||
session.delete(schedule)
|
||||
session.commit()
|
||||
st.success("Schedule deleted!")
|
||||
st.rerun()
|
||||
|
||||
def show_create_schedule():
|
||||
"""Display the schedule creation interface."""
|
||||
st.header("➕ Create New Schedule")
|
||||
|
||||
# Get available content items
|
||||
content_items = session.query(ContentItem).all()
|
||||
|
||||
if not content_items:
|
||||
st.warning("No content items available. Please create content in the Content Calendar first.")
|
||||
return
|
||||
|
||||
# Create schedule form
|
||||
with st.form("create_schedule_form"):
|
||||
st.subheader("Schedule Configuration")
|
||||
|
||||
# Select content item
|
||||
content_options = {f"{item.title} ({item.content_type.value})": item.id for item in content_items}
|
||||
selected_content = st.selectbox(
|
||||
"Select Content Item",
|
||||
options=list(content_options.keys()),
|
||||
key="schedule_content_select"
|
||||
)
|
||||
|
||||
# Schedule timing
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
schedule_date = st.date_input(
|
||||
"Schedule Date",
|
||||
value=datetime.now().date() + timedelta(days=1),
|
||||
key="schedule_date"
|
||||
)
|
||||
|
||||
with col2:
|
||||
schedule_time = st.time_input(
|
||||
"Schedule Time",
|
||||
value=datetime.now().time(),
|
||||
key="schedule_time"
|
||||
)
|
||||
|
||||
# Combine date and time
|
||||
schedule_datetime = datetime.combine(schedule_date, schedule_time)
|
||||
|
||||
# Recurrence options
|
||||
recurrence = st.selectbox(
|
||||
"Recurrence",
|
||||
options=["none", "daily", "weekly", "monthly"],
|
||||
key="schedule_recurrence"
|
||||
)
|
||||
|
||||
# Priority
|
||||
priority = st.slider(
|
||||
"Priority",
|
||||
min_value=1,
|
||||
max_value=10,
|
||||
value=5,
|
||||
key="schedule_priority"
|
||||
)
|
||||
|
||||
# Platform selection (override content item platforms if needed)
|
||||
content_item_id = content_options[selected_content]
|
||||
content_item = session.query(ContentItem).get(content_item_id)
|
||||
|
||||
if content_item:
|
||||
current_platforms = content_item.platforms if isinstance(content_item.platforms, list) else [content_item.platforms]
|
||||
st.write(f"**Current Platforms:** {', '.join(current_platforms)}")
|
||||
|
||||
override_platforms = st.checkbox("Override Platforms", key="override_platforms")
|
||||
|
||||
if override_platforms:
|
||||
available_platforms = [p.value for p in Platform]
|
||||
selected_platforms = st.multiselect(
|
||||
"Select Platforms",
|
||||
options=available_platforms,
|
||||
default=current_platforms,
|
||||
key="schedule_platforms"
|
||||
)
|
||||
else:
|
||||
selected_platforms = current_platforms
|
||||
|
||||
# Submit button
|
||||
submitted = st.form_submit_button("Create Schedule")
|
||||
|
||||
if submitted:
|
||||
try:
|
||||
# Create new schedule
|
||||
new_schedule = Schedule(
|
||||
content_item_id=content_item_id,
|
||||
scheduled_time=schedule_datetime,
|
||||
status=ScheduleStatus.SCHEDULED,
|
||||
recurrence=recurrence if recurrence != "none" else None,
|
||||
priority=priority
|
||||
)
|
||||
|
||||
session.add(new_schedule)
|
||||
session.commit()
|
||||
|
||||
st.success(f"✅ Schedule created successfully! Content will be published on {schedule_datetime}")
|
||||
|
||||
# Show schedule details
|
||||
with st.expander("Schedule Details", expanded=True):
|
||||
st.write(f"**Content:** {content_item.title}")
|
||||
st.write(f"**Scheduled Time:** {schedule_datetime}")
|
||||
st.write(f"**Platforms:** {', '.join(selected_platforms)}")
|
||||
st.write(f"**Recurrence:** {recurrence}")
|
||||
st.write(f"**Priority:** {priority}")
|
||||
|
||||
except Exception as e:
|
||||
st.error(f"❌ Error creating schedule: {str(e)}")
|
||||
|
||||
def show_job_monitor():
|
||||
"""Display the job monitoring interface."""
|
||||
st.header("🔍 Job Monitor")
|
||||
|
||||
# Get all schedules with their status
|
||||
all_schedules = session.query(Schedule).all()
|
||||
|
||||
if not all_schedules:
|
||||
st.info("No jobs to monitor.")
|
||||
return
|
||||
|
||||
# Status distribution
|
||||
status_counts = {}
|
||||
for schedule in all_schedules:
|
||||
status = schedule.status.value
|
||||
status_counts[status] = status_counts.get(status, 0) + 1
|
||||
|
||||
# Display status chart
|
||||
if status_counts:
|
||||
fig = px.pie(
|
||||
values=list(status_counts.values()),
|
||||
names=list(status_counts.keys()),
|
||||
title="Job Status Distribution"
|
||||
)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
|
||||
# Recent job activity
|
||||
st.subheader("📊 Recent Job Activity")
|
||||
|
||||
recent_schedules = sorted(all_schedules, key=lambda x: x.updated_at, reverse=True)[:10]
|
||||
|
||||
for schedule in recent_schedules:
|
||||
content_item = session.query(ContentItem).get(schedule.content_item_id)
|
||||
|
||||
if content_item:
|
||||
status_color = {
|
||||
ScheduleStatus.SCHEDULED: "🟡",
|
||||
ScheduleStatus.RUNNING: "🔵",
|
||||
ScheduleStatus.COMPLETED: "🟢",
|
||||
ScheduleStatus.FAILED: "🔴",
|
||||
ScheduleStatus.CANCELLED: "⚫"
|
||||
}.get(schedule.status, "⚪")
|
||||
|
||||
st.write(f"{status_color} **{content_item.title}** - {schedule.status.value} - {schedule.updated_at.strftime('%Y-%m-%d %H:%M')}")
|
||||
|
||||
if schedule.result:
|
||||
st.write(f" └─ {schedule.result}")
|
||||
|
||||
def show_analytics():
|
||||
"""Display the analytics dashboard."""
|
||||
st.header("📈 Analytics")
|
||||
|
||||
# Get data
|
||||
all_content = session.query(ContentItem).all()
|
||||
all_schedules = session.query(Schedule).all()
|
||||
|
||||
if not all_schedules:
|
||||
st.info("No data available for analytics.")
|
||||
return
|
||||
|
||||
# Time-based analytics
|
||||
st.subheader("📅 Schedule Timeline")
|
||||
|
||||
# Create timeline data
|
||||
timeline_data = []
|
||||
for schedule in all_schedules:
|
||||
content_item = session.query(ContentItem).get(schedule.content_item_id)
|
||||
if content_item:
|
||||
timeline_data.append({
|
||||
'Date': schedule.scheduled_time.date(),
|
||||
'Content': content_item.title,
|
||||
'Status': schedule.status.value,
|
||||
'Type': content_item.content_type.value
|
||||
})
|
||||
|
||||
if timeline_data:
|
||||
df = pd.DataFrame(timeline_data)
|
||||
|
||||
# Schedule frequency by date
|
||||
date_counts = df.groupby('Date').size().reset_index(name='Count')
|
||||
fig = px.line(date_counts, x='Date', y='Count', title='Scheduled Content Over Time')
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
|
||||
# Content type distribution
|
||||
type_counts = df['Type'].value_counts()
|
||||
fig = px.bar(x=type_counts.index, y=type_counts.values, title='Content Type Distribution')
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
|
||||
# Status breakdown
|
||||
status_counts = df['Status'].value_counts()
|
||||
fig = px.pie(values=status_counts.values, names=status_counts.index, title='Status Distribution')
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
|
||||
# Performance metrics
|
||||
st.subheader("📊 Performance Metrics")
|
||||
|
||||
col1, col2, col3 = st.columns(3)
|
||||
|
||||
with col1:
|
||||
total_schedules = len(all_schedules)
|
||||
st.metric("Total Schedules", total_schedules)
|
||||
|
||||
with col2:
|
||||
completed_schedules = len([s for s in all_schedules if s.status == ScheduleStatus.COMPLETED])
|
||||
success_rate = (completed_schedules / total_schedules * 100) if total_schedules > 0 else 0
|
||||
st.metric("Success Rate", f"{success_rate:.1f}%")
|
||||
|
||||
with col3:
|
||||
failed_schedules = len([s for s in all_schedules if s.status == ScheduleStatus.FAILED])
|
||||
failure_rate = (failed_schedules / total_schedules * 100) if total_schedules > 0 else 0
|
||||
st.metric("Failure Rate", f"{failure_rate:.1f}%")
|
||||
392
lib/content_scheduler/ui/views/timeline_view.py
Normal file
392
lib/content_scheduler/ui/views/timeline_view.py
Normal file
@@ -0,0 +1,392 @@
|
||||
"""
|
||||
Timeline view implementation for the Content Scheduler.
|
||||
Provides interactive Gantt charts and progress tracking visualization.
|
||||
"""
|
||||
|
||||
import streamlit as st
|
||||
import plotly.figure_factory as ff
|
||||
import plotly.graph_objects as go
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Any, Optional
|
||||
import pandas as pd
|
||||
import json
|
||||
|
||||
# Use unified database models
|
||||
from lib.database.models import ContentItem, Schedule, ScheduleStatus, get_session
|
||||
|
||||
class TimelineView:
|
||||
"""Interactive timeline view with Gantt charts and progress tracking."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the timeline view."""
|
||||
self.session = get_session()
|
||||
|
||||
def render(self):
|
||||
"""Render the timeline view."""
|
||||
st.header("Schedule Timeline")
|
||||
|
||||
# Timeline controls
|
||||
self._render_timeline_controls()
|
||||
|
||||
# Main timeline view
|
||||
self._render_timeline()
|
||||
|
||||
# Progress tracking
|
||||
self._render_progress_tracking()
|
||||
|
||||
def _render_timeline_controls(self):
|
||||
"""Render timeline control options."""
|
||||
col1, col2, col3 = st.columns([2, 2, 1])
|
||||
|
||||
with col1:
|
||||
view_type = st.selectbox(
|
||||
"View Type",
|
||||
["Gantt Chart", "Timeline", "List View"],
|
||||
help="Select the type of timeline visualization"
|
||||
)
|
||||
|
||||
with col2:
|
||||
date_range = st.date_input(
|
||||
"Date Range",
|
||||
value=(
|
||||
datetime.now().date(),
|
||||
datetime.now().date() + timedelta(days=7)
|
||||
),
|
||||
help="Select the date range to display"
|
||||
)
|
||||
|
||||
with col3:
|
||||
if st.button("Export", help="Export timeline data"):
|
||||
self._export_timeline_data()
|
||||
|
||||
def _render_timeline(self):
|
||||
"""Render the main timeline visualization."""
|
||||
# Get schedules for the selected date range
|
||||
schedules = self._get_schedules_for_timeline()
|
||||
|
||||
if not schedules:
|
||||
st.info("No schedules found for the selected date range.")
|
||||
return
|
||||
|
||||
# Create Gantt chart data
|
||||
gantt_data = self._create_gantt_data(schedules)
|
||||
|
||||
# Create and display Gantt chart
|
||||
fig = self._create_gantt_chart(gantt_data)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
|
||||
# Display schedule details
|
||||
self._render_schedule_details(schedules)
|
||||
|
||||
def _render_progress_tracking(self):
|
||||
"""Render progress tracking visualization."""
|
||||
st.subheader("Progress Tracking")
|
||||
|
||||
# Progress metrics
|
||||
col1, col2, col3 = st.columns(3)
|
||||
|
||||
with col1:
|
||||
self._render_progress_metric(
|
||||
"Completed",
|
||||
self._get_completed_count(),
|
||||
"green"
|
||||
)
|
||||
|
||||
with col2:
|
||||
self._render_progress_metric(
|
||||
"In Progress",
|
||||
self._get_in_progress_count(),
|
||||
"orange"
|
||||
)
|
||||
|
||||
with col3:
|
||||
self._render_progress_metric(
|
||||
"Pending",
|
||||
self._get_pending_count(),
|
||||
"blue"
|
||||
)
|
||||
|
||||
# Progress chart
|
||||
self._render_progress_chart()
|
||||
|
||||
def _get_schedules_for_timeline(self) -> List[Schedule]:
|
||||
"""Get schedules for the timeline view."""
|
||||
try:
|
||||
# Get date range from session state or use default
|
||||
if hasattr(st.session_state, 'date_range') and st.session_state.date_range:
|
||||
start_date, end_date = st.session_state.date_range
|
||||
else:
|
||||
start_date = datetime.now().date()
|
||||
end_date = start_date + timedelta(days=7)
|
||||
|
||||
# Convert to datetime
|
||||
start_datetime = datetime.combine(start_date, datetime.min.time())
|
||||
end_datetime = datetime.combine(end_date, datetime.max.time())
|
||||
|
||||
# Query schedules from unified database
|
||||
schedules = self.session.query(Schedule).filter(
|
||||
Schedule.scheduled_time >= start_datetime,
|
||||
Schedule.scheduled_time <= end_datetime
|
||||
).all()
|
||||
|
||||
return schedules
|
||||
|
||||
except Exception as e:
|
||||
st.error(f"Failed to get schedules: {str(e)}")
|
||||
return []
|
||||
|
||||
def _create_gantt_data(self, schedules: List[Schedule]) -> List[Dict[str, Any]]:
|
||||
"""Create data for Gantt chart."""
|
||||
gantt_data = []
|
||||
|
||||
for schedule in schedules:
|
||||
# Get content item details
|
||||
content_item = self.session.query(ContentItem).filter(
|
||||
ContentItem.id == schedule.content_item_id
|
||||
).first()
|
||||
|
||||
if content_item:
|
||||
# Calculate task duration
|
||||
duration = timedelta(hours=1) # Default duration
|
||||
|
||||
# Create task data
|
||||
task = {
|
||||
'Task': content_item.title[:50] + "..." if len(content_item.title) > 50 else content_item.title,
|
||||
'Start': schedule.scheduled_time,
|
||||
'Finish': schedule.scheduled_time + duration,
|
||||
'Resource': schedule.status.value,
|
||||
'Status': schedule.status.value,
|
||||
'Progress': self._calculate_progress(schedule)
|
||||
}
|
||||
|
||||
gantt_data.append(task)
|
||||
|
||||
return gantt_data
|
||||
|
||||
def _create_gantt_chart(self, gantt_data: List[Dict[str, Any]]) -> go.Figure:
|
||||
"""Create Gantt chart visualization."""
|
||||
if not gantt_data:
|
||||
# Return empty figure
|
||||
fig = go.Figure()
|
||||
fig.update_layout(
|
||||
title='Content Schedule Timeline',
|
||||
xaxis_title='Timeline',
|
||||
yaxis_title='Status',
|
||||
height=400
|
||||
)
|
||||
return fig
|
||||
|
||||
# Convert data to DataFrame
|
||||
df = pd.DataFrame(gantt_data)
|
||||
|
||||
# Create Gantt chart
|
||||
fig = ff.create_gantt(
|
||||
df,
|
||||
index_col='Resource',
|
||||
show_colorbar=True,
|
||||
group_tasks=True,
|
||||
showgrid_x=True,
|
||||
showgrid_y=True
|
||||
)
|
||||
|
||||
# Update layout
|
||||
fig.update_layout(
|
||||
title='Content Schedule Timeline',
|
||||
xaxis_title='Timeline',
|
||||
yaxis_title='Status',
|
||||
height=400,
|
||||
showlegend=True
|
||||
)
|
||||
|
||||
return fig
|
||||
|
||||
def _render_schedule_details(self, schedules: List[Schedule]):
|
||||
"""Render detailed schedule information."""
|
||||
st.subheader("Schedule Details")
|
||||
|
||||
for schedule in schedules:
|
||||
# Get content item details
|
||||
content_item = self.session.query(ContentItem).filter(
|
||||
ContentItem.id == schedule.content_item_id
|
||||
).first()
|
||||
|
||||
if content_item:
|
||||
with st.expander(f"{content_item.title} - {schedule.status.value}"):
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
with col1:
|
||||
st.write("**Schedule Information**")
|
||||
st.write(f"Content Type: {content_item.content_type.value if content_item.content_type else 'Unknown'}")
|
||||
st.write(f"Status: {schedule.status.value}")
|
||||
st.write(f"Scheduled Time: {schedule.scheduled_time}")
|
||||
st.write(f"Priority: {schedule.priority}")
|
||||
if schedule.recurrence:
|
||||
st.write(f"Recurrence: {schedule.recurrence}")
|
||||
|
||||
with col2:
|
||||
st.write("**Progress**")
|
||||
progress = self._calculate_progress(schedule)
|
||||
st.progress(progress / 100)
|
||||
st.write(f"Progress: {progress:.1f}%")
|
||||
|
||||
# Action buttons
|
||||
col2a, col2b = st.columns(2)
|
||||
with col2a:
|
||||
if st.button(f"Edit {schedule.id}", key=f"edit_{schedule.id}"):
|
||||
st.session_state.edit_schedule_id = schedule.id
|
||||
with col2b:
|
||||
if st.button(f"Cancel {schedule.id}", key=f"cancel_{schedule.id}"):
|
||||
self._cancel_schedule(schedule.id)
|
||||
|
||||
def _render_progress_metric(self, label: str, value: int, color: str):
|
||||
"""Render a progress metric."""
|
||||
st.metric(label, value)
|
||||
|
||||
def _render_progress_chart(self):
|
||||
"""Render progress chart visualization."""
|
||||
try:
|
||||
# Get progress data
|
||||
progress_data = self._get_progress_data()
|
||||
|
||||
if progress_data:
|
||||
# Create pie chart
|
||||
labels = list(progress_data.keys())
|
||||
values = list(progress_data.values())
|
||||
|
||||
fig = go.Figure(data=[go.Pie(labels=labels, values=values)])
|
||||
fig.update_layout(
|
||||
title="Schedule Status Distribution",
|
||||
height=300
|
||||
)
|
||||
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
else:
|
||||
st.info("No progress data available.")
|
||||
|
||||
except Exception as e:
|
||||
st.error(f"Error rendering progress chart: {str(e)}")
|
||||
|
||||
def _calculate_progress(self, schedule: Schedule) -> float:
|
||||
"""Calculate progress percentage for a schedule."""
|
||||
try:
|
||||
if schedule.status == ScheduleStatus.COMPLETED:
|
||||
return 100.0
|
||||
elif schedule.status == ScheduleStatus.RUNNING:
|
||||
return 50.0
|
||||
elif schedule.status == ScheduleStatus.FAILED:
|
||||
return 0.0
|
||||
else: # PENDING
|
||||
return 0.0
|
||||
|
||||
except Exception as e:
|
||||
st.error(f"Error calculating progress: {str(e)}")
|
||||
return 0.0
|
||||
|
||||
def _get_completed_count(self) -> int:
|
||||
"""Get count of completed schedules."""
|
||||
try:
|
||||
return self.session.query(Schedule).filter(
|
||||
Schedule.status == ScheduleStatus.COMPLETED
|
||||
).count()
|
||||
except Exception as e:
|
||||
st.error(f"Error getting completed count: {str(e)}")
|
||||
return 0
|
||||
|
||||
def _get_in_progress_count(self) -> int:
|
||||
"""Get count of in-progress schedules."""
|
||||
try:
|
||||
return self.session.query(Schedule).filter(
|
||||
Schedule.status == ScheduleStatus.RUNNING
|
||||
).count()
|
||||
except Exception as e:
|
||||
st.error(f"Error getting in-progress count: {str(e)}")
|
||||
return 0
|
||||
|
||||
def _get_pending_count(self) -> int:
|
||||
"""Get count of pending schedules."""
|
||||
try:
|
||||
return self.session.query(Schedule).filter(
|
||||
Schedule.status == ScheduleStatus.PENDING
|
||||
).count()
|
||||
except Exception as e:
|
||||
st.error(f"Error getting pending count: {str(e)}")
|
||||
return 0
|
||||
|
||||
def _get_progress_data(self) -> Dict[str, int]:
|
||||
"""Get progress data for visualization."""
|
||||
try:
|
||||
progress_data = {}
|
||||
|
||||
# Count schedules by status
|
||||
for status in ScheduleStatus:
|
||||
count = self.session.query(Schedule).filter(
|
||||
Schedule.status == status
|
||||
).count()
|
||||
progress_data[status.value] = count
|
||||
|
||||
return progress_data
|
||||
|
||||
except Exception as e:
|
||||
st.error(f"Error getting progress data: {str(e)}")
|
||||
return {}
|
||||
|
||||
def _cancel_schedule(self, schedule_id: int):
|
||||
"""Cancel a schedule."""
|
||||
try:
|
||||
schedule = self.session.query(Schedule).filter(
|
||||
Schedule.id == schedule_id
|
||||
).first()
|
||||
|
||||
if schedule:
|
||||
schedule.status = ScheduleStatus.CANCELLED
|
||||
self.session.commit()
|
||||
st.success(f"Schedule {schedule_id} cancelled successfully!")
|
||||
st.experimental_rerun()
|
||||
else:
|
||||
st.error("Schedule not found.")
|
||||
|
||||
except Exception as e:
|
||||
st.error(f"Error cancelling schedule: {str(e)}")
|
||||
self.session.rollback()
|
||||
|
||||
def _export_timeline_data(self):
|
||||
"""Export timeline data."""
|
||||
try:
|
||||
schedules = self._get_schedules_for_timeline()
|
||||
|
||||
if schedules:
|
||||
# Prepare export data
|
||||
export_data = []
|
||||
|
||||
for schedule in schedules:
|
||||
content_item = self.session.query(ContentItem).filter(
|
||||
ContentItem.id == schedule.content_item_id
|
||||
).first()
|
||||
|
||||
if content_item:
|
||||
export_data.append({
|
||||
'Schedule ID': schedule.id,
|
||||
'Title': content_item.title,
|
||||
'Content Type': content_item.content_type.value if content_item.content_type else 'Unknown',
|
||||
'Scheduled Time': schedule.scheduled_time.isoformat(),
|
||||
'Status': schedule.status.value,
|
||||
'Priority': schedule.priority,
|
||||
'Recurrence': schedule.recurrence or 'None'
|
||||
})
|
||||
|
||||
# Convert to CSV
|
||||
df = pd.DataFrame(export_data)
|
||||
csv = df.to_csv(index=False)
|
||||
|
||||
# Provide download
|
||||
st.download_button(
|
||||
label="Download Timeline Data",
|
||||
data=csv,
|
||||
file_name=f"timeline_data_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
|
||||
mime="text/csv"
|
||||
)
|
||||
else:
|
||||
st.warning("No data to export.")
|
||||
|
||||
except Exception as e:
|
||||
st.error(f"Error exporting data: {str(e)}")
|
||||
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