-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathalerts.py
More file actions
572 lines (487 loc) · 20.7 KB
/
Copy pathalerts.py
File metadata and controls
572 lines (487 loc) · 20.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
# alerts.py
# Sends alerts for expiring data plans.
# Called by tracker.py when running the "check" command.
# Three alert types: desktop notification, email, and dashboard print to console.
import json
import os
import smtplib
from datetime import datetime
from email.mime.text import MIMEText
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
def load_config() -> dict:
"""Load config.json, returning defaults if the file is missing or broken.
Environment variables override config.json for sensitive values."""
defaults = {
"alert_days_warning": 14,
"alert_auto_renew": False,
"monthly_budget": None,
"annual_budget": None,
"email": {
"enabled": False,
"smtp_server": "smtp.gmail.com",
"smtp_port": 587,
"sender_email": "",
"sender_password": "",
"recipient_email": ""
},
"desktop_notification": {
"enabled": True
},
"webhooks": {
"discord": {
"enabled": False,
"url": ""
},
"slack": {
"enabled": False,
"url": ""
},
"telegram": {
"enabled": False,
"bot_token": "",
"chat_id": ""
}
}
}
# Load from file
config = defaults
if os.path.exists(CONFIG_PATH):
try:
with open(CONFIG_PATH, "r") as f:
config = json.load(f)
except Exception as e:
print(f"[!] Could not read config.json: {e} - using defaults")
# Environment variable overrides for sensitive data
# Webhook URLs from env vars (more secure than config.json)
discord_url = os.getenv("DATAPLAN_DISCORD_WEBHOOK")
if discord_url:
config.setdefault("webhooks", {}).setdefault("discord", {})["url"] = discord_url
config["webhooks"]["discord"]["enabled"] = True
slack_url = os.getenv("DATAPLAN_SLACK_WEBHOOK")
if slack_url:
config.setdefault("webhooks", {}).setdefault("slack", {})["url"] = slack_url
config["webhooks"]["slack"]["enabled"] = True
telegram_token = os.getenv("DATAPLAN_TELEGRAM_BOT_TOKEN")
telegram_chat = os.getenv("DATAPLAN_TELEGRAM_CHAT_ID")
if telegram_token and telegram_chat:
config.setdefault("webhooks", {}).setdefault("telegram", {})
config["webhooks"]["telegram"]["bot_token"] = telegram_token
config["webhooks"]["telegram"]["chat_id"] = telegram_chat
config["webhooks"]["telegram"]["enabled"] = True
# Email settings from env vars
email_user = os.getenv("DATAPLAN_EMAIL_USER")
email_pass = os.getenv("DATAPLAN_EMAIL_PASS")
if email_user:
config.setdefault("email", {})["sender_email"] = email_user
if email_pass:
config.setdefault("email", {})["sender_password"] = email_pass
# Budget settings from env vars
monthly_budget = os.getenv("DATAPLAN_MONTHLY_BUDGET")
if monthly_budget:
try:
config["monthly_budget"] = float(monthly_budget)
except ValueError:
pass
annual_budget = os.getenv("DATAPLAN_ANNUAL_BUDGET")
if annual_budget:
try:
config["annual_budget"] = float(annual_budget)
except ValueError:
pass
return config
def _days_left(next_renewal: str) -> int:
"""Calculate how many days until the renewal date (can be negative if overdue)."""
try:
renewal = datetime.strptime(next_renewal, "%Y-%m-%d").date()
today = datetime.now().date()
return (renewal - today).days
except Exception:
return 999 # Unknown date - treat as far away
def calculate_severity(plan: dict, warning_days: int = 14) -> str:
"""
Calculate alert severity based on days until renewal.
Returns: 'critical', 'warning', 'info', or 'none'
"""
days = _days_left(plan.get("next_renewal", ""))
if days < 0:
return "critical" # Overdue
elif days <= 3:
return "critical" # Very urgent
elif days <= 7:
return "warning" # Warning zone
elif days <= warning_days:
return "info" # Info/notice
else:
return "none" # No alert needed
def should_send_for_severity(severity: str, channel: str, config: dict) -> bool:
"""
Check if a channel should send alerts for this severity.
Uses routing config from config.json if available.
"""
# Default routing if no config exists
default_routing = {
"critical": ["desktop", "email", "webhooks"],
"warning": ["desktop", "email"],
"info": ["desktop"],
}
routing = config.get("alert_routing", default_routing)
allowed = routing.get(severity, [])
return channel in allowed
def _format_plan_line(plan: dict) -> str:
"""Format a single plan as a plain-text summary line."""
days = _days_left(plan.get("next_renewal", ""))
name = plan.get("name", "Unknown")
provider = plan.get("provider", "")
vm = plan.get("assigned_vm", "")
cost = plan.get("cost", 0.0)
renewal = plan.get("next_renewal", "N/A")
# Convert YYYY-MM-DD to DD/MM/YYYY for display
try:
renewal_display = datetime.strptime(renewal, "%Y-%m-%d").strftime("%d/%m/%Y")
except Exception:
renewal_display = renewal
parts = [f" - {name}"]
if provider:
parts[0] += f" ({provider})"
if vm:
parts[0] += f" [{vm}]"
parts[0] += f" | Due: {renewal_display} | {days} days left | AUD ${cost:.2f}"
return parts[0]
def _format_number_expiry_line(plan: dict) -> str:
"""Format a number-expiry alert line."""
days = _days_left(plan.get("number_expiry", ""))
name = plan.get("name", "Unknown")
phone = plan.get("phone_number", "") or "no number stored"
expiry = plan.get("number_expiry", "N/A")
try:
expiry_display = datetime.strptime(expiry, "%Y-%m-%d").strftime("%d/%m/%Y")
except Exception:
expiry_display = expiry
return f" - {name} | Number: {phone} | Expires: {expiry_display} | {days} days left"
def get_expiring_numbers(all_plans: list, warning_days: int) -> list:
"""Return plans whose phone number expiry is within warning_days days."""
return [
p for p in all_plans
if p.get("number_expiry") and 0 <= _days_left(p.get("number_expiry", "")) <= warning_days
]
def get_overdue_plans(all_plans: list) -> tuple:
"""
Return plans that are already overdue (negative days left).
Returns tuple of (overdue_renewals, overdue_numbers).
"""
overdue_renewals = [
p for p in all_plans
if p.get("next_renewal") and _days_left(p.get("next_renewal", "")) < 0
]
overdue_numbers = [
p for p in all_plans
if p.get("number_expiry") and _days_left(p.get("number_expiry", "")) < 0
]
return overdue_renewals, overdue_numbers
def get_expired_plans(all_plans: list) -> tuple:
"""
Return plans that expired more than 30 days ago.
Returns tuple of (expired_renewals, expired_numbers).
"""
expired_renewals = [
p for p in all_plans
if p.get("next_renewal") and _days_left(p.get("next_renewal", "")) < -30
]
expired_numbers = [
p for p in all_plans
if p.get("number_expiry") and _days_left(p.get("number_expiry", "")) < -30
]
return expired_renewals, expired_numbers
def send_desktop_notification(expiring_plans: list, config: dict, expiring_numbers: list = None) -> None:
"""
Show a Windows toast notification listing expiring plans.
Uses the plyer library. Silently skips if plyer is not installed.
"""
if not config.get("desktop_notification", {}).get("enabled", True):
return
expiring_numbers = expiring_numbers or []
if not expiring_plans and not expiring_numbers:
return
try:
from plyer import notification
except ImportError:
print("[!] plyer not installed - desktop notifications skipped. Run: pip install plyer")
return
message_lines = []
# Plan renewals
for plan in expiring_plans[:4]:
days = _days_left(plan.get("next_renewal", ""))
message_lines.append(f"Renew: {plan.get('name', 'Unknown')} - {days} days")
# Number expiries
for plan in expiring_numbers[:3]:
days = _days_left(plan.get("number_expiry", ""))
phone = plan.get("phone_number", "") or "no number"
message_lines.append(f"NUM EXPIRY: {plan.get('name', 'Unknown')} ({phone}) - {days} days")
total = len(expiring_plans) + len(expiring_numbers)
shown = min(len(expiring_plans), 4) + min(len(expiring_numbers), 3)
if total > shown:
message_lines.append(f"... and {total - shown} more")
message = "\n".join(message_lines)
try:
notification.notify(
title=f"Data Plan Alert - {total} item(s) need attention",
message=message,
app_name="Data Plan Tracker",
timeout=10 # Notification stays for 10 seconds
)
print(f"[*] Desktop notification sent ({total} items)")
except Exception as e:
print(f"[!] Desktop notification failed: {e}")
def send_email_alert(expiring_plans: list, config: dict) -> None:
"""
Send a plain-text email listing expiring plans via Gmail SMTP.
Only runs if email.enabled is true in config.json.
"""
email_cfg = config.get("email", {})
if not email_cfg.get("enabled", False):
print("[*] Email alerts are disabled in config.json - skipping email")
return
if not expiring_plans:
return
# Validate that email settings are filled in
required = ["sender_email", "sender_password", "recipient_email"]
missing = [k for k in required if not email_cfg.get(k, "").strip()]
if missing:
print(f"[!] Email config incomplete - missing: {', '.join(missing)}")
return
count = len(expiring_plans)
subject = f"Data Plan Alert - {count} plan(s) expiring soon"
# Build email body
lines = [
f"Data Plan Tracker Alert",
f"Generated: {datetime.now().strftime('%d/%m/%Y %H:%M')}",
f"",
f"The following {count} plan(s) are expiring within {config.get('alert_days_warning', 14)} days:",
f"",
]
for plan in expiring_plans:
lines.append(_format_plan_line(plan))
lines += [
"",
"Log in to renew these plans to avoid service interruption.",
"",
"-- Data Plan Tracker (automated alert)"
]
body = "\n".join(lines)
msg = MIMEText(body)
msg["Subject"] = subject
msg["From"] = email_cfg["sender_email"]
msg["To"] = email_cfg["recipient_email"]
try:
with smtplib.SMTP(email_cfg["smtp_server"], email_cfg["smtp_port"]) as server:
server.starttls() # Encrypt the connection
server.login(email_cfg["sender_email"], email_cfg["sender_password"])
server.sendmail(
email_cfg["sender_email"],
email_cfg["recipient_email"],
msg.as_string()
)
print(f"[*] Email alert sent to {email_cfg['recipient_email']}")
except smtplib.SMTPAuthenticationError:
print("[!] Email failed: authentication error. Check sender_email and sender_password.")
print(" Tip: For Gmail, use an App Password, not your regular password.")
print(" See: https://support.google.com/accounts/answer/185833")
except Exception as e:
print(f"[!] Email failed: {e}")
def _format_overdue_line(plan: dict, date_field: str = "next_renewal") -> str:
"""Format an overdue plan line with emphasis."""
days = _days_left(plan.get(date_field, ""))
name = plan.get("name", "Unknown")
provider = plan.get("provider", "")
vm = plan.get("assigned_vm", "")
date_str = plan.get(date_field, "N/A")
try:
date_display = datetime.strptime(date_str, "%Y-%m-%d").strftime("%d/%m/%Y")
except Exception:
date_display = date_str
parts = [f" *** {name}"]
if provider:
parts[0] += f" ({provider})"
if vm:
parts[0] += f" [{vm}]"
parts[0] += f" | Due: {date_display} | OVERDUE by {abs(days)} days ***"
return parts[0]
def print_dashboard(expiring_plans: list, all_plans: list, config: dict) -> None:
"""
Print a formatted summary to stdout.
This output appears in the Task Scheduler log when running as a scheduled task.
"""
now = datetime.now().strftime("%d/%m/%Y %H:%M")
warning_days = config.get("alert_days_warning", 14)
print("=" * 60)
print(f" DATA PLAN TRACKER - Daily Check")
print(f" Run at: {now}")
print("=" * 60)
expiring_numbers = get_expiring_numbers(all_plans, warning_days)
overdue_renewals, overdue_numbers = get_overdue_plans(all_plans)
expired_renewals, expired_numbers = get_expired_plans(all_plans)
print(f" Total active plans: {len(all_plans)}")
print(f" Plans expiring within {warning_days} days: {len(expiring_plans)}")
print(f" Phone numbers expiring within {warning_days} days: {len(expiring_numbers)}")
print(f" Plans OVERDUE: {len(overdue_renewals)}")
print(f" Phone numbers OVERDUE: {len(overdue_numbers)}")
print()
# Show overdue plans first (most urgent)
if overdue_renewals:
print(f"[!] OVERDUE PLAN RENEWALS (ACTION REQUIRED):")
for plan in overdue_renewals:
print(_format_overdue_line(plan, "next_renewal"))
print()
if overdue_numbers:
print(f"[!] OVERDUE PHONE NUMBER EXPIRIES (ACTION REQUIRED):")
for plan in overdue_numbers:
print(_format_overdue_line(plan, "number_expiry"))
print()
# Then show expiring soon
if expiring_plans:
print(f"[!] PLANS EXPIRING SOON:")
for plan in expiring_plans:
print(_format_plan_line(plan))
print()
elif not overdue_renewals:
print(f"[*] No plans expiring in the next {warning_days} days. All clear.")
print()
if expiring_numbers:
print(f"[!] PHONE NUMBERS EXPIRING SOON:")
for plan in expiring_numbers:
print(_format_number_expiry_line(plan))
print()
elif not overdue_numbers:
print(f"[*] No phone numbers expiring in the next {warning_days} days.")
print()
# Show warning for very old expired items
if expired_renewals or expired_numbers:
print(f"[!] WARNING: {len(expired_renewals)} plans and {len(expired_numbers)} phone numbers")
print(f" have been expired for over 30 days. Consider deactivating them.")
print()
print("=" * 60)
def send_webhook_alerts(expiring_plans: list, overdue_plans: list, expiring_numbers: list, config: dict) -> None:
"""Send alerts to configured webhooks (Discord, Slack, or generic)."""
webhooks = config.get("webhooks", {})
if not webhooks:
return
import urllib.request
import json
# Build message content
total_items = len(expiring_plans) + len(overdue_plans) + len(expiring_numbers)
if total_items == 0:
return
# Discord webhook format
discord_cfg = webhooks.get("discord", {})
if discord_cfg.get("enabled") and discord_cfg.get("url"):
content = f"**Data Plan Alert** - {total_items} item(s) need attention\n\n"
if overdue_plans:
content += "**OVERDUE (Action Required):**\n"
for p in overdue_plans[:3]:
days = _days_left(p.get("next_renewal", ""))
content += f"• {p.get('name')} - overdue by {abs(days)} days\n"
if len(overdue_plans) > 3:
content += f"• ... and {len(overdue_plans) - 3} more\n"
content += "\n"
if expiring_plans:
content += "**Expiring Soon:**\n"
for p in expiring_plans[:5]:
days = _days_left(p.get("next_renewal", ""))
content += f"• {p.get('name')} - {days} days left\n"
if len(expiring_plans) > 5:
content += f"• ... and {len(expiring_plans) - 5} more\n"
if expiring_numbers:
content += "\n**Phone Numbers Expiring:**\n"
for p in expiring_numbers[:3]:
days = _days_left(p.get("number_expiry", ""))
phone = p.get("phone_number", "no number")
content += f"• {phone} - {days} days\n"
payload = {"content": content}
try:
req = urllib.request.Request(
discord_cfg["url"],
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
method="POST"
)
urllib.request.urlopen(req, timeout=10)
print("[*] Discord webhook alert sent")
except Exception as e:
print(f"[!] Discord webhook failed: {e}")
# Slack webhook format
slack_cfg = webhooks.get("slack", {})
if slack_cfg.get("enabled") and slack_cfg.get("url"):
# Build Slack blocks
blocks = [
{
"type": "header",
"text": {"type": "plain_text", "text": f"📡 Data Plan Alert - {total_items} items need attention"}
}
]
if overdue_plans:
text = "*OVERDUE (Action Required):*\n"
for p in overdue_plans[:3]:
days = _days_left(p.get("next_renewal", ""))
text += f"• {p.get('name')} - overdue by {abs(days)} days\n"
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": text}})
if expiring_plans:
text = "*Expiring Soon:*\n"
for p in expiring_plans[:5]:
days = _days_left(p.get("next_renewal", ""))
text += f"• {p.get('name')} - {days} days left\n"
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": text}})
payload = {"blocks": blocks}
try:
req = urllib.request.Request(
slack_cfg["url"],
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
method="POST"
)
urllib.request.urlopen(req, timeout=10)
print("[*] Slack webhook alert sent")
except Exception as e:
print(f"[!] Slack webhook failed: {e}")
def run_all_alerts(expiring_plans: list, all_plans: list) -> None:
"""
Main entry point called by tracker.py check.
Runs all configured alert types in sequence with severity-based routing.
"""
config = load_config()
warning_days = config.get("alert_days_warning", 14)
# Get expiring numbers
expiring_numbers = get_expiring_numbers(all_plans, warning_days)
# Get overdue plans (negative days = overdue)
overdue_renewals, overdue_numbers = get_overdue_plans(all_plans)
# Smart filtering: separate manual-renew from auto-renew
manual_renew_expiring = [p for p in expiring_plans if not p.get("auto_renew")]
auto_renew_expiring = [p for p in expiring_plans if p.get("auto_renew")]
# Calculate severity for each plan
all_notification_plans = manual_renew_expiring + overdue_renewals
if config.get("alert_auto_renew", False):
all_notification_plans.extend(auto_renew_expiring)
# 1. Always print to console/log first (shows everything)
print_dashboard(expiring_plans, all_plans, config)
# Show auto-renew summary if any were filtered
if auto_renew_expiring and not config.get("alert_auto_renew", False):
print(f"[*] {len(auto_renew_expiring)} auto-renew plan(s) not alerted (enable in config)")
print()
# 2. Desktop notification (with severity filtering)
# Desktop gets critical + warning by default
desktop_plans = [p for p in all_notification_plans
if should_send_for_severity(calculate_severity(p, warning_days), "desktop", config)]
if desktop_plans or overdue_numbers:
send_desktop_notification(desktop_plans, config, expiring_numbers + overdue_numbers)
# 3. Email (with severity filtering)
# Email gets critical + warning by default
email_plans = [p for p in all_notification_plans
if should_send_for_severity(calculate_severity(p, warning_days), "email", config)]
if email_plans:
send_email_alert(email_plans, config)
# 4. Webhooks (with severity filtering)
# Webhooks get critical only by default
webhook_critical = [p for p in manual_renew_expiring
if calculate_severity(p, warning_days) == "critical"]
webhook_overdue = overdue_renewals # Overdue is always critical
if webhook_critical or webhook_overdue or overdue_numbers:
send_webhook_alerts(webhook_critical, webhook_overdue, expiring_numbers + overdue_numbers, config)