833 lines
37 KiB
Python
833 lines
37 KiB
Python
import sys
|
|
import os
|
|
import json
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox, filedialog
|
|
import smtplib
|
|
from email.mime.text import MIMEText
|
|
from email.mime.multipart import MIMEMultipart
|
|
from datetime import datetime, timedelta
|
|
import threading
|
|
|
|
DB_FILE = "contacts_db.json"
|
|
DEFAULT_DB = {
|
|
"smtp_settings": {
|
|
"server": "smtp.gmail.com",
|
|
"port": 587,
|
|
"email": "",
|
|
"password": ""
|
|
},
|
|
"contacts": [
|
|
{
|
|
"callsign": "Glenn",
|
|
"name": "Glenn",
|
|
"email": "[email protected]",
|
|
"phone_gateway": "[email protected]",
|
|
"tags": "ARES, RACES, LEADER",
|
|
"receive_email": True,
|
|
"receive_text": True
|
|
}
|
|
]
|
|
}
|
|
|
|
class DispatchApp:
|
|
def __init__(self, root):
|
|
self.root = root
|
|
self.root.title("CCAR Alert Portal")
|
|
self.root.geometry("900x700")
|
|
|
|
self.all_individuals = []
|
|
self.active_schedulers = {}
|
|
self.task_counter = 0
|
|
self.ignore_trace = False
|
|
|
|
self.load_database()
|
|
self.create_widgets()
|
|
self.refresh_contact_list()
|
|
self.update_dispatch_selectors()
|
|
|
|
# --- RIGHT-CLICK CONTEXT MENU HELPER ---
|
|
def add_context_menu(self, widget):
|
|
menu = tk.Menu(widget, tearoff=0)
|
|
|
|
def cut_text():
|
|
widget.event_generate("<<Cut>>")
|
|
|
|
def copy_text():
|
|
widget.event_generate("<<Copy>>")
|
|
|
|
def paste_text():
|
|
widget.event_generate("<<Paste>>")
|
|
|
|
def select_all_text():
|
|
if isinstance(widget, tk.Text):
|
|
widget.tag_add("sel", "1.0", "end")
|
|
elif isinstance(widget, (ttk.Entry, tk.Entry)):
|
|
widget.select_range(0, tk.END)
|
|
widget.icursor(tk.END)
|
|
|
|
def clear_text():
|
|
if isinstance(widget, tk.Text):
|
|
widget.delete("1.0", tk.END)
|
|
elif isinstance(widget, (ttk.Entry, tk.Entry)):
|
|
widget.delete(0, tk.END)
|
|
|
|
menu.add_command(label="Cut", command=cut_text)
|
|
menu.add_command(label="Copy", command=copy_text)
|
|
menu.add_command(label="Paste", command=paste_text)
|
|
menu.add_separator()
|
|
menu.add_command(label="Select All", command=select_all_text)
|
|
menu.add_command(label="Clear", command=clear_text)
|
|
|
|
def show_popup(event):
|
|
menu.tk_popup(event.x_root, event.y_root)
|
|
|
|
# Windows and Linux Right-Click
|
|
widget.bind("<Button-3>", show_popup)
|
|
# macOS Right-Click / Ctrl-Click
|
|
widget.bind("<Button-2>", show_popup)
|
|
widget.bind("<Control-Button-1>", show_popup)
|
|
|
|
def load_database(self):
|
|
if not os.path.exists(DB_FILE):
|
|
with open(DB_FILE, 'w') as f:
|
|
json.dump(DEFAULT_DB, f, indent=4)
|
|
try:
|
|
with open(DB_FILE, 'r') as f:
|
|
self.db = json.load(f)
|
|
|
|
migrated = False
|
|
for c in self.db.get("contacts", []):
|
|
if "group" in c and "tags" not in c:
|
|
c["tags"] = f"ARES, {c['group'].strip().upper()}" if c["group"].strip().upper() != "ARES" else "ARES"
|
|
del c["group"]
|
|
migrated = True
|
|
if migrated:
|
|
self.save_database()
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"Failed to load database: {e}")
|
|
self.db = DEFAULT_DB
|
|
|
|
def save_database(self):
|
|
try:
|
|
with open(DB_FILE, 'w') as f:
|
|
json.dump(self.db, f, indent=4)
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"Failed to save database: {e}")
|
|
|
|
def create_widgets(self):
|
|
self.notebook = ttk.Notebook(self.root)
|
|
self.notebook.pack(fill='both', expand=True, padx=10, pady=10)
|
|
|
|
self.tab_dispatch = ttk.Frame(self.notebook)
|
|
self.notebook.add(self.tab_dispatch, text="Send Dispatch")
|
|
|
|
self.tab_roster = ttk.Frame(self.notebook)
|
|
self.notebook.add(self.tab_roster, text="Manage Roster")
|
|
|
|
self.tab_config = ttk.Frame(self.notebook)
|
|
self.notebook.add(self.tab_config, text="SMTP Setup")
|
|
|
|
self.setup_dispatch_tab()
|
|
self.setup_roster_tab()
|
|
self.setup_config_tab()
|
|
|
|
# --- TAB 1: DISPATCH ---
|
|
def setup_dispatch_tab(self):
|
|
top_frame = ttk.Frame(self.tab_dispatch)
|
|
top_frame.pack(fill='x', padx=10, pady=10)
|
|
|
|
# Targeting Frame
|
|
target_frame = ttk.LabelFrame(top_frame, text=" Recipient Targeting Mode ", padding=10)
|
|
target_frame.pack(side='left', fill='both', expand=True, padx=(0, 5))
|
|
|
|
self.target_mode = tk.StringVar(value="all")
|
|
|
|
rb_all = ttk.Radiobutton(target_frame, text="All Roster Members", variable=self.target_mode, value="all", command=self.toggle_target_fields)
|
|
rb_all.grid(row=0, column=0, sticky='w', padx=5, pady=5)
|
|
|
|
rb_group = ttk.Radiobutton(target_frame, text="By Group Tag Filter:", variable=self.target_mode, value="group", command=self.toggle_target_fields)
|
|
rb_group.grid(row=1, column=0, sticky='w', padx=5, pady=5)
|
|
|
|
self.cb_groups = ttk.Combobox(target_frame, state="disabled", width=15)
|
|
self.cb_groups.grid(row=1, column=1, sticky='w', padx=5, pady=5)
|
|
|
|
rb_indiv = ttk.Radiobutton(target_frame, text="Individual Operator:", variable=self.target_mode, value="individual", command=self.toggle_target_fields)
|
|
rb_indiv.grid(row=2, column=0, sticky='w', padx=5, pady=5)
|
|
|
|
self.indiv_search_var = tk.StringVar()
|
|
self.indiv_search_var.trace_add("write", self.on_individual_type)
|
|
|
|
self.cb_individuals = ttk.Combobox(target_frame, state="disabled", width=35, textvariable=self.indiv_search_var)
|
|
self.cb_individuals.grid(row=2, column=1, sticky='w', padx=5, pady=5)
|
|
self.add_context_menu(self.cb_individuals)
|
|
|
|
# Transmission Channel & Dynamic Sender Options
|
|
channel_frame = ttk.LabelFrame(top_frame, text=" Transmission Settings ", padding=10)
|
|
channel_frame.pack(side='right', fill='both', padx=(5, 0))
|
|
|
|
self.dispatch_send_email = tk.BooleanVar(value=True)
|
|
self.dispatch_send_text = tk.BooleanVar(value=False)
|
|
|
|
chk_send_email = ttk.Checkbutton(channel_frame, text="Send via Email", variable=self.dispatch_send_email)
|
|
chk_send_email.pack(anchor='w', padx=10, pady=2)
|
|
|
|
chk_send_text = ttk.Checkbutton(channel_frame, text="Send via Text (SMS)", variable=self.dispatch_send_text)
|
|
chk_send_text.pack(anchor='w', padx=10, pady=2)
|
|
|
|
# Custom Sender Box
|
|
ttk.Label(channel_frame, text="Display Sender Email:").pack(anchor='w', padx=10, pady=(10, 2))
|
|
self.ent_custom_sender = ttk.Entry(channel_frame, width=28)
|
|
self.ent_custom_sender.pack(anchor='w', padx=10, pady=2)
|
|
cfg = self.db.get("smtp_settings", {})
|
|
self.ent_custom_sender.insert(0, cfg.get("email", ""))
|
|
self.add_context_menu(self.ent_custom_sender)
|
|
|
|
# Scheduler Panel
|
|
sched_frame = ttk.LabelFrame(self.tab_dispatch, text=" Transmission Timing Scheduling ", padding=10)
|
|
sched_frame.pack(fill='x', padx=10, pady=5)
|
|
|
|
self.sched_choice = tk.StringVar(value="now")
|
|
|
|
rb_now = ttk.Radiobutton(sched_frame, text="Send Immediately", variable=self.sched_choice, value="now", command=self.toggle_sched_fields)
|
|
rb_now.grid(row=0, column=0, sticky='w', padx=5, pady=5)
|
|
|
|
rb_later = ttk.Radiobutton(sched_frame, text="Schedule Deployment:", variable=self.sched_choice, value="later", command=self.toggle_sched_fields)
|
|
rb_later.grid(row=0, column=1, sticky='w', padx=15, pady=5)
|
|
|
|
ttk.Label(sched_frame, text="Date (MM-DD-YY):").grid(row=0, column=2, sticky='w', padx=5)
|
|
|
|
self.ent_sched_date = ttk.Entry(sched_frame, width=12, state="disabled")
|
|
self.ent_sched_date.insert(0, datetime.now().strftime("%m-%d-%y"))
|
|
self.ent_sched_date.grid(row=0, column=3, sticky='w', padx=5)
|
|
self.add_context_menu(self.ent_sched_date)
|
|
|
|
ttk.Label(sched_frame, text="Time (HH:MM 24hr):").grid(row=0, column=4, sticky='w', padx=5)
|
|
|
|
self.ent_sched_time = ttk.Entry(sched_frame, width=8, state="disabled")
|
|
self.ent_sched_time.insert(0, (datetime.now() + timedelta(hours=1)).strftime("%H:%M"))
|
|
self.ent_sched_time.grid(row=0, column=5, sticky='w', padx=5)
|
|
self.add_context_menu(self.ent_sched_time)
|
|
|
|
# Queue Status Trackers
|
|
self.lbl_queue_status = ttk.Label(sched_frame, text="Pending Alerts: 0", font=('Helvetica', 9, 'italic'), foreground='blue')
|
|
self.lbl_queue_status.grid(row=0, column=6, sticky='e', padx=(20, 5))
|
|
|
|
btn_manage_queue = ttk.Button(sched_frame, text="Manage Queue", command=self.open_queue_manager)
|
|
btn_manage_queue.grid(row=0, column=7, sticky='e', padx=5)
|
|
|
|
# Message Entry Layout
|
|
lbl_msg = ttk.Label(self.tab_dispatch, text="Emergency Alert Message Content (HTML Supported):", font=('Helvetica', 10, 'bold'))
|
|
lbl_msg.pack(anchor='w', padx=10, pady=5)
|
|
|
|
self.txt_message = tk.Text(self.tab_dispatch, height=8, wrap='word', undo=True)
|
|
self.txt_message.pack(fill='both', expand=True, padx=10, pady=5)
|
|
|
|
# Standard Keyboard Shortcuts
|
|
self.txt_message.bind("<Control-a>", self.handle_select_all)
|
|
self.txt_message.bind("<Control-c>", self.handle_copy)
|
|
self.txt_message.bind("<Control-v>", self.handle_paste)
|
|
self.txt_message.bind("<Control-x>", self.handle_cut)
|
|
|
|
# Attach Right-Click Context Menu
|
|
self.add_context_menu(self.txt_message)
|
|
|
|
# Action Buttons Frame
|
|
btn_frame = ttk.Frame(self.tab_dispatch)
|
|
btn_frame.pack(fill='x', padx=10, pady=10)
|
|
|
|
btn_send = ttk.Button(btn_frame, text="TRANSMIT DISPATCH", command=self.process_dispatch_request)
|
|
btn_send.pack(side='right', padx=5)
|
|
|
|
btn_clear_screen = ttk.Button(btn_frame, text="Clear Screen", command=self.clear_message_screen)
|
|
btn_clear_screen.pack(side='right', padx=5)
|
|
|
|
def handle_select_all(self, event):
|
|
self.txt_message.tag_add("sel", "1.0", "end")
|
|
return "break"
|
|
|
|
def handle_copy(self, event):
|
|
self.txt_message.event_generate("<<Copy>>")
|
|
return "break"
|
|
|
|
def handle_paste(self, event):
|
|
self.txt_message.event_generate("<<Paste>>")
|
|
return "break"
|
|
|
|
def handle_cut(self, event):
|
|
self.txt_message.event_generate("<<Cut>>")
|
|
return "break"
|
|
|
|
def clear_message_screen(self):
|
|
self.txt_message.delete('1.0', tk.END)
|
|
|
|
def toggle_target_fields(self):
|
|
mode = self.target_mode.get()
|
|
if mode == "all":
|
|
self.cb_individuals.config(state="disabled")
|
|
self.cb_groups.config(state="disabled")
|
|
elif mode == "group":
|
|
self.cb_groups.config(state="readonly")
|
|
self.cb_individuals.config(state="disabled")
|
|
elif mode == "individual":
|
|
self.cb_groups.config(state="disabled")
|
|
self.cb_individuals.config(state="normal")
|
|
self.ignore_trace = True
|
|
self.cb_individuals.set("")
|
|
self.ignore_trace = False
|
|
self.cb_individuals.focus_set()
|
|
|
|
def toggle_sched_fields(self):
|
|
if self.sched_choice.get() == "now":
|
|
self.ent_sched_date.config(state="disabled")
|
|
self.ent_sched_time.config(state="disabled")
|
|
else:
|
|
self.ent_sched_date.config(state="normal")
|
|
self.ent_sched_time.config(state="normal")
|
|
|
|
def update_dispatch_selectors(self):
|
|
unique_tags = set()
|
|
self.all_individuals = []
|
|
|
|
for c in self.db.get("contacts", []):
|
|
if c.get("tags"):
|
|
parts = [t.strip().upper() for t in c["tags"].split(",") if t.strip()]
|
|
unique_tags.update(parts)
|
|
|
|
display_name = f"{c.get('callsign', 'VOLUNTEER')} - {c.get('name', '')} ({c.get('email', '')})"
|
|
self.all_individuals.append(display_name)
|
|
|
|
self.cb_groups['values'] = sorted(list(unique_tags))
|
|
if unique_tags:
|
|
if "ARES" in unique_tags:
|
|
self.cb_groups.set("ARES")
|
|
else:
|
|
self.cb_groups.current(0)
|
|
|
|
self.all_individuals = sorted(self.all_individuals)
|
|
self.cb_individuals['values'] = self.all_individuals
|
|
|
|
def on_individual_type(self, *args):
|
|
if self.ignore_trace or self.target_mode.get() != "individual":
|
|
return
|
|
|
|
typed_text = self.cb_individuals.get()
|
|
typed_text_lower = typed_text.strip().lower()
|
|
|
|
if typed_text_lower == "":
|
|
filtered_values = self.all_individuals
|
|
else:
|
|
filtered_values = [person for person in self.all_individuals if typed_text_lower in person.lower()]
|
|
|
|
self.ignore_trace = True
|
|
self.cb_individuals['values'] = filtered_values
|
|
self.ignore_trace = False
|
|
|
|
# --- TAB 2: ROSTER MANAGEMENT ---
|
|
def setup_roster_tab(self):
|
|
top_panel = ttk.Frame(self.tab_roster)
|
|
top_panel.pack(fill='x', padx=10, pady=5)
|
|
|
|
btn_import = ttk.Button(top_panel, text="Import Google CSV", command=self.import_google_csv)
|
|
btn_import.pack(side='left', padx=5)
|
|
|
|
ttk.Label(top_panel, text="Search Roster:").pack(side='left', padx=(20, 5))
|
|
self.ent_search = ttk.Entry(top_panel, width=25)
|
|
self.ent_search.pack(side='left', padx=5)
|
|
self.ent_search.bind("<KeyRelease>", lambda event: self.refresh_contact_list())
|
|
self.add_context_menu(self.ent_search)
|
|
|
|
main_roster_frame = ttk.Frame(self.tab_roster)
|
|
main_roster_frame.pack(fill='both', expand=True, padx=10, pady=5)
|
|
|
|
self.tree = ttk.Treeview(main_roster_frame, columns=("Callsign", "Name", "Tags", "Email", "SMS Gateway", "Email Ok", "Text Ok"), show='headings')
|
|
self.tree.heading("Callsign", text="Callsign / ID")
|
|
self.tree.heading("Name", text="Name")
|
|
self.tree.heading("Tags", text="Assigned Tags / Groups")
|
|
self.tree.heading("Email", text="Email")
|
|
self.tree.heading("SMS Gateway", text="SMS Gateway Address")
|
|
self.tree.heading("Email Ok", text="Email Ok")
|
|
self.tree.heading("Text Ok", text="Text Ok")
|
|
|
|
self.tree.column("Email Ok", width=70, anchor='center')
|
|
self.tree.column("Text Ok", width=70, anchor='center')
|
|
self.tree.column("Tags", width=150)
|
|
self.tree.pack(side='left', fill='both', expand=True)
|
|
self.tree.bind("<<TreeviewSelect>>", self.on_contact_select)
|
|
|
|
editor_frame = ttk.LabelFrame(main_roster_frame, text=" Operator Profiles / Details ", padding=10)
|
|
editor_frame.pack(side='right', fill='y', padx=(10, 0))
|
|
|
|
ttk.Label(editor_frame, text="Callsign/ID:").grid(row=0, column=0, sticky='w', pady=2)
|
|
self.form_callsign = ttk.Entry(editor_frame, width=22)
|
|
self.form_callsign.grid(row=0, column=1, pady=2, sticky='w')
|
|
self.add_context_menu(self.form_callsign)
|
|
|
|
ttk.Label(editor_frame, text="Name:").grid(row=1, column=0, sticky='w', pady=2)
|
|
self.form_name = ttk.Entry(editor_frame, width=22)
|
|
self.form_name.grid(row=1, column=1, pady=2, sticky='w')
|
|
self.add_context_menu(self.form_name)
|
|
|
|
ttk.Label(editor_frame, text="Group Tags:").grid(row=2, column=0, sticky='w', pady=2)
|
|
self.form_tags = ttk.Entry(editor_frame, width=22)
|
|
self.form_tags.grid(row=2, column=1, pady=2, sticky='w')
|
|
self.add_context_menu(self.form_tags)
|
|
ttk.Label(editor_frame, text="(Separate with commas)", font=('Helvetica', 8, 'italic'), foreground='gray').grid(row=3, column=1, sticky='w')
|
|
|
|
ttk.Label(editor_frame, text="Email:").grid(row=4, column=0, sticky='w', pady=2)
|
|
self.form_email = ttk.Entry(editor_frame, width=22)
|
|
self.form_email.grid(row=4, column=1, pady=2, sticky='w')
|
|
self.add_context_menu(self.form_email)
|
|
|
|
ttk.Label(editor_frame, text="SMS Gateway:").grid(row=5, column=0, sticky='w', pady=2)
|
|
self.form_sms = ttk.Entry(editor_frame, width=22)
|
|
self.form_sms.grid(row=5, column=1, pady=2, sticky='w')
|
|
self.add_context_menu(self.form_sms)
|
|
|
|
ttk.Label(editor_frame, text="Channels:").grid(row=6, column=0, sticky='nw', pady=5)
|
|
chan_subframe = ttk.Frame(editor_frame)
|
|
chan_subframe.grid(row=6, column=1, sticky='w', pady=5)
|
|
|
|
self.form_receive_email = tk.BooleanVar(value=True)
|
|
self.form_receive_text = tk.BooleanVar(value=True)
|
|
|
|
ttk.Checkbutton(chan_subframe, text="Email Enabled", variable=self.form_receive_email).pack(anchor='w')
|
|
ttk.Checkbutton(chan_subframe, text="Text Enabled", variable=self.form_receive_text).pack(anchor='w')
|
|
|
|
btn_form_frame = ttk.Frame(editor_frame)
|
|
btn_form_frame.grid(row=7, column=0, columnspan=2, pady=15)
|
|
|
|
btn_save_user = ttk.Button(btn_form_frame, text="Save / Update", command=self.save_or_update_contact)
|
|
btn_save_user.pack(side='left', padx=2)
|
|
|
|
btn_del_user = ttk.Button(btn_form_frame, text="Delete Entry", command=self.delete_contact)
|
|
btn_del_user.pack(side='left', padx=2)
|
|
|
|
btn_clear_user = ttk.Button(btn_form_frame, text="Clear Form", command=self.clear_form)
|
|
btn_clear_user.pack(side='left', padx=2)
|
|
|
|
btn_wipe_all = ttk.Button(btn_form_frame, text="Wipe Roster", command=self.wipe_entire_roster)
|
|
btn_wipe_all.pack(side='left', padx=2)
|
|
|
|
self.selected_contact_email = None
|
|
self.clear_form()
|
|
|
|
def on_contact_select(self, event):
|
|
selected = self.tree.selection()
|
|
if not selected:
|
|
return
|
|
|
|
values = self.tree.item(selected[0], "values")
|
|
self.clear_form()
|
|
|
|
self.form_callsign.insert(0, values[0])
|
|
self.form_name.insert(0, values[1])
|
|
self.form_tags.insert(0, values[2])
|
|
self.form_email.insert(0, values[3])
|
|
self.form_sms.insert(0, values[4])
|
|
|
|
self.form_receive_email.set(True if values[5] == "YES" else False)
|
|
self.form_receive_text.set(True if values[6] == "YES" else False)
|
|
|
|
self.selected_contact_email = values[3]
|
|
|
|
def clear_form(self):
|
|
self.form_callsign.delete(0, tk.END)
|
|
self.form_name.delete(0, tk.END)
|
|
self.form_tags.delete(0, tk.END)
|
|
self.form_email.delete(0, tk.END)
|
|
self.form_sms.delete(0, tk.END)
|
|
self.form_receive_email.set(True)
|
|
self.form_receive_text.set(True)
|
|
self.selected_contact_email = None
|
|
|
|
def save_or_update_contact(self):
|
|
email = self.form_email.get().strip()
|
|
if not email:
|
|
messagebox.showwarning("Validation Error", "An email address is required.")
|
|
return
|
|
|
|
raw_tags = self.form_tags.get().strip()
|
|
tag_list = [t.strip().upper() for t in raw_tags.split(",") if t.strip()]
|
|
sanitized_tags_str = ", ".join(tag_list)
|
|
|
|
new_data = {
|
|
"callsign": self.form_callsign.get().strip(),
|
|
"name": self.form_name.get().strip(),
|
|
"tags": sanitized_tags_str,
|
|
"email": email,
|
|
"phone_gateway": self.form_sms.get().strip(),
|
|
"receive_email": self.form_receive_email.get(),
|
|
"receive_text": self.form_receive_text.get()
|
|
}
|
|
|
|
updated = False
|
|
for i, c in enumerate(self.db["contacts"]):
|
|
if (self.selected_contact_email and c.get("email") == self.selected_contact_email) or (not self.selected_contact_email and c.get("email") == email):
|
|
self.db["contacts"][i] = new_data
|
|
updated = True
|
|
break
|
|
|
|
if not updated:
|
|
self.db["contacts"].append(new_data)
|
|
|
|
self.save_database()
|
|
self.refresh_contact_list()
|
|
self.update_dispatch_selectors()
|
|
self.clear_form()
|
|
messagebox.showinfo("Success", "Roster record saved successfully.")
|
|
|
|
def delete_contact(self):
|
|
if not self.selected_contact_email:
|
|
messagebox.showwarning("Selection Required", "Select an operator entry from the roster list to delete.")
|
|
return
|
|
|
|
if messagebox.askyesno("Confirm Deletion", "Are you sure you want to completely remove this operator entry?"):
|
|
self.db["contacts"] = [c for c in self.db["contacts"] if c.get("email") != self.selected_contact_email]
|
|
self.save_database()
|
|
self.refresh_contact_list()
|
|
self.update_dispatch_selectors()
|
|
self.clear_form()
|
|
|
|
def wipe_entire_roster(self):
|
|
if messagebox.askyesno("CRITICAL WARNING", "Are you sure you want to permanently delete the ENTIRE contact roster?\n\nThis cannot be undone."):
|
|
if messagebox.askyesno("FINAL CONFIRMATION", "Confirming one last time: Erase all operator profiles?"):
|
|
self.db["contacts"] = []
|
|
self.save_database()
|
|
self.refresh_contact_list()
|
|
self.update_dispatch_selectors()
|
|
self.clear_form()
|
|
messagebox.showinfo("Roster Purged", "The entire contact database has been wiped clean.")
|
|
|
|
# --- TAB 3: CONFIGURATION ---
|
|
def setup_config_tab(self):
|
|
cfg = self.db.get("smtp_settings", {})
|
|
frame = ttk.Frame(self.tab_config, padding=20)
|
|
frame.pack(fill='both', expand=True)
|
|
|
|
ttk.Label(frame, text="SMTP Server:").grid(row=0, column=0, sticky='w', pady=5)
|
|
self.ent_server = ttk.Entry(frame, width=30)
|
|
self.ent_server.insert(0, cfg.get("server", "smtp.gmail.com"))
|
|
self.ent_server.grid(row=0, column=1, sticky='w', pady=5)
|
|
self.add_context_menu(self.ent_server)
|
|
|
|
ttk.Label(frame, text="Port:").grid(row=1, column=0, sticky='w', pady=5)
|
|
self.ent_port = ttk.Entry(frame, width=10)
|
|
self.ent_port.insert(0, str(cfg.get("port", 587)))
|
|
self.ent_port.grid(row=1, column=1, sticky='w', pady=5)
|
|
self.add_context_menu(self.ent_port)
|
|
|
|
ttk.Label(frame, text="Sender Email:").grid(row=2, column=0, sticky='w', pady=5)
|
|
self.ent_email = ttk.Entry(frame, width=30)
|
|
self.ent_email.insert(0, cfg.get("email", ""))
|
|
self.ent_email.grid(row=2, column=1, sticky='w', pady=5)
|
|
self.add_context_menu(self.ent_email)
|
|
|
|
ttk.Label(frame, text="App Password:").grid(row=3, column=0, sticky='w', pady=5)
|
|
self.ent_pass = ttk.Entry(frame, width=30, show="*")
|
|
self.ent_pass.insert(0, cfg.get("password", ""))
|
|
self.ent_pass.grid(row=3, column=1, sticky='w', pady=5)
|
|
self.add_context_menu(self.ent_pass)
|
|
|
|
btn_save_cfg = ttk.Button(frame, text="Save Settings", command=self.save_config)
|
|
btn_save_cfg.grid(row=4, column=1, sticky='e', pady=15)
|
|
|
|
def save_config(self):
|
|
self.db["smtp_settings"] = {
|
|
"server": self.ent_server.get(),
|
|
"port": int(self.ent_port.get()),
|
|
"email": self.ent_email.get(),
|
|
"password": self.ent_pass.get()
|
|
}
|
|
self.save_database()
|
|
|
|
self.ent_custom_sender.delete(0, tk.END)
|
|
self.ent_custom_sender.insert(0, self.ent_email.get())
|
|
|
|
messagebox.showinfo("Success", "SMTP configurations saved.")
|
|
|
|
def refresh_contact_list(self):
|
|
for item in self.tree.get_children():
|
|
self.tree.delete(item)
|
|
|
|
search_term = self.ent_search.get().strip().lower()
|
|
|
|
for c in self.db.get("contacts", []):
|
|
call = c.get("callsign", "").lower()
|
|
name = c.get("name", "").lower()
|
|
email = c.get("email", "").lower()
|
|
tags = c.get("tags", "").lower()
|
|
|
|
if search_term and (search_term not in call and search_term not in name and search_term not in email and search_term not in tags):
|
|
continue
|
|
|
|
e_ok = "YES" if c.get("receive_email", True) else "NO"
|
|
t_ok = "YES" if c.get("receive_text", True) else "NO"
|
|
|
|
self.tree.insert("", "end", values=(c.get("callsign", ""), c.get("name", ""), c.get("tags", ""), c.get("email", ""), c.get("phone_gateway", ""), e_ok, t_ok))
|
|
|
|
def import_google_csv(self):
|
|
import csv
|
|
file_path = filedialog.askopenfilename(filetypes=[("CSV Files", "*.csv")])
|
|
if not file_path:
|
|
return
|
|
|
|
try:
|
|
with open(file_path, mode='r', encoding='utf-8-sig') as f:
|
|
reader = csv.DictReader(f)
|
|
headers = {k.lower(): k for k in reader.fieldnames}
|
|
|
|
name_key = headers.get('name') or headers.get('given name')
|
|
first_name_key = headers.get('given name') or headers.get('first name')
|
|
email_key = headers.get('e-mail 1 - value') or headers.get('email 1 - value') or headers.get('email')
|
|
phone_key = headers.get('phone 1 - value') or headers.get('phone')
|
|
notes_key = headers.get('notes')
|
|
group_key = headers.get('group membership')
|
|
|
|
imported_count = 0
|
|
for row in reader:
|
|
name = row.get(name_key, "").strip() if name_key else ""
|
|
first_name = row.get(first_name_key, "").strip() if first_name_key else ""
|
|
email = row.get(email_key, "").strip() if email_key else ""
|
|
phone = row.get(phone_key, "").strip() if phone_key else ""
|
|
notes = row.get(notes_key, "").strip() if notes_key else ""
|
|
g_membership = row.get(group_key, "").strip() if group_key else ""
|
|
|
|
if not name and not email:
|
|
continue
|
|
|
|
tags_detected = []
|
|
g_upper = g_membership.upper()
|
|
n_upper = notes.upper()
|
|
|
|
if "ARES" in g_upper or "ARES" in n_upper:
|
|
tags_detected.append("ARES")
|
|
if "RACES" in g_upper or "RACES" in n_upper:
|
|
tags_detected.append("RACES")
|
|
if "SKYWARN" in g_upper or "SKYWARN" in n_upper:
|
|
tags_detected.append("SKYWARN")
|
|
if "LEADER" in g_upper or "LEADER" in n_upper or "EC" in g_upper:
|
|
tags_detected.append("LEADER")
|
|
|
|
callsign = ""
|
|
if notes and ("/" in notes or len(notes) <= 6 and notes.isalnum()):
|
|
callsign = notes.upper()
|
|
elif "[" in name and "]" in name:
|
|
callsign = name[name.find("[")+1:name.find("]")].upper()
|
|
name = name.split("[")[0].strip()
|
|
|
|
if not callsign and first_name:
|
|
callsign = first_name
|
|
|
|
clean_phone = ''.join(c for c in phone if c.isdigit())
|
|
gateway = ""
|
|
if len(clean_phone) == 10:
|
|
gateway = f"{clean_phone}@vtext.com"
|
|
|
|
if not any(c['email'] == email for c in self.db['contacts'] if email):
|
|
self.db['contacts'].append({
|
|
"callsign": callsign if callsign else "VOLUNTEER",
|
|
"name": name,
|
|
"email": email,
|
|
"phone_gateway": gateway,
|
|
"tags": ", ".join(tags_detected),
|
|
"receive_email": True,
|
|
"receive_text": True if gateway else False
|
|
})
|
|
imported_count += 1
|
|
|
|
self.save_database()
|
|
self.refresh_contact_list()
|
|
self.update_dispatch_selectors()
|
|
messagebox.showinfo("Import Complete", f"Successfully imported {imported_count} contacts.")
|
|
except Exception as e:
|
|
messagebox.showerror("Import Error", f"Failed to parse Google Contacts CSV:\n{e}")
|
|
|
|
# --- TRANSMIT LAYERS ---
|
|
def process_dispatch_request(self):
|
|
cfg = self.db.get("smtp_settings", {})
|
|
if not cfg.get("email") or not cfg.get("password"):
|
|
messagebox.showwarning("Setup Required", "Please configure your SMTP credentials.")
|
|
return
|
|
|
|
raw_body = self.txt_message.get('1.0', tk.END).strip()
|
|
if not raw_body:
|
|
messagebox.showwarning("Empty Message", "Cannot send an empty alert dispatch text.")
|
|
return
|
|
|
|
# Plain Text Version without the unmonitored header
|
|
plain_body = raw_body
|
|
|
|
# HTML Version without the unmonitored header
|
|
html_body = raw_body.replace("\n", "<br>")
|
|
|
|
# Fetch dynamically typed sender address from form UI
|
|
display_sender = self.ent_custom_sender.get().strip()
|
|
if not display_sender:
|
|
messagebox.showwarning("Sender Address Missing", "Please provide a valid sender verification string.")
|
|
return
|
|
|
|
send_email_globally = self.dispatch_send_email.get()
|
|
send_text_globally = self.dispatch_send_text.get()
|
|
|
|
if not send_email_globally and not send_text_globally:
|
|
messagebox.showwarning("Selection Missing", "Please check at least one transmission mode to send.")
|
|
return
|
|
|
|
mode = self.target_mode.get()
|
|
target_list = []
|
|
|
|
if mode == "all":
|
|
target_list = self.db.get("contacts", [])
|
|
elif mode == "group":
|
|
selected_tag = self.cb_groups.get().strip().upper()
|
|
for c in self.db.get("contacts", []):
|
|
if c.get("tags"):
|
|
c_tags = [t.strip().upper() for t in c["tags"].split(",") if t.strip()]
|
|
if selected_tag in c_tags:
|
|
target_list.append(c)
|
|
elif mode == "individual":
|
|
selected_indiv = self.cb_individuals.get()
|
|
found = False
|
|
for c in self.db.get("contacts", []):
|
|
display_str = f"{c.get('callsign', 'VOLUNTEER')} - {c.get('name', '')} ({c.get('email', '')})"
|
|
if display_str == selected_indiv:
|
|
target_list = [c]
|
|
found = True
|
|
break
|
|
if not found:
|
|
messagebox.showwarning("Invalid Selection", "Please choose a valid individual entry.")
|
|
return
|
|
|
|
all_recipients = []
|
|
for contact in target_list:
|
|
if send_email_globally and contact.get("receive_email", True) and contact.get("email"):
|
|
all_recipients.append(contact["email"])
|
|
if send_text_globally and contact.get("receive_text", True) and contact.get("phone_gateway"):
|
|
all_recipients.append(contact["phone_gateway"])
|
|
|
|
all_recipients = list(set(all_recipients))
|
|
|
|
if not all_recipients:
|
|
messagebox.showwarning("No Recipients", "No targets available under selected permissions constraints.")
|
|
return
|
|
|
|
payload_tuple = (plain_body, html_body)
|
|
|
|
if self.sched_choice.get() == "now":
|
|
self.execute_transmission(all_recipients, payload_tuple, cfg, display_sender, None)
|
|
else:
|
|
date_str = self.ent_sched_date.get().strip()
|
|
time_str = self.ent_sched_time.get().strip()
|
|
|
|
try:
|
|
target_datetime = datetime.strptime(f"{date_str} {time_str}", "%m-%d-%y %H:%M")
|
|
delta_seconds = (target_datetime - datetime.now()).total_seconds()
|
|
|
|
if delta_seconds <= 0:
|
|
messagebox.showwarning("Invalid Time", "The scheduled time must be in the future.")
|
|
return
|
|
|
|
self.task_counter += 1
|
|
tid = f"TASK_{self.task_counter}"
|
|
|
|
t = threading.Timer(delta_seconds, self.execute_transmission, args=[all_recipients, payload_tuple, cfg, display_sender, tid])
|
|
|
|
self.active_schedulers[tid] = {
|
|
"timer": t,
|
|
"time": target_datetime.strftime("%m-%d-%y %H:%M:%S"),
|
|
"recipients_count": len(all_recipients)
|
|
}
|
|
|
|
t.start()
|
|
self.lbl_queue_status.config(text=f"Pending Alerts: {len(self.active_schedulers)}")
|
|
messagebox.showinfo("Alert Scheduled", f"Dispatch safely added to internal system queue.")
|
|
except ValueError:
|
|
messagebox.showerror("Format Error", "Date syntax must follow MM-DD-YY and time HH:MM format parameters.")
|
|
|
|
def execute_transmission(self, recipients, body_tuple, cfg, sender_override, task_id):
|
|
try:
|
|
plain_text, html_markup = body_tuple
|
|
|
|
if int(cfg["port"]) == 465:
|
|
server = smtplib.SMTP_SSL(cfg["server"], cfg["port"])
|
|
else:
|
|
server = smtplib.SMTP(cfg["server"], cfg["port"])
|
|
server.starttls()
|
|
|
|
server.login(cfg["email"], cfg["password"])
|
|
|
|
for rec in recipients:
|
|
msg = MIMEMultipart('alternative')
|
|
msg['From'] = sender_override
|
|
msg['To'] = rec
|
|
msg['Subject'] = "CCAR: MESSAGE ALERT PORTAL"
|
|
|
|
part1 = MIMEText(plain_text, 'plain')
|
|
part2 = MIMEText(html_markup, 'html')
|
|
msg.attach(part1)
|
|
msg.attach(part2)
|
|
|
|
server.send_message(msg)
|
|
|
|
server.quit()
|
|
|
|
if task_id and task_id in self.active_schedulers:
|
|
del self.active_schedulers[task_id]
|
|
self.root.after(0, self.update_queue_ui_label)
|
|
else:
|
|
messagebox.showinfo("Success", f"Immediate dispatch completed to {len(recipients)} endpoints.")
|
|
except Exception as e:
|
|
if threading.current_thread() == threading.main_thread():
|
|
messagebox.showerror("Transmission Failure", f"An error occurred while sending:\n{e}")
|
|
else:
|
|
print(f"[BACKGROUND DETECT WORKER ERROR]: {e}")
|
|
|
|
def update_queue_ui_label(self):
|
|
self.lbl_queue_status.config(text=f"Pending Alerts: {len(self.active_schedulers)}")
|
|
messagebox.showinfo("Queue System Update", "A scheduled group dispatch has deployed successfully.")
|
|
|
|
# --- PENDING QUEUE CANCELLATION SUB-SYSTEM ---
|
|
def open_queue_manager(self):
|
|
q_win = tk.Toplevel(self.root)
|
|
q_win.title("Active Task Queue Manager")
|
|
q_win.geometry("500x350")
|
|
q_win.grab_set()
|
|
|
|
ttk.Label(q_win, text="Active Pending Deployments:", font=('Helvetica', 10, 'bold')).pack(anchor='w', padx=10, pady=10)
|
|
|
|
lb_queue = tk.Listbox(q_win, selectmode="single", font=('Consolas', 10))
|
|
lb_queue.pack(fill='both', expand=True, padx=10, pady=5)
|
|
|
|
index_to_task_id = {}
|
|
|
|
for idx, (tid, details) in enumerate(self.active_schedulers.items()):
|
|
display_str = f"[{tid}] Time: {details['time']} | Recipients: {details['recipients_count']}"
|
|
lb_queue.insert(tk.END, display_str)
|
|
index_to_task_id[idx] = tid
|
|
|
|
def cancel_selected_task():
|
|
selection = lb_queue.curselection()
|
|
if not selection:
|
|
messagebox.showwarning("No Selection", "Please click a task inside the list viewport window first.")
|
|
return
|
|
|
|
chosen_idx = selection[0]
|
|
target_tid = index_to_task_id[chosen_idx]
|
|
|
|
task_dict = self.active_schedulers[target_tid]
|
|
task_dict["timer"].cancel()
|
|
|
|
del self.active_schedulers[target_tid]
|
|
|
|
self.lbl_queue_status.config(text=f"Pending Alerts: {len(self.active_schedulers)}")
|
|
q_win.destroy()
|
|
messagebox.showinfo("Task Aborted", f"Successfully terminated alert string identifier {target_tid} safely.")
|
|
|
|
btn_cancel = ttk.Button(q_win, text="Cancel Selected Alert", command=cancel_selected_task)
|
|
btn_cancel.pack(pady=10)
|
|
|
|
if __name__ == "__main__":
|
|
root = tk.Tk()
|
|
app = DispatchApp(root)
|
|
root.mainloop() |