feat(phase-4): Complete scheduling and automation system
- Add Celery worker with 5 scheduled tasks (Beat)
- Create ContentScheduler for optimal posting times
- Add calendar endpoints for scheduled posts management
- Implement Telegram notification service
- Add notification API with setup guide
Celery Beat Schedule:
- check_scheduled_posts: Every minute
- generate_daily_content: Daily at 6 AM
- sync_interactions: Every 15 minutes
- send_daily_summary: Daily at 9 PM
- cleanup_old_data: Weekly on Sundays
New endpoints:
- GET /api/calendar/posts/scheduled - List scheduled posts
- GET /api/calendar/posts/view - Calendar view
- GET /api/calendar/posts/slots - Available time slots
- POST /api/calendar/posts/{id}/schedule - Schedule post
- POST /api/calendar/posts/{id}/publish-now - Publish immediately
- GET /api/notifications/status - Check notification config
- POST /api/notifications/test - Send test notification
- GET /api/notifications/setup-guide - Configuration guide
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
147
app/services/notifications.py
Normal file
147
app/services/notifications.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Notification service for alerts and summaries.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from typing import Optional
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
async def telegram_notify(
|
||||
message: str,
|
||||
parse_mode: str = "Markdown",
|
||||
disable_notification: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Send a notification via Telegram.
|
||||
|
||||
Args:
|
||||
message: Message text (supports Markdown)
|
||||
parse_mode: "Markdown" or "HTML"
|
||||
disable_notification: If True, send silently
|
||||
|
||||
Returns:
|
||||
True if sent successfully
|
||||
"""
|
||||
if not settings.TELEGRAM_BOT_TOKEN or not settings.TELEGRAM_CHAT_ID:
|
||||
return False
|
||||
|
||||
url = f"https://api.telegram.org/bot{settings.TELEGRAM_BOT_TOKEN}/sendMessage"
|
||||
|
||||
payload = {
|
||||
"chat_id": settings.TELEGRAM_CHAT_ID,
|
||||
"text": message,
|
||||
"parse_mode": parse_mode,
|
||||
"disable_notification": disable_notification
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, json=payload)
|
||||
return response.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def telegram_notify_error(
|
||||
error: str,
|
||||
context: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Send error notification."""
|
||||
message = f"🚨 *Error*\n\n{error}"
|
||||
if context:
|
||||
message += f"\n\n📍 Contexto: {context}"
|
||||
|
||||
return await telegram_notify(message)
|
||||
|
||||
|
||||
async def telegram_notify_success(
|
||||
title: str,
|
||||
details: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Send success notification."""
|
||||
message = f"✅ *{title}*"
|
||||
if details:
|
||||
message += f"\n\n{details}"
|
||||
|
||||
return await telegram_notify(message)
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""Service for managing notifications."""
|
||||
|
||||
def __init__(self):
|
||||
self.telegram_enabled = bool(
|
||||
settings.TELEGRAM_BOT_TOKEN and settings.TELEGRAM_CHAT_ID
|
||||
)
|
||||
|
||||
async def notify_publish(
|
||||
self,
|
||||
platform: str,
|
||||
content: str,
|
||||
url: Optional[str] = None,
|
||||
success: bool = True
|
||||
):
|
||||
"""Notify about a publication."""
|
||||
if not self.telegram_enabled:
|
||||
return
|
||||
|
||||
emoji = "✅" if success else "❌"
|
||||
status = "Publicado" if success else "Error al publicar"
|
||||
|
||||
message = f"{emoji} *{status} en {platform}*\n\n"
|
||||
message += f"📝 {content[:150]}..."
|
||||
|
||||
if url:
|
||||
message += f"\n\n🔗 {url}"
|
||||
|
||||
await telegram_notify(message)
|
||||
|
||||
async def notify_interaction(
|
||||
self,
|
||||
platform: str,
|
||||
author: str,
|
||||
content: str,
|
||||
interaction_type: str = "comentario"
|
||||
):
|
||||
"""Notify about a new interaction."""
|
||||
if not self.telegram_enabled:
|
||||
return
|
||||
|
||||
message = f"💬 *Nueva interacción en {platform}*\n\n"
|
||||
message += f"👤 @{author}\n"
|
||||
message += f"📝 {content[:200]}"
|
||||
|
||||
await telegram_notify(message)
|
||||
|
||||
async def notify_daily_summary(
|
||||
self,
|
||||
published: int,
|
||||
failed: int,
|
||||
scheduled: int,
|
||||
interactions: int
|
||||
):
|
||||
"""Send daily summary."""
|
||||
if not self.telegram_enabled:
|
||||
return
|
||||
|
||||
message = "📊 *Resumen del día*\n\n"
|
||||
message += f"✅ Publicados: {published}\n"
|
||||
if failed > 0:
|
||||
message += f"❌ Fallidos: {failed}\n"
|
||||
message += f"📅 Programados: {scheduled}\n"
|
||||
message += f"💬 Interacciones: {interactions}"
|
||||
|
||||
await telegram_notify(message)
|
||||
|
||||
async def notify_error(self, error: str, context: str = None):
|
||||
"""Send error notification."""
|
||||
if not self.telegram_enabled:
|
||||
return
|
||||
|
||||
await telegram_notify_error(error, context)
|
||||
|
||||
|
||||
# Global instance
|
||||
notification_service = NotificationService()
|
||||
337
app/services/scheduler.py
Normal file
337
app/services/scheduler.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
Content scheduler service.
|
||||
Manages scheduling of posts according to optimal times and calendar.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional, Dict
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.core.database import SessionLocal
|
||||
from app.data.content_templates import OPTIMAL_POSTING_TIMES
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduleSlot:
|
||||
"""A time slot for scheduling."""
|
||||
datetime: datetime
|
||||
platform: str
|
||||
available: bool = True
|
||||
|
||||
|
||||
class ContentScheduler:
|
||||
"""
|
||||
Service for scheduling content according to optimal times.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.posting_times = OPTIMAL_POSTING_TIMES
|
||||
|
||||
def get_next_available_slot(
|
||||
self,
|
||||
platform: str,
|
||||
after: Optional[datetime] = None,
|
||||
min_gap_hours: int = 2
|
||||
) -> datetime:
|
||||
"""
|
||||
Get the next available time slot for a platform.
|
||||
|
||||
Args:
|
||||
platform: Target platform
|
||||
after: Start searching after this time (default: now)
|
||||
min_gap_hours: Minimum hours between posts
|
||||
|
||||
Returns:
|
||||
Next available datetime
|
||||
"""
|
||||
if after is None:
|
||||
after = datetime.utcnow()
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from app.models.post import Post
|
||||
|
||||
# Get platform's optimal times
|
||||
times = self.posting_times.get(platform, self.posting_times["x"])
|
||||
|
||||
# Start from tomorrow if after work hours
|
||||
current = after
|
||||
if current.hour >= 21:
|
||||
current = current.replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
) + timedelta(days=1)
|
||||
|
||||
# Search next 7 days
|
||||
for day_offset in range(7):
|
||||
check_date = current.date() + timedelta(days=day_offset)
|
||||
is_weekend = check_date.weekday() >= 5
|
||||
|
||||
day_times = times["weekend"] if is_weekend else times["weekday"]
|
||||
|
||||
for time_str in day_times:
|
||||
hour, minute = map(int, time_str.split(":"))
|
||||
slot_time = datetime.combine(check_date, datetime.min.time())
|
||||
slot_time = slot_time.replace(hour=hour, minute=minute)
|
||||
|
||||
# Skip past times
|
||||
if slot_time <= after:
|
||||
continue
|
||||
|
||||
# Check if slot is free (no posts within min_gap)
|
||||
window_start = slot_time - timedelta(hours=min_gap_hours)
|
||||
window_end = slot_time + timedelta(hours=min_gap_hours)
|
||||
|
||||
existing = db.query(Post).filter(
|
||||
Post.platforms.contains([platform]),
|
||||
Post.status.in_(["scheduled", "published"]),
|
||||
Post.scheduled_at >= window_start,
|
||||
Post.scheduled_at <= window_end
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
return slot_time
|
||||
|
||||
# Fallback: tomorrow at best time
|
||||
tomorrow = after.date() + timedelta(days=1)
|
||||
best_time = times.get("best", "12:00")
|
||||
hour, minute = map(int, best_time.split(":"))
|
||||
|
||||
return datetime.combine(tomorrow, datetime.min.time()).replace(
|
||||
hour=hour, minute=minute
|
||||
)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def get_available_slots(
|
||||
self,
|
||||
platform: str,
|
||||
start_date: datetime,
|
||||
days: int = 7
|
||||
) -> List[ScheduleSlot]:
|
||||
"""
|
||||
Get all available slots for a platform within a date range.
|
||||
|
||||
Args:
|
||||
platform: Target platform
|
||||
start_date: Start of range
|
||||
days: Number of days to check
|
||||
|
||||
Returns:
|
||||
List of available slots
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from app.models.post import Post
|
||||
|
||||
slots = []
|
||||
times = self.posting_times.get(platform, self.posting_times["x"])
|
||||
|
||||
for day_offset in range(days):
|
||||
check_date = start_date.date() + timedelta(days=day_offset)
|
||||
is_weekend = check_date.weekday() >= 5
|
||||
|
||||
day_times = times["weekend"] if is_weekend else times["weekday"]
|
||||
|
||||
for time_str in day_times:
|
||||
hour, minute = map(int, time_str.split(":"))
|
||||
slot_time = datetime.combine(check_date, datetime.min.time())
|
||||
slot_time = slot_time.replace(hour=hour, minute=minute)
|
||||
|
||||
# Check availability
|
||||
existing = db.query(Post).filter(
|
||||
Post.platforms.contains([platform]),
|
||||
Post.status.in_(["scheduled", "published"]),
|
||||
Post.scheduled_at == slot_time
|
||||
).first()
|
||||
|
||||
slots.append(ScheduleSlot(
|
||||
datetime=slot_time,
|
||||
platform=platform,
|
||||
available=existing is None
|
||||
))
|
||||
|
||||
return slots
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def schedule_post(
|
||||
self,
|
||||
post_id: int,
|
||||
scheduled_at: Optional[datetime] = None,
|
||||
platform: Optional[str] = None
|
||||
) -> datetime:
|
||||
"""
|
||||
Schedule a post for publishing.
|
||||
|
||||
Args:
|
||||
post_id: Post to schedule
|
||||
scheduled_at: Specific time (or auto-select if None)
|
||||
platform: Platform for auto-scheduling
|
||||
|
||||
Returns:
|
||||
Scheduled datetime
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from app.models.post import Post
|
||||
|
||||
post = db.query(Post).filter(Post.id == post_id).first()
|
||||
if not post:
|
||||
raise ValueError(f"Post {post_id} not found")
|
||||
|
||||
# Auto-select time if not provided
|
||||
if scheduled_at is None:
|
||||
target_platform = platform or (post.platforms[0] if post.platforms else "x")
|
||||
scheduled_at = self.get_next_available_slot(target_platform)
|
||||
|
||||
post.scheduled_at = scheduled_at
|
||||
post.status = "scheduled"
|
||||
|
||||
db.commit()
|
||||
|
||||
return scheduled_at
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def reschedule_post(
|
||||
self,
|
||||
post_id: int,
|
||||
new_time: datetime
|
||||
) -> bool:
|
||||
"""Reschedule a post to a new time."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from app.models.post import Post
|
||||
|
||||
post = db.query(Post).filter(Post.id == post_id).first()
|
||||
if not post:
|
||||
return False
|
||||
|
||||
if post.status == "published":
|
||||
return False # Can't reschedule published posts
|
||||
|
||||
post.scheduled_at = new_time
|
||||
post.status = "scheduled"
|
||||
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def cancel_scheduled(self, post_id: int) -> bool:
|
||||
"""Cancel a scheduled post."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from app.models.post import Post
|
||||
|
||||
post = db.query(Post).filter(Post.id == post_id).first()
|
||||
if not post or post.status != "scheduled":
|
||||
return False
|
||||
|
||||
post.status = "draft"
|
||||
post.scheduled_at = None
|
||||
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def get_calendar(
|
||||
self,
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
platforms: Optional[List[str]] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Get calendar view of scheduled posts.
|
||||
|
||||
Returns:
|
||||
Dict with posts grouped by date
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from app.models.post import Post
|
||||
|
||||
query = db.query(Post).filter(
|
||||
Post.scheduled_at >= start_date,
|
||||
Post.scheduled_at <= end_date,
|
||||
Post.status.in_(["scheduled", "pending_approval", "published"])
|
||||
)
|
||||
|
||||
if platforms:
|
||||
# Filter by platforms (any match)
|
||||
query = query.filter(
|
||||
Post.platforms.overlap(platforms)
|
||||
)
|
||||
|
||||
posts = query.order_by(Post.scheduled_at).all()
|
||||
|
||||
# Group by date
|
||||
calendar = {}
|
||||
for post in posts:
|
||||
date_key = post.scheduled_at.strftime("%Y-%m-%d")
|
||||
|
||||
if date_key not in calendar:
|
||||
calendar[date_key] = []
|
||||
|
||||
calendar[date_key].append({
|
||||
"id": post.id,
|
||||
"content": post.content[:100] + "..." if len(post.content) > 100 else post.content,
|
||||
"platforms": post.platforms,
|
||||
"status": post.status,
|
||||
"scheduled_at": post.scheduled_at.isoformat(),
|
||||
"content_type": post.content_type
|
||||
})
|
||||
|
||||
return calendar
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def auto_fill_calendar(
|
||||
self,
|
||||
start_date: datetime,
|
||||
days: int,
|
||||
platforms: List[str],
|
||||
posts_per_day: Dict[str, int] = None
|
||||
) -> List[ScheduleSlot]:
|
||||
"""
|
||||
Get suggested slots to fill the calendar.
|
||||
|
||||
Args:
|
||||
start_date: Start filling from
|
||||
days: Number of days
|
||||
platforms: Platforms to schedule for
|
||||
posts_per_day: Posts per day per platform
|
||||
|
||||
Returns:
|
||||
List of empty slots that need content
|
||||
"""
|
||||
if posts_per_day is None:
|
||||
posts_per_day = {"x": 4, "threads": 3, "instagram": 2, "facebook": 1}
|
||||
|
||||
empty_slots = []
|
||||
|
||||
for platform in platforms:
|
||||
target_count = posts_per_day.get(platform, 2)
|
||||
slots = self.get_available_slots(platform, start_date, days)
|
||||
|
||||
available = [s for s in slots if s.available]
|
||||
|
||||
# Distribute evenly
|
||||
slots_needed = target_count * days
|
||||
step = max(1, len(available) // slots_needed) if available else 1
|
||||
|
||||
for i in range(0, min(len(available), slots_needed), step):
|
||||
empty_slots.append(available[i])
|
||||
|
||||
return sorted(empty_slots, key=lambda s: s.datetime)
|
||||
|
||||
|
||||
# Global instance
|
||||
content_scheduler = ContentScheduler()
|
||||
Reference in New Issue
Block a user