From c87f564e93aafd597cc2d9c34023b69e46d27f42 Mon Sep 17 00:00:00 2001 From: n3mel Date: Sat, 15 Aug 2026 13:57:28 -0400 Subject: [PATCH] Upload files to "/" --- contacts_db.json | 451 +++++++++++++++++++++++++ dispatch.py | 833 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1284 insertions(+) create mode 100644 contacts_db.json create mode 100644 dispatch.py diff --git a/contacts_db.json b/contacts_db.json new file mode 100644 index 0000000..ef67759 --- /dev/null +++ b/contacts_db.json @@ -0,0 +1,451 @@ +{ + "smtp_settings": { + "server": "smtp.gmail.com", + "port": 587, + "email": "alert.w3eoc@gmail.com", + "password": "krgm gdyc faud nwou" + }, + "contacts": [ + { + "callsign": "N3FLL", + "name": "Frank", + "tags": "ARES, NBEMS, ADMIN", + "email": "frankrocap@protonmail.com", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "K3CIM", + "name": "Alex", + "tags": "ARES", + "email": "ka3cim@gmail.com", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "K3CWI", + "name": "Wayne", + "tags": "ARES", + "email": "wayne2232@yahoo.com", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "K3EUI", + "name": "Barry 2", + "tags": "ARES", + "email": "k3euibarry@gmail.com", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "K3FYV", + "name": "Barry1", + "tags": "ARES, ADMIN", + "email": "k3fyvradio@gmail.com", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "K3JSE", + "name": "Andy", + "tags": "ARES, ADMIN, WORK", + "email": "k3jse@coolioh.com", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "K3JSP", + "name": "Scott", + "tags": "ARES", + "email": "scott@thislink.com", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "K3XY", + "name": "Mark", + "tags": "ARES", + "email": "mark3xy@gmail.com", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "K3YK", + "name": "Dave", + "tags": "ARES", + "email": "doc62@comcast.net", + "phone_gateway": "4846382333@vtext.com", + "receive_email": true, + "receive_text": true + }, + { + "callsign": "K3ZA", + "name": "Tony", + "tags": "ARES", + "email": "tony.k3za@verizon.net", + "phone_gateway": "", + "receive_email": true, + "receive_text": true + }, + { + "callsign": "K5HAL", + "name": "Cullen", + "tags": "ARES", + "email": "cullenl@aol.com", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "KA2DDO", + "name": "Andrew", + "tags": "ARES, ADMIN", + "email": "ka2ddo@arrl.net", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "KB3FCJ", + "name": "Tim", + "tags": "ARES", + "email": "Totally65@yahoo.com", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "KB3PCY", + "name": "Martin", + "tags": "ARES", + "email": "kb3pcy@gmail.com", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "KB3PKJ", + "name": "Mike", + "tags": "ARES", + "email": "DiDomenico.MikeJ@gmail.com", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "KB3YCK", + "name": "Dave", + "tags": "ARES", + "email": "kb3yck@icloud.com", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "KB3ZIM", + "name": "Bobb", + "tags": "ARES", + "email": "bobbrayner@gmail.com", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "KC3HLK", + "name": "Justin", + "tags": "ARES", + "email": "JDK370@gmail.com", + "phone_gateway": "6102412444@txt.att.net", + "receive_email": true, + "receive_text": true + }, + { + "callsign": "KC3ITD", + "name": "John", + "tags": "ARES", + "email": "JR_KC3ITD@proton.me", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "KC3NVQ", + "name": "", + "email": "RKLawlor@gmail.com", + "phone_gateway": "", + "tags": "ARES", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "KC3QGE", + "name": "Fran", + "tags": "ARES", + "email": "fran100b@zoominternet.net", + "phone_gateway": "6102461449@vtext.com", + "receive_email": true, + "receive_text": true + }, + { + "callsign": "KC3VHO", + "name": "Rich", + "tags": "ARES, WORK", + "email": "rich@vansleet.com", + "phone_gateway": "4846953301@txt.att.com", + "receive_email": true, + "receive_text": true + }, + { + "callsign": "KC3YSM", + "name": "Steve", + "tags": "ARES", + "email": "smkane210@gmail.com", + "phone_gateway": "4844591938@vtext.com", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "KC3ZCC", + "name": "Rob", + "tags": "ARES", + "email": "rade@radenet.com", + "phone_gateway": "6109990841@txt.att.net", + "receive_email": true, + "receive_text": true + }, + { + "callsign": "KD3BAN", + "name": "", + "tags": "ARES", + "email": "ol.calum@gmail.com", + "phone_gateway": "2676581381@vtext.com", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "KE3HG", + "name": "Rich", + "tags": "ARES, ADMIN", + "email": "ke3hg@comcast.net", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "N3EWF", + "name": "Rick", + "tags": "ARES", + "email": "rickyems@aol.com", + "phone_gateway": "6103509899@vtext.com", + "receive_email": true, + "receive_text": true + }, + { + "callsign": "N3FIH", + "name": "Terry", + "tags": "ARES", + "email": "terry@theryanzoo.com", + "phone_gateway": "4847988885@vtext.com", + "receive_email": true, + "receive_text": true + }, + { + "callsign": "N3ILS", + "name": "Travis", + "tags": "ARES", + "email": "N3ILS@liskfamily.com", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "N3MEL", + "name": "Glenn", + "tags": "ARES, ADMIN", + "email": "n3mel.epa@gmail.com", + "phone_gateway": "6106377492@vtext.com", + "receive_email": true, + "receive_text": true + }, + { + "callsign": "N3PEG", + "name": "", + "tags": "ARES", + "email": "PegDalyPA@msn.com", + "phone_gateway": "2672544208@vtext.com", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "N4SEN", + "name": "", + "email": "kevdpl@gmail.com", + "phone_gateway": "", + "tags": "ARES", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "NX3L", + "name": "", + "email": "joseph356@comcast.net", + "phone_gateway": "", + "tags": "ARES", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "W3BIF", + "name": "", + "email": "w3bif@comcast.net", + "phone_gateway": "", + "tags": "ARES", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "W3FES", + "name": "", + "email": "fredsheehansr@verizon.net", + "phone_gateway": "", + "tags": "ARES", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "W3JBS", + "name": "", + "email": "jbstratton@aol.com", + "phone_gateway": "", + "tags": "ARES", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "W3JFS", + "name": "", + "email": "w3jfs@protonmail.ch", + "phone_gateway": "", + "tags": "ARES", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "W3OMW", + "name": "", + "email": "steamerone@mac.com", + "phone_gateway": "", + "tags": "ARES", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "WA3BXE", + "name": "", + "tags": "ARES, ADMIN", + "email": "ggmetz66@gmail.com", + "phone_gateway": "6103229294@txt.att.net", + "receive_email": true, + "receive_text": true + }, + { + "callsign": "WA3NOA", + "name": "", + "tags": "ARES, JIM, ADMIN", + "email": "wa3noa@w3eoc.org", + "phone_gateway": "6102174271@vtext.com", + "receive_email": true, + "receive_text": true + }, + { + "callsign": "WB3GCK", + "name": "", + "tags": "ARES, ADMIN", + "email": "craig.labarge@gmail.com", + "phone_gateway": "6102834566@vtext.com", + "receive_email": true, + "receive_text": true + }, + { + "callsign": "WN3A", + "name": "", + "tags": "ARES, WORK", + "email": "jeff@wn3a.net", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "K3HXW", + "name": "", + "tags": "RCOMM2", + "email": "KC3HXW@gmail.com", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "KC3TMT", + "name": "", + "tags": "RCOMM2", + "email": "tcostello@Upstreme.com", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "KD3DEK", + "name": "Zack Elwyn", + "tags": "ARES", + "email": "zach@qwertyuiop.us", + "phone_gateway": "2157155133@tmomail.net", + "receive_email": true, + "receive_text": true + }, + { + "callsign": "WA3NOA-1", + "name": "Jim Harris", + "tags": "ARES, JIM, ADMIN, WORK", + "email": "jim.harris1@verizon.net", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "WA3NOA-2", + "name": "Jim Harris", + "tags": "JIM", + "email": "jim@ohmstours.net", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "WA3NOA-3", + "name": "Jim Harris", + "tags": "JIM", + "email": "wa3noa@arrl.net", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + }, + { + "callsign": "WA3NOA-4", + "name": "Jim Harris", + "tags": "JIM", + "email": "wa3noa@gmail.com", + "phone_gateway": "", + "receive_email": true, + "receive_text": false + } + ] +} \ No newline at end of file diff --git a/dispatch.py b/dispatch.py new file mode 100644 index 0000000..fa3f51b --- /dev/null +++ b/dispatch.py @@ -0,0 +1,833 @@ +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": "glenn@example.com", + "phone_gateway": "1234567890@vtext.com", + "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("<>") + + def copy_text(): + widget.event_generate("<>") + + def paste_text(): + widget.event_generate("<>") + + 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("", show_popup) + # macOS Right-Click / Ctrl-Click + widget.bind("", show_popup) + widget.bind("", 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("", self.handle_select_all) + self.txt_message.bind("", self.handle_copy) + self.txt_message.bind("", self.handle_paste) + self.txt_message.bind("", 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("<>") + return "break" + + def handle_paste(self, event): + self.txt_message.event_generate("<>") + return "break" + + def handle_cut(self, event): + self.txt_message.event_generate("<>") + 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("", 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("<>", 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", "
") + + # 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() \ No newline at end of file