Spaces:
Running
Running
Commit ·
d22dc41
1
Parent(s): be4faf2
Update: Add newsletter subscription endpoints and alert service for daily/weekly email notifications
Browse files- app/config.py +4 -1
- app/routes/admin.py +147 -0
- app/routes/subscription.py +15 -5
- app/services/alert_service.py +150 -0
- app/services/brevo_email_service.py +133 -8
- app/services/firebase_service.py +116 -3
- app/services/newsletter_service.py +292 -0
- app/services/scheduler.py +115 -2
- test_newsletter.py +101 -0
app/config.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 2 |
from pydantic import field_validator
|
| 3 |
-
from typing import List, Union
|
| 4 |
|
| 5 |
class Settings(BaseSettings):
|
| 6 |
"""Application settings"""
|
|
@@ -56,6 +56,9 @@ class Settings(BaseSettings):
|
|
| 56 |
APPWRITE_DATABASE_ID: str = "segmento_db"
|
| 57 |
APPWRITE_COLLECTION_ID: str = "articles"
|
| 58 |
|
|
|
|
|
|
|
|
|
|
| 59 |
@field_validator('CORS_ORIGINS', 'NEWS_PROVIDER_PRIORITY', mode='before')
|
| 60 |
@classmethod
|
| 61 |
def parse_comma_separated(cls, v: Union[str, List[str]]) -> List[str]:
|
|
|
|
| 1 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 2 |
from pydantic import field_validator
|
| 3 |
+
from typing import List, Union, Optional
|
| 4 |
|
| 5 |
class Settings(BaseSettings):
|
| 6 |
"""Application settings"""
|
|
|
|
| 56 |
APPWRITE_DATABASE_ID: str = "segmento_db"
|
| 57 |
APPWRITE_COLLECTION_ID: str = "articles"
|
| 58 |
|
| 59 |
+
# Admin Alerting (Optional - Discord/Slack webhook URL)
|
| 60 |
+
ADMIN_WEBHOOK_URL: Optional[str] = None
|
| 61 |
+
|
| 62 |
@field_validator('CORS_ORIGINS', 'NEWS_PROVIDER_PRIORITY', mode='before')
|
| 63 |
@classmethod
|
| 64 |
def parse_comma_separated(cls, v: Union[str, List[str]]) -> List[str]:
|
app/routes/admin.py
CHANGED
|
@@ -387,3 +387,150 @@ async def get_scheduler_status():
|
|
| 387 |
"success": False,
|
| 388 |
"error": str(e)
|
| 389 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
"success": False,
|
| 388 |
"error": str(e)
|
| 389 |
}
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
# Newsletter Admin Endpoints
|
| 393 |
+
@router.post("/newsletter/send-now")
|
| 394 |
+
async def send_newsletter_now(preference: str = "Weekly"):
|
| 395 |
+
"""
|
| 396 |
+
Manually trigger newsletter for specific preference group
|
| 397 |
+
|
| 398 |
+
Useful for testing before production deployment or sending ad-hoc newsletters.
|
| 399 |
+
|
| 400 |
+
Args:
|
| 401 |
+
preference: Newsletter preference (Morning/Afternoon/Evening/Weekly/Monthly)
|
| 402 |
+
|
| 403 |
+
Returns:
|
| 404 |
+
Send statistics and status
|
| 405 |
+
"""
|
| 406 |
+
try:
|
| 407 |
+
from app.services.scheduler import trigger_newsletter_now
|
| 408 |
+
|
| 409 |
+
# Validate preference
|
| 410 |
+
allowed_preferences = ["Morning", "Afternoon", "Evening", "Weekly", "Monthly"]
|
| 411 |
+
if preference not in allowed_preferences:
|
| 412 |
+
raise HTTPException(
|
| 413 |
+
status_code=400,
|
| 414 |
+
detail=f"Invalid preference. Must be one of: {allowed_preferences}"
|
| 415 |
+
)
|
| 416 |
+
|
| 417 |
+
# Trigger newsletter
|
| 418 |
+
result = await trigger_newsletter_now(preference)
|
| 419 |
+
|
| 420 |
+
return {
|
| 421 |
+
"success": True,
|
| 422 |
+
"preference": preference,
|
| 423 |
+
"timestamp": str(asyncio.get_event_loop().time()),
|
| 424 |
+
**result
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
except Exception as e:
|
| 428 |
+
raise HTTPException(
|
| 429 |
+
status_code=500,
|
| 430 |
+
detail=f"Failed to send newsletter: {str(e)}"
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
@router.get("/subscribers/analytics")
|
| 435 |
+
async def get_subscriber_analytics():
|
| 436 |
+
"""
|
| 437 |
+
Get subscriber distribution by preference
|
| 438 |
+
|
| 439 |
+
Shows how many subscribers have chosen each newsletter timing.
|
| 440 |
+
Useful for understanding user preferences and planning content strategy.
|
| 441 |
+
|
| 442 |
+
Returns:
|
| 443 |
+
Total active subscribers and breakdown by preference
|
| 444 |
+
"""
|
| 445 |
+
try:
|
| 446 |
+
from app.services.firebase_service import get_firebase_service
|
| 447 |
+
|
| 448 |
+
firebase = get_firebase_service()
|
| 449 |
+
|
| 450 |
+
if not firebase.initialized:
|
| 451 |
+
raise HTTPException(
|
| 452 |
+
status_code=503,
|
| 453 |
+
detail="Firebase service not available"
|
| 454 |
+
)
|
| 455 |
+
|
| 456 |
+
all_subscribers = firebase.get_all_subscribers()
|
| 457 |
+
|
| 458 |
+
# Calculate preference distribution
|
| 459 |
+
preference_counts = {
|
| 460 |
+
"Morning": 0,
|
| 461 |
+
"Afternoon": 0,
|
| 462 |
+
"Evening": 0,
|
| 463 |
+
"Weekly": 0,
|
| 464 |
+
"Monthly": 0
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
active_count = 0
|
| 468 |
+
total_count = len(all_subscribers)
|
| 469 |
+
|
| 470 |
+
for sub in all_subscribers:
|
| 471 |
+
if sub.get('subscribed', True):
|
| 472 |
+
active_count += 1
|
| 473 |
+
pref = sub.get('preference', 'Weekly')
|
| 474 |
+
if pref in preference_counts:
|
| 475 |
+
preference_counts[pref] += 1
|
| 476 |
+
|
| 477 |
+
return {
|
| 478 |
+
"total_subscribers": total_count,
|
| 479 |
+
"active_subscribers": active_count,
|
| 480 |
+
"unsubscribed": total_count - active_count,
|
| 481 |
+
"distribution_by_preference": preference_counts,
|
| 482 |
+
" percentage_distribution": {
|
| 483 |
+
pref: round((count / active_count * 100), 2) if active_count > 0 else 0
|
| 484 |
+
for pref, count in preference_counts.items()
|
| 485 |
+
}
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
except HTTPException:
|
| 489 |
+
raise
|
| 490 |
+
except Exception as e:
|
| 491 |
+
raise HTTPException(
|
| 492 |
+
status_code=500,
|
| 493 |
+
detail=f"Failed to get analytics: {str(e)}"
|
| 494 |
+
)
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
@router.get("/newsletter/preview/{preference}")
|
| 498 |
+
async def preview_newsletter_content(preference: str):
|
| 499 |
+
"""
|
| 500 |
+
Preview newsletter content without sending emails
|
| 501 |
+
|
| 502 |
+
Useful for testing and debugging content selection logic.
|
| 503 |
+
Shows what articles would be included in the next newsletter.
|
| 504 |
+
|
| 505 |
+
Args:
|
| 506 |
+
preference: Newsletter preference to preview
|
| 507 |
+
|
| 508 |
+
Returns:
|
| 509 |
+
Article list and metadata
|
| 510 |
+
"""
|
| 511 |
+
try:
|
| 512 |
+
from app.services.newsletter_service import preview_newsletter_content as preview
|
| 513 |
+
|
| 514 |
+
# Validate preference
|
| 515 |
+
allowed_preferences = ["Morning", "Afternoon", "Evening", "Weekly", "Monthly"]
|
| 516 |
+
if preference not in allowed_preferences:
|
| 517 |
+
raise HTTPException(
|
| 518 |
+
status_code=400,
|
| 519 |
+
detail=f"Invalid preference. Must be one of: {allowed_preferences}"
|
| 520 |
+
)
|
| 521 |
+
|
| 522 |
+
result = await preview(preference)
|
| 523 |
+
|
| 524 |
+
return {
|
| 525 |
+
"success": True,
|
| 526 |
+
**result
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
except HTTPException:
|
| 530 |
+
raise
|
| 531 |
+
except Exception as e:
|
| 532 |
+
raise HTTPException(
|
| 533 |
+
status_code=500,
|
| 534 |
+
detail=f"Failed to preview content: {str(e)}"
|
| 535 |
+
)
|
| 536 |
+
|
app/routes/subscription.py
CHANGED
|
@@ -18,6 +18,14 @@ class SubscribeRequest(BaseModel):
|
|
| 18 |
email: EmailStr
|
| 19 |
name: str
|
| 20 |
topics: Optional[List[str]] = ["news", "security", "cloud", "ai"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
class SubscribeResponse(BaseModel):
|
|
@@ -40,6 +48,7 @@ async def subscribe(request: SubscribeRequest):
|
|
| 40 |
- Adds subscriber to Firebase
|
| 41 |
- Sends welcome email via Brevo
|
| 42 |
- Returns subscription token
|
|
|
|
| 43 |
"""
|
| 44 |
try:
|
| 45 |
firebase = get_firebase_service()
|
|
@@ -48,14 +57,15 @@ async def subscribe(request: SubscribeRequest):
|
|
| 48 |
# Generate unique token
|
| 49 |
token = brevo.generate_unsubscribe_token(request.email)
|
| 50 |
|
| 51 |
-
# Add subscriber to Firebase
|
| 52 |
subscriber_data = {
|
| 53 |
"email": request.email,
|
| 54 |
"name": request.name,
|
| 55 |
"subscribed": True,
|
| 56 |
"token": token,
|
| 57 |
"subscribedAt": datetime.now().isoformat(),
|
| 58 |
-
"topics": request.topics
|
|
|
|
| 59 |
}
|
| 60 |
|
| 61 |
success = firebase.add_subscriber(request.email, subscriber_data)
|
|
@@ -66,7 +76,7 @@ async def subscribe(request: SubscribeRequest):
|
|
| 66 |
detail="Failed to save subscriber to database"
|
| 67 |
)
|
| 68 |
|
| 69 |
-
# Send welcome email
|
| 70 |
email_sent = brevo.send_welcome_email(
|
| 71 |
email=request.email,
|
| 72 |
name=request.name,
|
|
@@ -77,13 +87,13 @@ async def subscribe(request: SubscribeRequest):
|
|
| 77 |
# Subscriber added but email failed
|
| 78 |
return SubscribeResponse(
|
| 79 |
success=True,
|
| 80 |
-
message="Subscribed
|
| 81 |
token=token
|
| 82 |
)
|
| 83 |
|
| 84 |
return SubscribeResponse(
|
| 85 |
success=True,
|
| 86 |
-
message="Successfully subscribed! Check your email for confirmation.",
|
| 87 |
token=token
|
| 88 |
)
|
| 89 |
|
|
|
|
| 18 |
email: EmailStr
|
| 19 |
name: str
|
| 20 |
topics: Optional[List[str]] = ["news", "security", "cloud", "ai"]
|
| 21 |
+
preference: str = "Weekly" # Default to Weekly for backward compatibility
|
| 22 |
+
|
| 23 |
+
@validator('preference')
|
| 24 |
+
def validate_preference(cls, v):
|
| 25 |
+
allowed = ["Morning", "Afternoon", "Evening", "Weekly", "Monthly"]
|
| 26 |
+
if v not in allowed:
|
| 27 |
+
raise ValueError(f"Preference must be one of: {allowed}")
|
| 28 |
+
return v
|
| 29 |
|
| 30 |
|
| 31 |
class SubscribeResponse(BaseModel):
|
|
|
|
| 48 |
- Adds subscriber to Firebase
|
| 49 |
- Sends welcome email via Brevo
|
| 50 |
- Returns subscription token
|
| 51 |
+
- Now supports time-based preferences (Morning/Afternoon/Evening/Weekly/Monthly)
|
| 52 |
"""
|
| 53 |
try:
|
| 54 |
firebase = get_firebase_service()
|
|
|
|
| 57 |
# Generate unique token
|
| 58 |
token = brevo.generate_unsubscribe_token(request.email)
|
| 59 |
|
| 60 |
+
# Add subscriber to Firebase with preference
|
| 61 |
subscriber_data = {
|
| 62 |
"email": request.email,
|
| 63 |
"name": request.name,
|
| 64 |
"subscribed": True,
|
| 65 |
"token": token,
|
| 66 |
"subscribedAt": datetime.now().isoformat(),
|
| 67 |
+
"topics": request.topics,
|
| 68 |
+
"preference": request.preference # NEW: Store newsletter preference
|
| 69 |
}
|
| 70 |
|
| 71 |
success = firebase.add_subscriber(request.email, subscriber_data)
|
|
|
|
| 76 |
detail="Failed to save subscriber to database"
|
| 77 |
)
|
| 78 |
|
| 79 |
+
# Send welcome email (could be enhanced to mention preference)
|
| 80 |
email_sent = brevo.send_welcome_email(
|
| 81 |
email=request.email,
|
| 82 |
name=request.name,
|
|
|
|
| 87 |
# Subscriber added but email failed
|
| 88 |
return SubscribeResponse(
|
| 89 |
success=True,
|
| 90 |
+
message=f"Subscribed to {request.preference} newsletter! Check your email for confirmation.",
|
| 91 |
token=token
|
| 92 |
)
|
| 93 |
|
| 94 |
return SubscribeResponse(
|
| 95 |
success=True,
|
| 96 |
+
message=f"Successfully subscribed to {request.preference} newsletter! Check your email for confirmation.",
|
| 97 |
token=token
|
| 98 |
)
|
| 99 |
|
app/services/alert_service.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Admin Alerting Service
|
| 3 |
+
Sends real-time alerts via webhooks (Discord/Slack) for critical failures.
|
| 4 |
+
Converts passive logs into active notifications.
|
| 5 |
+
"""
|
| 6 |
+
from typing import Optional, Dict
|
| 7 |
+
import httpx
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from app.config import settings
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
async def send_admin_alert(
|
| 13 |
+
title: str,
|
| 14 |
+
message: str,
|
| 15 |
+
severity: str = "warning",
|
| 16 |
+
details: Optional[Dict] = None
|
| 17 |
+
) -> bool:
|
| 18 |
+
"""
|
| 19 |
+
Send alert to admin via webhook (Discord/Slack)
|
| 20 |
+
|
| 21 |
+
This converts passive logs into ACTIVE alerts that ping your phone.
|
| 22 |
+
|
| 23 |
+
Args:
|
| 24 |
+
title: Alert title (e.g., "Critical: No Articles Found")
|
| 25 |
+
message: Detailed description
|
| 26 |
+
severity: "info", "warning", "error", "critical"
|
| 27 |
+
details: Optional dict with extra context
|
| 28 |
+
|
| 29 |
+
Returns:
|
| 30 |
+
True if alert sent successfully
|
| 31 |
+
"""
|
| 32 |
+
if not settings.ADMIN_WEBHOOK_URL:
|
| 33 |
+
# No webhook configured, silent fail (keeps logs only)
|
| 34 |
+
return False
|
| 35 |
+
|
| 36 |
+
try:
|
| 37 |
+
# Map severity to colors (Discord embed colors)
|
| 38 |
+
color_map = {
|
| 39 |
+
"info": 3447003, # Blue
|
| 40 |
+
"warning": 16776960, # Yellow
|
| 41 |
+
"error": 16711680, # Red
|
| 42 |
+
"critical": 10038562 # Dark Red
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
# Build timestamp
|
| 46 |
+
timestamp = datetime.now().isoformat()
|
| 47 |
+
|
| 48 |
+
# Format details if provided
|
| 49 |
+
details_text = ""
|
| 50 |
+
if details:
|
| 51 |
+
details_text = "\n**Details:**\n"
|
| 52 |
+
for key, value in details.items():
|
| 53 |
+
details_text += f"• {key}: `{value}`\n"
|
| 54 |
+
|
| 55 |
+
# Discord/Slack webhook payload
|
| 56 |
+
# This format works for both services
|
| 57 |
+
payload = {
|
| 58 |
+
"embeds": [{
|
| 59 |
+
"title": f"🚨 {title}",
|
| 60 |
+
"description": f"{message}{details_text}",
|
| 61 |
+
"color": color_map.get(severity, 16776960),
|
| 62 |
+
"footer": {
|
| 63 |
+
"text": f"SegmentoPulse Newsletter System • {timestamp}"
|
| 64 |
+
},
|
| 65 |
+
"fields": [
|
| 66 |
+
{
|
| 67 |
+
"name": "Severity",
|
| 68 |
+
"value": severity.upper(),
|
| 69 |
+
"inline": True
|
| 70 |
+
}
|
| 71 |
+
]
|
| 72 |
+
}]
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
# Send webhook request (non-blocking, timeout after 5s)
|
| 76 |
+
async with httpx.AsyncClient(timeout=5.0) as client:
|
| 77 |
+
response = await client.post(
|
| 78 |
+
settings.ADMIN_WEBHOOK_URL,
|
| 79 |
+
json=payload
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
if response.status_code in [200, 204]:
|
| 83 |
+
print(f"✅ Admin alert sent via webhook")
|
| 84 |
+
return True
|
| 85 |
+
else:
|
| 86 |
+
print(f"⚠️ Webhook failed with status {response.status_code}")
|
| 87 |
+
return False
|
| 88 |
+
|
| 89 |
+
except httpx.TimeoutException:
|
| 90 |
+
print(f"⚠️ Webhook timeout (5s) - alert not sent")
|
| 91 |
+
return False
|
| 92 |
+
except Exception as e:
|
| 93 |
+
print(f"⚠️ Failed to send webhook alert: {e}")
|
| 94 |
+
return False
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
async def alert_zero_articles(preference: str, timestamp: str) -> None:
|
| 98 |
+
"""Alert: Critical - No articles available for newsletter"""
|
| 99 |
+
await send_admin_alert(
|
| 100 |
+
title="Critical: Zero Articles",
|
| 101 |
+
message=f"No articles found for **{preference}** newsletter!",
|
| 102 |
+
severity="critical",
|
| 103 |
+
details={
|
| 104 |
+
"Preference": preference,
|
| 105 |
+
"Time (IST)": timestamp,
|
| 106 |
+
"Action": "Run /api/admin/scheduler/fetch-now",
|
| 107 |
+
"Possible Cause": "News fetcher failed or rate limited"
|
| 108 |
+
}
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
async def alert_quota_exhausted(
|
| 113 |
+
preference: str,
|
| 114 |
+
sent: int,
|
| 115 |
+
skipped: int,
|
| 116 |
+
remaining: int
|
| 117 |
+
) -> None:
|
| 118 |
+
"""Alert: Warning - Brevo API quota exhausted"""
|
| 119 |
+
await send_admin_alert(
|
| 120 |
+
title="Quota Exhausted",
|
| 121 |
+
message=f"Brevo API limit reached for **{preference}** newsletter",
|
| 122 |
+
severity="error",
|
| 123 |
+
details={
|
| 124 |
+
"Emails Sent": sent,
|
| 125 |
+
"Subscribers Skipped": skipped,
|
| 126 |
+
"Remaining Credits": remaining,
|
| 127 |
+
"Action": "Upgrade Brevo plan or reduce frequency"
|
| 128 |
+
}
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
async def alert_high_failure_rate(
|
| 133 |
+
preference: str,
|
| 134 |
+
sent: int,
|
| 135 |
+
failed: int,
|
| 136 |
+
failure_rate: float
|
| 137 |
+
) -> None:
|
| 138 |
+
"""Alert: Error - High email failure rate"""
|
| 139 |
+
if failure_rate > 0.1: # Alert if >10% failure
|
| 140 |
+
await send_admin_alert(
|
| 141 |
+
title="High Failure Rate",
|
| 142 |
+
message=f"**{failure_rate*100:.1f}%** of emails failed for **{preference}** newsletter",
|
| 143 |
+
severity="error",
|
| 144 |
+
details={
|
| 145 |
+
"Emails Sent": sent,
|
| 146 |
+
"Failed": failed,
|
| 147 |
+
"Failure Rate": f"{failure_rate*100:.1f}%",
|
| 148 |
+
"Action": "Check Brevo dashboard for bounce reasons"
|
| 149 |
+
}
|
| 150 |
+
)
|
app/services/brevo_email_service.py
CHANGED
|
@@ -27,6 +27,79 @@ class BrevoEmailService:
|
|
| 27 |
self.contacts_api = sib_api_v3_sdk.ContactsApi(
|
| 28 |
sib_api_v3_sdk.ApiClient(configuration)
|
| 29 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
def generate_unsubscribe_token(self, email: str) -> str:
|
| 32 |
"""Generate unique token for unsubscribe links"""
|
|
@@ -126,18 +199,61 @@ class BrevoEmailService:
|
|
| 126 |
|
| 127 |
def send_newsletter(
|
| 128 |
self,
|
| 129 |
-
|
|
|
|
|
|
|
| 130 |
articles: List[Dict],
|
| 131 |
-
subscribers: List[Dict]
|
|
|
|
| 132 |
) -> Dict[str, int]:
|
| 133 |
"""
|
| 134 |
-
Send newsletter to
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
"""
|
| 137 |
sent = 0
|
| 138 |
failed = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
if not subscriber.get('subscribed', True):
|
| 142 |
continue
|
| 143 |
|
|
@@ -186,11 +302,11 @@ class BrevoEmailService:
|
|
| 186 |
</head>
|
| 187 |
<body>
|
| 188 |
<div class="header">
|
| 189 |
-
<h1>{
|
| 190 |
</div>
|
| 191 |
<div class="content">
|
| 192 |
<h2>Hi {name},</h2>
|
| 193 |
-
<p>
|
| 194 |
{articles_html}
|
| 195 |
<p style="text-align: center; margin-top: 30px;">
|
| 196 |
<a href="https://segmento.in/pulse" style="background: #667eea; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block;">
|
|
@@ -214,7 +330,16 @@ class BrevoEmailService:
|
|
| 214 |
print(f"Failed to send to {subscriber.get('email')}: {e}")
|
| 215 |
failed += 1
|
| 216 |
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
|
| 219 |
def send_unsubscribe_confirmation(self, email: str, name: str) -> bool:
|
| 220 |
"""Send confirmation email after unsubscribe"""
|
|
|
|
| 27 |
self.contacts_api = sib_api_v3_sdk.ContactsApi(
|
| 28 |
sib_api_v3_sdk.ApiClient(configuration)
|
| 29 |
)
|
| 30 |
+
self.account_api = sib_api_v3_sdk.AccountApi(
|
| 31 |
+
sib_api_v3_sdk.ApiClient(configuration)
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
def get_account_info(self) -> Optional[Dict]:
|
| 35 |
+
"""
|
| 36 |
+
Get Brevo account information including email credits
|
| 37 |
+
|
| 38 |
+
Returns: {
|
| 39 |
+
'email_credits': int, # Remaining email credits
|
| 40 |
+
'plan_type': str,
|
| 41 |
+
'credits_type': str # 'monthly' or 'payAsYouGo'
|
| 42 |
+
}
|
| 43 |
+
"""
|
| 44 |
+
try:
|
| 45 |
+
account = self.account_api.get_account()
|
| 46 |
+
|
| 47 |
+
# Extract email plan info
|
| 48 |
+
email_plan = account.plan[0] if account.plan else None
|
| 49 |
+
|
| 50 |
+
if not email_plan:
|
| 51 |
+
print("⚠️ No email plan found in Brevo account")
|
| 52 |
+
return None
|
| 53 |
+
|
| 54 |
+
return {
|
| 55 |
+
'email_credits': email_plan.credits,
|
| 56 |
+
'plan_type': email_plan.type,
|
| 57 |
+
'credits_type': email_plan.credits_type
|
| 58 |
+
}
|
| 59 |
+
except ApiException as e:
|
| 60 |
+
print(f"Brevo API error getting account info: {e}")
|
| 61 |
+
return None
|
| 62 |
+
except Exception as e:
|
| 63 |
+
print(f"Error getting account info: {e}")
|
| 64 |
+
return None
|
| 65 |
+
|
| 66 |
+
def check_quota(self, required_emails: int) -> Dict[str, any]:
|
| 67 |
+
"""
|
| 68 |
+
Check if there are enough email credits for the send job
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
required_emails: Number of emails we want to send
|
| 72 |
+
|
| 73 |
+
Returns: {
|
| 74 |
+
'sufficient': bool,
|
| 75 |
+
'remaining_credits': int,
|
| 76 |
+
'required': int,
|
| 77 |
+
'shortfall': int # How many we can't send (0 if sufficient)
|
| 78 |
+
}
|
| 79 |
+
"""
|
| 80 |
+
account_info = self.get_account_info()
|
| 81 |
+
|
| 82 |
+
if not account_info:
|
| 83 |
+
# If we can't check quota, assume unlimited (best effort)
|
| 84 |
+
print("⚠️ Could not check Brevo quota, proceeding with send")
|
| 85 |
+
return {
|
| 86 |
+
'sufficient': True,
|
| 87 |
+
'remaining_credits': -1, # Unknown
|
| 88 |
+
'required': required_emails,
|
| 89 |
+
'shortfall': 0
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
remaining = account_info['email_credits']
|
| 93 |
+
sufficient = remaining >= required_emails
|
| 94 |
+
shortfall = max(0, required_emails - remaining)
|
| 95 |
+
|
| 96 |
+
return {
|
| 97 |
+
'sufficient': sufficient,
|
| 98 |
+
'remaining_credits': remaining,
|
| 99 |
+
'required': required_emails,
|
| 100 |
+
'shortfall': shortfall,
|
| 101 |
+
'plan_type': account_info.get('plan_type', 'unknown')
|
| 102 |
+
}
|
| 103 |
|
| 104 |
def generate_unsubscribe_token(self, email: str) -> str:
|
| 105 |
"""Generate unique token for unsubscribe links"""
|
|
|
|
| 199 |
|
| 200 |
def send_newsletter(
|
| 201 |
self,
|
| 202 |
+
preference: str,
|
| 203 |
+
subject: str,
|
| 204 |
+
greeting: str,
|
| 205 |
articles: List[Dict],
|
| 206 |
+
subscribers: List[Dict],
|
| 207 |
+
max_send: Optional[int] = None
|
| 208 |
) -> Dict[str, int]:
|
| 209 |
"""
|
| 210 |
+
Send newsletter to subscribers with QUOTA-AWARE sending
|
| 211 |
+
|
| 212 |
+
Args:
|
| 213 |
+
preference: Newsletter preference (Morning/Afternoon/Evening/Weekly/Monthly)
|
| 214 |
+
subject: Email subject line
|
| 215 |
+
greeting: Personalized greeting text
|
| 216 |
+
articles: List of article dictionaries
|
| 217 |
+
subscribers: List of subscriber dictionaries
|
| 218 |
+
max_send: Optional limit on number of emails (for quota management)
|
| 219 |
+
|
| 220 |
+
Returns: {
|
| 221 |
+
"sent": count,
|
| 222 |
+
"failed": count,
|
| 223 |
+
"quota_limited": bool, # True if we hit quota limits
|
| 224 |
+
"remaining_credits": int # Brevo credits remaining after send
|
| 225 |
+
}
|
| 226 |
"""
|
| 227 |
sent = 0
|
| 228 |
failed = 0
|
| 229 |
+
quota_limited = False
|
| 230 |
+
|
| 231 |
+
# QUOTA CHECK: Determine how many we can actually send
|
| 232 |
+
total_subscribers = len(subscribers)
|
| 233 |
+
quota_status = self.check_quota(total_subscribers)
|
| 234 |
+
|
| 235 |
+
if not quota_status['sufficient']:
|
| 236 |
+
print(f"")
|
| 237 |
+
print(f"{'='*80}")
|
| 238 |
+
print(f"⚠️ QUOTA WARNING: Brevo API Limit Reached!")
|
| 239 |
+
print(f" Requested: {quota_status['required']} emails")
|
| 240 |
+
print(f" Available: {quota_status['remaining_credits']} credits")
|
| 241 |
+
print(f" Shortfall: {quota_status['shortfall']} emails WILL NOT be sent")
|
| 242 |
+
print(f" Plan: {quota_status.get('plan_type', 'unknown')}")
|
| 243 |
+
print(f"{'='*80}")
|
| 244 |
+
print(f"")
|
| 245 |
+
quota_limited = True
|
| 246 |
+
# Limit sending to available quota
|
| 247 |
+
max_send = quota_status['remaining_credits']
|
| 248 |
|
| 249 |
+
# Apply quota limit if set
|
| 250 |
+
subscribers_to_send = subscribers[:max_send] if max_send else subscribers
|
| 251 |
+
|
| 252 |
+
print(f"📧 Sending to {len(subscribers_to_send)} of {total_subscribers} subscribers")
|
| 253 |
+
if quota_limited:
|
| 254 |
+
print(f" ⚠️ {total_subscribers - len(subscribers_to_send)} subscribers SKIPPED due to quota")
|
| 255 |
+
|
| 256 |
+
for subscriber in subscribers_to_send:
|
| 257 |
if not subscriber.get('subscribed', True):
|
| 258 |
continue
|
| 259 |
|
|
|
|
| 302 |
</head>
|
| 303 |
<body>
|
| 304 |
<div class="header">
|
| 305 |
+
<h1>{preference} Newsletter</h1>
|
| 306 |
</div>
|
| 307 |
<div class="content">
|
| 308 |
<h2>Hi {name},</h2>
|
| 309 |
+
<p>{greeting}</p>
|
| 310 |
{articles_html}
|
| 311 |
<p style="text-align: center; margin-top: 30px;">
|
| 312 |
<a href="https://segmento.in/pulse" style="background: #667eea; color: white; padding: 12px 30px; text-decoration: none; border-radius: 5px; display: inline-block;">
|
|
|
|
| 330 |
print(f"Failed to send to {subscriber.get('email')}: {e}")
|
| 331 |
failed += 1
|
| 332 |
|
| 333 |
+
# Get final quota status after sending
|
| 334 |
+
final_quota = self.check_quota(0) # Just to get remaining credits
|
| 335 |
+
|
| 336 |
+
return {
|
| 337 |
+
"sent": sent,
|
| 338 |
+
"failed": failed,
|
| 339 |
+
"quota_limited": quota_limited,
|
| 340 |
+
"remaining_credits": final_quota.get('remaining_credits', -1),
|
| 341 |
+
"skipped_count": total_subscribers - len(subscribers_to_send) if quota_limited else 0
|
| 342 |
+
}
|
| 343 |
|
| 344 |
def send_unsubscribe_confirmation(self, email: str, name: str) -> bool:
|
| 345 |
"""Send confirmation email after unsubscribe"""
|
app/services/firebase_service.py
CHANGED
|
@@ -190,7 +190,7 @@ class FirebaseService:
|
|
| 190 |
return False
|
| 191 |
|
| 192 |
def get_all_subscribers(self) -> list:
|
| 193 |
-
"""Get all subscribers from database"""
|
| 194 |
if not self.initialized:
|
| 195 |
return []
|
| 196 |
|
|
@@ -201,11 +201,124 @@ class FirebaseService:
|
|
| 201 |
if not all_subscribers:
|
| 202 |
return []
|
| 203 |
|
| 204 |
-
# Convert to list
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
except Exception as e:
|
| 207 |
print(f"Error getting all subscribers: {e}")
|
| 208 |
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
|
| 210 |
|
| 211 |
# Singleton instance
|
|
|
|
| 190 |
return False
|
| 191 |
|
| 192 |
def get_all_subscribers(self) -> list:
|
| 193 |
+
"""Get all subscribers from database with migration support"""
|
| 194 |
if not self.initialized:
|
| 195 |
return []
|
| 196 |
|
|
|
|
| 201 |
if not all_subscribers:
|
| 202 |
return []
|
| 203 |
|
| 204 |
+
# Convert to list and add default preference for legacy subscribers
|
| 205 |
+
subscribers = []
|
| 206 |
+
for subscriber_data in all_subscribers.values():
|
| 207 |
+
# Migration: Add default 'Weekly' preference for existing subscribers
|
| 208 |
+
if 'preference' not in subscriber_data:
|
| 209 |
+
subscriber_data['preference'] = 'Weekly'
|
| 210 |
+
subscribers.append(subscriber_data)
|
| 211 |
+
|
| 212 |
+
return subscribers
|
| 213 |
except Exception as e:
|
| 214 |
print(f"Error getting all subscribers: {e}")
|
| 215 |
return []
|
| 216 |
+
|
| 217 |
+
def get_subscribers_by_preference(self, preference: str) -> list:
|
| 218 |
+
"""
|
| 219 |
+
Get all subscribers filtered by newsletter preference (SERVER-SIDE FILTER)
|
| 220 |
+
|
| 221 |
+
PERFORMANCE OPTIMIZATION:
|
| 222 |
+
- OLD: Fetch ALL subscribers → Filter in Python → O(N) memory
|
| 223 |
+
- NEW: Firebase server-side filter → Only returns matches → O(matched) memory
|
| 224 |
+
|
| 225 |
+
FAIRNESS FIX:
|
| 226 |
+
- Sorts by 'lastSentAt' (oldest first) to ensure ROTATION
|
| 227 |
+
- Prevents "unlucky subscriber" problem where last N never get emails
|
| 228 |
+
|
| 229 |
+
This prevents memory issues when subscriber count grows to 10K+
|
| 230 |
+
"""
|
| 231 |
+
if not self.initialized:
|
| 232 |
+
return []
|
| 233 |
+
|
| 234 |
+
try:
|
| 235 |
+
subscribers_ref = db.reference('pulse/subscribers')
|
| 236 |
+
|
| 237 |
+
# SERVER-SIDE FILTER: Only fetch subscribers with matching preference
|
| 238 |
+
# This uses Firebase's indexing to avoid loading all data
|
| 239 |
+
query = subscribers_ref.order_by_child('preference').equal_to(preference)
|
| 240 |
+
filtered_subscribers = query.get()
|
| 241 |
+
|
| 242 |
+
if not filtered_subscribers:
|
| 243 |
+
return []
|
| 244 |
+
|
| 245 |
+
# Convert to list and filter for active subscriptions only
|
| 246 |
+
subscribers = []
|
| 247 |
+
for subscriber_id, subscriber_data in filtered_subscribers.items():
|
| 248 |
+
# Only include active subscribers
|
| 249 |
+
if subscriber_data.get('subscribed', True):
|
| 250 |
+
subscribers.append(subscriber_data)
|
| 251 |
+
|
| 252 |
+
# FAIRNESS FIX: Sort by lastSentAt (oldest first)
|
| 253 |
+
# This ensures subscribers who didn't get email yesterday appear first
|
| 254 |
+
# Prevents quota limiting from always skipping the same users
|
| 255 |
+
subscribers.sort(
|
| 256 |
+
key=lambda x: x.get('lastSentAt', '1970-01-01T00:00:00Z')
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
return subscribers
|
| 260 |
+
|
| 261 |
+
except Exception as e:
|
| 262 |
+
print(f"Error getting subscribers by preference: {e}")
|
| 263 |
+
# FALLBACK: If indexing not set up, use the old method
|
| 264 |
+
print(f"⚠️ Firebase indexing may not be configured for 'preference' field")
|
| 265 |
+
print(f" Falling back to client-side filtering (slower)")
|
| 266 |
+
|
| 267 |
+
try:
|
| 268 |
+
all_subscribers = self.get_all_subscribers()
|
| 269 |
+
filtered = [
|
| 270 |
+
sub for sub in all_subscribers
|
| 271 |
+
if sub.get('preference') == preference and sub.get('subscribed', True)
|
| 272 |
+
]
|
| 273 |
+
# Also sort fallback for fairness
|
| 274 |
+
filtered.sort(
|
| 275 |
+
key=lambda x: x.get('lastSentAt', '1970-01-01T00:00:00Z')
|
| 276 |
+
)
|
| 277 |
+
return filtered
|
| 278 |
+
except Exception as fallback_error:
|
| 279 |
+
print(f"❌ Fallback failed: {fallback_error}")
|
| 280 |
+
return []
|
| 281 |
+
|
| 282 |
+
def update_preference(self, email: str, preference: str) -> bool:
|
| 283 |
+
"""Update subscriber's newsletter preference"""
|
| 284 |
+
if not self.initialized:
|
| 285 |
+
return False
|
| 286 |
+
|
| 287 |
+
try:
|
| 288 |
+
import hashlib
|
| 289 |
+
email_hash = hashlib.sha256(email.encode()).hexdigest()[:16]
|
| 290 |
+
|
| 291 |
+
subscribers_ref = db.reference('pulse/subscribers')
|
| 292 |
+
subscriber_ref = subscribers_ref.child(email_hash)
|
| 293 |
+
|
| 294 |
+
subscriber_ref.update({'preference': preference})
|
| 295 |
+
return True
|
| 296 |
+
except Exception as e:
|
| 297 |
+
print(f"Error updating preference: {e}")
|
| 298 |
+
return False
|
| 299 |
+
|
| 300 |
+
def update_last_sent(self, email: str) -> bool:
|
| 301 |
+
"""Update timestamp of last newsletter sent (UTC)"""
|
| 302 |
+
if not self.initialized:
|
| 303 |
+
return False
|
| 304 |
+
|
| 305 |
+
try:
|
| 306 |
+
import hashlib
|
| 307 |
+
from datetime import datetime, timezone
|
| 308 |
+
|
| 309 |
+
email_hash = hashlib.sha256(email.encode()).hexdigest()[:16]
|
| 310 |
+
|
| 311 |
+
subscribers_ref = db.reference('pulse/subscribers')
|
| 312 |
+
subscriber_ref = subscribers_ref.child(email_hash)
|
| 313 |
+
|
| 314 |
+
# Store in UTC format
|
| 315 |
+
utc_now = datetime.now(timezone.utc).isoformat()
|
| 316 |
+
subscriber_ref.update({'lastSentAt': utc_now})
|
| 317 |
+
|
| 318 |
+
return True
|
| 319 |
+
except Exception as e:
|
| 320 |
+
print(f"Error updating last sent timestamp: {e}")
|
| 321 |
+
return False
|
| 322 |
|
| 323 |
|
| 324 |
# Singleton instance
|
app/services/newsletter_service.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Newsletter Service
|
| 3 |
+
Orchestrates newsletter sending with time-based preferences and smart content selection.
|
| 4 |
+
Handles IST-to-UTC timezone conversion and stale data protection.
|
| 5 |
+
"""
|
| 6 |
+
from typing import List, Dict, Optional
|
| 7 |
+
from datetime import datetime, timedelta
|
| 8 |
+
import pytz
|
| 9 |
+
from app.services.appwrite_db import get_appwrite_db
|
| 10 |
+
from app.services.firebase_service import get_firebase_service
|
| 11 |
+
from app.services.brevo_email_service import get_brevo_service
|
| 12 |
+
from app.services.alert_service import alert_zero_articles, alert_quota_exhausted, alert_high_failure_rate
|
| 13 |
+
from app.config import settings
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# Timezone constants
|
| 17 |
+
IST = pytz.timezone('Asia/Kolkata')
|
| 18 |
+
UTC = pytz.timezone('UTC')
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# Newsletter configuration
|
| 22 |
+
PREFERENCE_CONFIG = {
|
| 23 |
+
"Morning": {
|
| 24 |
+
"hours_back": 12,
|
| 25 |
+
"max_articles": 5,
|
| 26 |
+
"subject": "☀️ Your Morning Tech Brief - SegmentoPulse",
|
| 27 |
+
"greeting": "Good morning! Start your day with the latest tech news:"
|
| 28 |
+
},
|
| 29 |
+
"Afternoon": {
|
| 30 |
+
"hours_back": 6,
|
| 31 |
+
"max_articles": 5,
|
| 32 |
+
"subject": "📰 Midday Tech Update - SegmentoPulse",
|
| 33 |
+
"greeting": "Here's your midday tech update to keep you informed:"
|
| 34 |
+
},
|
| 35 |
+
"Evening": {
|
| 36 |
+
"hours_back": 24,
|
| 37 |
+
"max_articles": 7,
|
| 38 |
+
"subject": "🌙 Evening Tech Digest - SegmentoPulse",
|
| 39 |
+
"greeting": "Wrapping up the day? Catch up on today's top stories:"
|
| 40 |
+
},
|
| 41 |
+
"Weekly": {
|
| 42 |
+
"days_back": 7,
|
| 43 |
+
"max_articles": 15,
|
| 44 |
+
"subject": "📅 Your Weekly Tech Roundup - SegmentoPulse",
|
| 45 |
+
"greeting": "Your curated tech highlights from the past week:"
|
| 46 |
+
},
|
| 47 |
+
"Monthly": {
|
| 48 |
+
"days_back": 30,
|
| 49 |
+
"max_articles": 25,
|
| 50 |
+
"subject": "📊 Monthly Tech Intelligence - SegmentoPulse",
|
| 51 |
+
"greeting": "The most impactful tech stories from this month:"
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
async def get_newsletter_content(preference: str) -> List[Dict]:
|
| 57 |
+
"""
|
| 58 |
+
Fetch articles from Appwrite database with timezone-aware queries.
|
| 59 |
+
|
| 60 |
+
Critical: Converts IST trigger time to UTC for database queries since
|
| 61 |
+
Appwrite stores all timestamps in UTC format.
|
| 62 |
+
|
| 63 |
+
Returns empty list if no articles found (caller must check before sending).
|
| 64 |
+
"""
|
| 65 |
+
if preference not in PREFERENCE_CONFIG:
|
| 66 |
+
print(f"❌ Invalid preference: {preference}")
|
| 67 |
+
return []
|
| 68 |
+
|
| 69 |
+
config = PREFERENCE_CONFIG[preference]
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
# Step 1: Convert IST "now" to UTC for database query
|
| 73 |
+
now_ist = datetime.now(IST)
|
| 74 |
+
now_utc = now_ist.astimezone(UTC)
|
| 75 |
+
|
| 76 |
+
# Step 2: Calculate time range based on preference
|
| 77 |
+
if "hours_back" in config:
|
| 78 |
+
time_cutoff = now_utc - timedelta(hours=config["hours_back"])
|
| 79 |
+
else: # days_back for Weekly/Monthly
|
| 80 |
+
time_cutoff = now_utc - timedelta(days=config["days_back"])
|
| 81 |
+
|
| 82 |
+
print(f"🔍 Fetching {preference} newsletter articles...")
|
| 83 |
+
print(f" Time range: {time_cutoff.isoformat()} to {now_utc.isoformat()} (UTC)")
|
| 84 |
+
|
| 85 |
+
# Step 3: Query Appwrite database
|
| 86 |
+
appwrite_db = get_appwrite_db()
|
| 87 |
+
|
| 88 |
+
if not appwrite_db.initialized:
|
| 89 |
+
print("⚠️ Appwrite database not initialized")
|
| 90 |
+
return []
|
| 91 |
+
|
| 92 |
+
# Fetch all articles (Appwrite stores them with UTC timestamps)
|
| 93 |
+
all_articles = await appwrite_db.get_all_articles()
|
| 94 |
+
|
| 95 |
+
if not all_articles:
|
| 96 |
+
print("⚠️ No articles found in Appwrite database")
|
| 97 |
+
return []
|
| 98 |
+
|
| 99 |
+
# Step 4: Filter by time range and sort by recency
|
| 100 |
+
filtered_articles = []
|
| 101 |
+
for article in all_articles:
|
| 102 |
+
published_at_str = article.get('publishedAt')
|
| 103 |
+
if not published_at_str:
|
| 104 |
+
continue
|
| 105 |
+
|
| 106 |
+
try:
|
| 107 |
+
# Parse UTC timestamp from database
|
| 108 |
+
published_at = datetime.fromisoformat(
|
| 109 |
+
published_at_str.replace('Z', '+00:00')
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
# Convert to UTC-aware datetime if not already
|
| 113 |
+
if published_at.tzinfo is None:
|
| 114 |
+
published_at = UTC.localize(published_at)
|
| 115 |
+
else:
|
| 116 |
+
published_at = published_at.astimezone(UTC)
|
| 117 |
+
|
| 118 |
+
# Check if within time range
|
| 119 |
+
if published_at >= time_cutoff:
|
| 120 |
+
filtered_articles.append(article)
|
| 121 |
+
|
| 122 |
+
except (ValueError, AttributeError) as e:
|
| 123 |
+
print(f"⚠️ Error parsing date for article: {e}")
|
| 124 |
+
continue
|
| 125 |
+
|
| 126 |
+
# Step 5: Sort by date (most recent first) and limit
|
| 127 |
+
filtered_articles.sort(
|
| 128 |
+
key=lambda x: x.get('publishedAt', ''),
|
| 129 |
+
reverse=True
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
limited_articles = filtered_articles[:config["max_articles"]]
|
| 133 |
+
|
| 134 |
+
print(f"✅ Found {len(filtered_articles)} articles, returning top {len(limited_articles)}")
|
| 135 |
+
|
| 136 |
+
return limited_articles
|
| 137 |
+
|
| 138 |
+
except Exception as e:
|
| 139 |
+
print(f"❌ Error fetching newsletter content: {e}")
|
| 140 |
+
import traceback
|
| 141 |
+
traceback.print_exc()
|
| 142 |
+
return []
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
async def send_scheduled_newsletter(preference: str) -> Dict[str, int]:
|
| 146 |
+
"""
|
| 147 |
+
Main newsletter orchestrator.
|
| 148 |
+
|
| 149 |
+
CRITICAL SAFETY CHECKS:
|
| 150 |
+
1. Validates preference parameter
|
| 151 |
+
2. Fetches content from Appwrite (with timezone conversion)
|
| 152 |
+
3. SKIPS sending if no articles found (stale data protection)
|
| 153 |
+
4. Gets subscribers for this preference
|
| 154 |
+
5. Sends emails via Brevo API
|
| 155 |
+
|
| 156 |
+
Returns: {"sent": int, "failed": int, "skipped": Optional[str]}
|
| 157 |
+
"""
|
| 158 |
+
print(f"\n{'='*80}")
|
| 159 |
+
print(f"📧 NEWSLETTER SEND TRIGGER: {preference}")
|
| 160 |
+
print(f"⏰ Trigger Time: {datetime.now(IST).strftime('%Y-%m-%d %H:%M:%S %Z')}")
|
| 161 |
+
print(f"{'='*80}\n")
|
| 162 |
+
|
| 163 |
+
# Validation
|
| 164 |
+
if preference not in PREFERENCE_CONFIG:
|
| 165 |
+
print(f"❌ Invalid preference: {preference}")
|
| 166 |
+
return {"sent": 0, "failed": 0, "skipped": "invalid_preference"}
|
| 167 |
+
|
| 168 |
+
# SAFETY CHECK #1: Fetch articles with timezone conversion
|
| 169 |
+
articles = await get_newsletter_content(preference)
|
| 170 |
+
|
| 171 |
+
if not articles or len(articles) == 0:
|
| 172 |
+
# CRITICAL ALERT: Zero articles found
|
| 173 |
+
error_msg = f"CRITICAL ALERT: No articles for {preference} newsletter!"
|
| 174 |
+
timestamp_ist = datetime.now(IST).strftime('%Y-%m-%d %H:%M:%S %Z')
|
| 175 |
+
|
| 176 |
+
print(f"")
|
| 177 |
+
print(f"{'!'*80}")
|
| 178 |
+
print(f"⚠️ {error_msg}")
|
| 179 |
+
print(f" Preference: {preference}")
|
| 180 |
+
print(f" Time: {timestamp_ist}")
|
| 181 |
+
print(f" Possible causes:")
|
| 182 |
+
print(f" 1. News fetcher hasn't run yet")
|
| 183 |
+
print(f" 2. All APIs hit rate limits (429 errors)")
|
| 184 |
+
print(f" 3. No articles match the time window")
|
| 185 |
+
print(f" 4. Appwrite database is empty")
|
| 186 |
+
print(f" ")
|
| 187 |
+
print(f" ACTION REQUIRED: Check /api/admin/scheduler/fetch-now")
|
| 188 |
+
print(f"{'!'*80}")
|
| 189 |
+
print(f"")
|
| 190 |
+
|
| 191 |
+
# ACTIVE ALERT: Send webhook to admin (Discord/Slack)
|
| 192 |
+
await alert_zero_articles(preference, timestamp_ist)
|
| 193 |
+
|
| 194 |
+
return {"sent": 0, "failed": 0, "skipped": "no_articles", "alert": True}
|
| 195 |
+
|
| 196 |
+
# SAFETY CHECK #2: Get subscribers for this preference
|
| 197 |
+
firebase = get_firebase_service()
|
| 198 |
+
subscribers = firebase.get_subscribers_by_preference(preference)
|
| 199 |
+
|
| 200 |
+
if not subscribers or len(subscribers) == 0:
|
| 201 |
+
print(f"ℹ️ SKIP: No active subscribers for {preference} preference.")
|
| 202 |
+
print(f"\n{'='*80}\n")
|
| 203 |
+
return {"sent": 0, "failed": 0, "skipped": "no_subscribers"}
|
| 204 |
+
|
| 205 |
+
print(f"👥 Found {len(subscribers)} active subscribers")
|
| 206 |
+
print(f"📰 Sending {len(articles)} curated articles")
|
| 207 |
+
|
| 208 |
+
# Send newsletter via Brevo
|
| 209 |
+
config = PREFERENCE_CONFIG[preference]
|
| 210 |
+
brevo = get_brevo_service()
|
| 211 |
+
|
| 212 |
+
result = brevo.send_newsletter(
|
| 213 |
+
preference=preference,
|
| 214 |
+
subject=config["subject"],
|
| 215 |
+
greeting=config["greeting"],
|
| 216 |
+
articles=articles,
|
| 217 |
+
subscribers=subscribers
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
# Check for quota issues and alert if needed
|
| 221 |
+
if result.get('quota_limited', False):
|
| 222 |
+
print(f"")
|
| 223 |
+
print(f"{'!'*80}")
|
| 224 |
+
print(f"⚠️ QUOTA ALERT: Brevo API limit reached!")
|
| 225 |
+
print(f" Sent: {result['sent']}")
|
| 226 |
+
print(f" Skipped: {result.get('skipped_count', 0)}")
|
| 227 |
+
print(f" Remaining credits: {result.get('remaining_credits', 'unknown')}")
|
| 228 |
+
print(f" ")
|
| 229 |
+
print(f" ACTION REQUIRED: Upgrade Brevo plan or reduce frequency")
|
| 230 |
+
print(f"{'!'*80}")
|
| 231 |
+
print(f"")
|
| 232 |
+
|
| 233 |
+
# ACTIVE ALERT: Send webhook to admin
|
| 234 |
+
await alert_quota_exhausted(
|
| 235 |
+
preference=preference,
|
| 236 |
+
sent=result['sent'],
|
| 237 |
+
skipped=result.get('skipped_count', 0),
|
| 238 |
+
remaining=result.get('remaining_credits', 0)
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
# Check for high failure rate and alert
|
| 242 |
+
total_attempted = result.get('sent', 0) + result.get('failed', 0)
|
| 243 |
+
if total_attempted > 0:
|
| 244 |
+
failure_rate = result.get('failed', 0) / total_attempted
|
| 245 |
+
await alert_high_failure_rate(
|
| 246 |
+
preference=preference,
|
| 247 |
+
sent=result.get('sent', 0),
|
| 248 |
+
failed=result.get('failed', 0),
|
| 249 |
+
failure_rate=failure_rate
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
print(f"\n✅ Newsletter send complete!")
|
| 253 |
+
print(f" Sent: {result.get('sent', 0)}")
|
| 254 |
+
print(f" Failed: {result.get('failed', 0)}")
|
| 255 |
+
if result.get('quota_limited'):
|
| 256 |
+
print(f" ⚠️ Quota Limited: {result.get('skipped_count', 0)} skipped")
|
| 257 |
+
print(f" Remaining credits: {result.get('remaining_credits', 'N/A')}")
|
| 258 |
+
print(f"{'='*80}\n")
|
| 259 |
+
|
| 260 |
+
# Update last sent timestamp for all SENT subscribers only
|
| 261 |
+
sent_count = result.get('sent', 0)
|
| 262 |
+
if sent_count > 0:
|
| 263 |
+
for i, subscriber in enumerate(subscribers[:sent_count]):
|
| 264 |
+
email = subscriber.get('email')
|
| 265 |
+
if email:
|
| 266 |
+
firebase.update_last_sent(email)
|
| 267 |
+
|
| 268 |
+
return result
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def get_subscribers_by_preference(preference: str) -> List[Dict]:
|
| 272 |
+
"""
|
| 273 |
+
Helper function to get subscribers for a specific preference.
|
| 274 |
+
Used by admin endpoints and testing.
|
| 275 |
+
"""
|
| 276 |
+
firebase = get_firebase_service()
|
| 277 |
+
return firebase.get_subscribers_by_preference(preference)
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
async def preview_newsletter_content(preference: str) -> Dict:
|
| 281 |
+
"""
|
| 282 |
+
Preview newsletter content without sending emails.
|
| 283 |
+
Useful for testing and debugging.
|
| 284 |
+
"""
|
| 285 |
+
articles = await get_newsletter_content(preference)
|
| 286 |
+
|
| 287 |
+
return {
|
| 288 |
+
"preference": preference,
|
| 289 |
+
"article_count": len(articles),
|
| 290 |
+
"articles": articles,
|
| 291 |
+
"config": PREFERENCE_CONFIG.get(preference, {})
|
| 292 |
+
}
|
app/services/scheduler.py
CHANGED
|
@@ -8,6 +8,7 @@ from apscheduler.triggers.interval import IntervalTrigger
|
|
| 8 |
from apscheduler.triggers.cron import CronTrigger
|
| 9 |
from datetime import datetime, timedelta
|
| 10 |
import logging
|
|
|
|
| 11 |
|
| 12 |
from app.services.news_aggregator import NewsAggregator
|
| 13 |
from app.services.appwrite_db import get_appwrite_db
|
|
@@ -373,6 +374,106 @@ def start_scheduler():
|
|
| 373 |
logger.info(" 📋 Task: Delete articles older than 48 hours (up to 500 per run)")
|
| 374 |
logger.info(" 🔢 Total cleanup capacity: 6,000 articles/day (12 runs × 500)")
|
| 375 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
# Start the scheduler
|
| 377 |
logger.info("")
|
| 378 |
logger.info("🚀 Starting scheduler engine...")
|
|
@@ -412,7 +513,19 @@ async def trigger_fetch_now():
|
|
| 412 |
async def trigger_cleanup_now():
|
| 413 |
"""Manually trigger cleanup (for testing)"""
|
| 414 |
logger.info("")
|
| 415 |
-
logger.info("
|
| 416 |
logger.info("🔧 [MANUAL TRIGGER] Running cleanup job NOW...")
|
| 417 |
-
logger.info("
|
| 418 |
await cleanup_old_news()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
from apscheduler.triggers.cron import CronTrigger
|
| 9 |
from datetime import datetime, timedelta
|
| 10 |
import logging
|
| 11 |
+
import pytz
|
| 12 |
|
| 13 |
from app.services.news_aggregator import NewsAggregator
|
| 14 |
from app.services.appwrite_db import get_appwrite_db
|
|
|
|
| 374 |
logger.info(" 📋 Task: Delete articles older than 48 hours (up to 500 per run)")
|
| 375 |
logger.info(" 🔢 Total cleanup capacity: 6,000 articles/day (12 runs × 500)")
|
| 376 |
|
| 377 |
+
# Import newsletter service (lazy import to avoid circular dependencies)
|
| 378 |
+
from app.services.newsletter_service import send_scheduled_newsletter
|
| 379 |
+
|
| 380 |
+
# IST timezone for newsletter scheduling
|
| 381 |
+
IST = pytz.timezone('Asia/Kolkata')
|
| 382 |
+
|
| 383 |
+
# Job 3: Morning Newsletter - 7:00 AM IST, Monday-Saturday
|
| 384 |
+
scheduler.add_job(
|
| 385 |
+
send_scheduled_newsletter,
|
| 386 |
+
trigger=CronTrigger(
|
| 387 |
+
hour=7, minute=0,
|
| 388 |
+
day_of_week='mon-sat',
|
| 389 |
+
timezone=IST
|
| 390 |
+
),
|
| 391 |
+
args=["Morning"],
|
| 392 |
+
id='newsletter_morning',
|
| 393 |
+
name='Morning Newsletter (7 AM IST)',
|
| 394 |
+
replace_existing=True,
|
| 395 |
+
max_instances=1
|
| 396 |
+
)
|
| 397 |
+
logger.info("")
|
| 398 |
+
logger.info("✅ Job #3 Registered: ☀️ Morning Newsletter")
|
| 399 |
+
logger.info(" ⏱️ Schedule: 7:00 AM IST, Monday-Saturday")
|
| 400 |
+
logger.info(" 📋 Task: Send curated news to Morning preference subscribers")
|
| 401 |
+
|
| 402 |
+
# Job 4: Afternoon Newsletter - 2:00 PM IST, Monday-Friday
|
| 403 |
+
scheduler.add_job(
|
| 404 |
+
send_scheduled_newsletter,
|
| 405 |
+
trigger=CronTrigger(
|
| 406 |
+
hour=14, minute=0,
|
| 407 |
+
day_of_week='mon-fri',
|
| 408 |
+
timezone=IST
|
| 409 |
+
),
|
| 410 |
+
args=["Afternoon"],
|
| 411 |
+
id='newsletter_afternoon',
|
| 412 |
+
name='Afternoon Newsletter (2 PM IST)',
|
| 413 |
+
replace_existing=True,
|
| 414 |
+
max_instances=1
|
| 415 |
+
)
|
| 416 |
+
logger.info("")
|
| 417 |
+
logger.info("✅ Job #4 Registered: 📰 Afternoon Newsletter")
|
| 418 |
+
logger.info(" ⏱️ Schedule: 2:00 PM IST, Monday-Friday")
|
| 419 |
+
logger.info(" 📋 Task: Send midday update to Afternoon preference subscribers")
|
| 420 |
+
|
| 421 |
+
# Job 5: Evening Newsletter - 7:00 PM IST, Daily
|
| 422 |
+
scheduler.add_job(
|
| 423 |
+
send_scheduled_newsletter,
|
| 424 |
+
trigger=CronTrigger(
|
| 425 |
+
hour=19, minute=0,
|
| 426 |
+
timezone=IST
|
| 427 |
+
),
|
| 428 |
+
args=["Evening"],
|
| 429 |
+
id='newsletter_evening',
|
| 430 |
+
name='Evening Newsletter (7 PM IST)',
|
| 431 |
+
replace_existing=True,
|
| 432 |
+
max_instances=1
|
| 433 |
+
)
|
| 434 |
+
logger.info("")
|
| 435 |
+
logger.info("✅ Job #5 Registered: 🌙 Evening Newsletter")
|
| 436 |
+
logger.info(" ⏱️ Schedule: 7:00 PM IST, Daily")
|
| 437 |
+
logger.info(" 📋 Task: Send daily digest to Evening preference subscribers")
|
| 438 |
+
|
| 439 |
+
# Job 6: Weekly Newsletter - Sunday 9:00 AM IST
|
| 440 |
+
scheduler.add_job(
|
| 441 |
+
send_scheduled_newsletter,
|
| 442 |
+
trigger=CronTrigger(
|
| 443 |
+
hour=9, minute=0,
|
| 444 |
+
day_of_week='sun',
|
| 445 |
+
timezone=IST
|
| 446 |
+
),
|
| 447 |
+
args=["Weekly"],
|
| 448 |
+
id='newsletter_weekly',
|
| 449 |
+
name='Weekly Newsletter (Sunday 9 AM IST)',
|
| 450 |
+
replace_existing=True,
|
| 451 |
+
max_instances=1
|
| 452 |
+
)
|
| 453 |
+
logger.info("")
|
| 454 |
+
logger.info("✅ Job #6 Registered: 📅 Weekly Newsletter")
|
| 455 |
+
logger.info(" ⏱️ Schedule: Sunday 9:00 AM IST")
|
| 456 |
+
logger.info(" 📋 Task: Send weekly roundup to Weekly preference subscribers")
|
| 457 |
+
|
| 458 |
+
# Job 7: Monthly Newsletter - 1st of month, 9:00 AM IST
|
| 459 |
+
scheduler.add_job(
|
| 460 |
+
send_scheduled_newsletter,
|
| 461 |
+
trigger=CronTrigger(
|
| 462 |
+
hour=9, minute=0,
|
| 463 |
+
day=1,
|
| 464 |
+
timezone=IST
|
| 465 |
+
),
|
| 466 |
+
args=["Monthly"],
|
| 467 |
+
id='newsletter_monthly',
|
| 468 |
+
name='Monthly Newsletter (1st, 9 AM IST)',
|
| 469 |
+
replace_existing=True,
|
| 470 |
+
max_instances=1
|
| 471 |
+
)
|
| 472 |
+
logger.info("")
|
| 473 |
+
logger.info("✅ Job #7 Registered: 📊 Monthly Newsletter")
|
| 474 |
+
logger.info(" ⏱️ Schedule: 1st of month, 9:00 AM IST")
|
| 475 |
+
logger.info(" 📋 Task: Send monthly intelligence to Monthly preference subscribers")
|
| 476 |
+
|
| 477 |
# Start the scheduler
|
| 478 |
logger.info("")
|
| 479 |
logger.info("🚀 Starting scheduler engine...")
|
|
|
|
| 513 |
async def trigger_cleanup_now():
|
| 514 |
"""Manually trigger cleanup (for testing)"""
|
| 515 |
logger.info("")
|
| 516 |
+
logger.info("=" * 80)
|
| 517 |
logger.info("🔧 [MANUAL TRIGGER] Running cleanup job NOW...")
|
| 518 |
+
logger.info("=" * 80)
|
| 519 |
await cleanup_old_news()
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
async def trigger_newsletter_now(preference: str):
|
| 523 |
+
"""Manually trigger newsletter for specific preference (for testing)"""
|
| 524 |
+
from app.services.newsletter_service import send_scheduled_newsletter
|
| 525 |
+
|
| 526 |
+
logger.info("")
|
| 527 |
+
logger.info("=" * 80)
|
| 528 |
+
logger.info(f"🔧 [MANUAL TRIGGER] Running {preference} newsletter job NOW...")
|
| 529 |
+
logger.info("=" * 80)
|
| 530 |
+
result = await send_scheduled_newsletter(preference)
|
| 531 |
+
return result
|
test_newsletter.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Newsletter Test Script
|
| 3 |
+
Tests the newsletter service functionality without sending emails
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import sys
|
| 8 |
+
import os
|
| 9 |
+
|
| 10 |
+
# Add parent directory to path
|
| 11 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 12 |
+
|
| 13 |
+
async def main():
|
| 14 |
+
print("=" * 80)
|
| 15 |
+
print("🧪 NEWSLETTER SERVICE TEST")
|
| 16 |
+
print("=" * 80)
|
| 17 |
+
print("")
|
| 18 |
+
|
| 19 |
+
# Test 1: Preview newsletter content
|
| 20 |
+
print("📋 Test 1: Preview Newsletter Content")
|
| 21 |
+
print("-" * 80)
|
| 22 |
+
|
| 23 |
+
from app.services.newsletter_service import preview_newsletter_content
|
| 24 |
+
|
| 25 |
+
preferences = ["Morning", "Afternoon", "Evening", "Weekly", "Monthly"]
|
| 26 |
+
|
| 27 |
+
for preference in preferences:
|
| 28 |
+
print(f"\n🔍 Testing {preference} preference...")
|
| 29 |
+
result = await preview_newsletter_content(preference)
|
| 30 |
+
|
| 31 |
+
print(f" Articles found: {result['article_count']}")
|
| 32 |
+
print(f" Config: {result['config']['subject']}")
|
| 33 |
+
|
| 34 |
+
if result['article_count'] > 0:
|
| 35 |
+
print(f" ✅ Content available for {preference} newsletter")
|
| 36 |
+
else:
|
| 37 |
+
print(f" ⚠️ No articles found (may need to run fetcher first)")
|
| 38 |
+
|
| 39 |
+
print("")
|
| 40 |
+
print("-" * 80)
|
| 41 |
+
|
| 42 |
+
# Test 2: Check subscriber analytics
|
| 43 |
+
print("\n📊 Test 2: Subscriber Analytics")
|
| 44 |
+
print("-" * 80)
|
| 45 |
+
|
| 46 |
+
from app.services.firebase_service import get_firebase_service
|
| 47 |
+
|
| 48 |
+
firebase = get_firebase_service()
|
| 49 |
+
|
| 50 |
+
if firebase.initialized:
|
| 51 |
+
all_subs = firebase.get_all_subscribers()
|
| 52 |
+
|
| 53 |
+
print(f" Total subscribers: {len(all_subs)}")
|
| 54 |
+
|
| 55 |
+
# Count by preference
|
| 56 |
+
pref_counts = {}
|
| 57 |
+
for sub in all_subs:
|
| 58 |
+
pref = sub.get('preference', 'Weekly')
|
| 59 |
+
pref_counts[pref] = pref_counts.get(pref, 0) + 1
|
| 60 |
+
|
| 61 |
+
print(f" Distribution by preference:")
|
| 62 |
+
for pref, count in pref_counts.items():
|
| 63 |
+
print(f" - {pref}: {count}")
|
| 64 |
+
|
| 65 |
+
print(f" ✅ Firebase service operational")
|
| 66 |
+
else:
|
| 67 |
+
print(f" ⚠️ Firebase not initialized (credentials may be missing)")
|
| 68 |
+
|
| 69 |
+
print("")
|
| 70 |
+
print("-" * 80)
|
| 71 |
+
|
| 72 |
+
# Test 3: Check scheduler jobs
|
| 73 |
+
print("\n⏰ Test 3: Scheduler Jobs")
|
| 74 |
+
print("-" * 80)
|
| 75 |
+
|
| 76 |
+
from app.services.scheduler import scheduler
|
| 77 |
+
|
| 78 |
+
jobs = scheduler.get_jobs()
|
| 79 |
+
newsletter_jobs = [j for j in jobs if 'newsletter' in j.id]
|
| 80 |
+
|
| 81 |
+
print(f" Total scheduler jobs: {len(jobs)}")
|
| 82 |
+
print(f" Newsletter jobs: {len(newsletter_jobs)}")
|
| 83 |
+
|
| 84 |
+
for job in newsletter_jobs:
|
| 85 |
+
print(f"\n Job: {job.name}")
|
| 86 |
+
print(f" ID: {job.id}")
|
| 87 |
+
print(f" Next run: {job.next_run_time}")
|
| 88 |
+
print(f" Trigger: {job.trigger}")
|
| 89 |
+
|
| 90 |
+
if len(newsletter_jobs) == 5:
|
| 91 |
+
print(f"\n ✅ All 5 newsletter jobs registered correctly")
|
| 92 |
+
else:
|
| 93 |
+
print(f"\n ⚠️ Expected 5 newsletter jobs, found {len(newsletter_jobs)}")
|
| 94 |
+
|
| 95 |
+
print("")
|
| 96 |
+
print("=" * 80)
|
| 97 |
+
print("✅ Test Complete!")
|
| 98 |
+
print("=" * 80)
|
| 99 |
+
|
| 100 |
+
if __name__ == "__main__":
|
| 101 |
+
asyncio.run(main())
|