diff --git a/bin/pyqso b/bin/pyqso index 6200861..df3b1bc 100755 --- a/bin/pyqso +++ b/bin/pyqso @@ -41,93 +41,95 @@ from pyqso.toolbar import * from pyqso.toolbox import * from pyqso.preferences_dialog import * + class PyQSO(Gtk.Window): - """ The PyQSO application class. """ - - def __init__(self, logbook_path=None): - """ Set up the main (root) window, start the event loop, and open a logbook (if the logbook's path is specified by the user in the command line). - - :arg str logbook_path: An optional argument containing the path of the logbook file to open. If no value is provided, this defaults to None and no logbook is opened. - """ - # Call the constructor of the super class (Gtk.Window) - Gtk.Window.__init__(self, title="PyQSO") + """ The PyQSO application class. """ - # Check that the directory for holding PyQSO configuration files exists. If it doesn't, create it now. - try: - os.makedirs(os.path.expanduser('~/.config/pyqso'), exist_ok=True) - except Exception as e: - logging.error("An error occurred whilst creating a directory for PyQSO configuration files. Try creating the directory '~/.config/pyqso' manually.") - logging.exception(e) + def __init__(self, logbook_path=None): + """ Set up the main (root) window, start the event loop, and open a logbook (if the logbook's path is specified by the user in the command line). - # Get any application-specific preferences from the configuration file - config = configparser.ConfigParser() + :arg str logbook_path: An optional argument containing the path of the logbook file to open. If no value is provided, this defaults to None and no logbook is opened. + """ - # Check that the configuration file actually exists (and is readable) - # otherwise, we will resort to the defaults. - have_config = (config.read(os.path.expanduser("~/.config/pyqso/preferences.ini")) != []) + # Call the constructor of the super class (Gtk.Window) + Gtk.Window.__init__(self, title="PyQSO") - self.set_size_request(800, 600) # Default to an 800 x 600 resolution. - self.set_position(Gtk.WindowPosition.CENTER) - possible_icon_paths = [os.path.join(pyqso_path, "icons", "log_64x64.png")] - for icon_path in possible_icon_paths: - try: - self.set_icon_from_file(icon_path) - except Exception as error: - print(error.message) + # Check that the directory for holding PyQSO configuration files exists. If it doesn't, create it now. + try: + os.makedirs(os.path.expanduser('~/.config/pyqso'), exist_ok=True) + except Exception as e: + logging.error("An error occurred whilst creating a directory for PyQSO configuration files. Try creating the directory '~/.config/pyqso' manually.") + logging.exception(e) - # Kills the application if the close button is clicked on the main window itself. - self.connect("delete-event", Gtk.main_quit) - - vbox_outer = Gtk.VBox() - self.add(vbox_outer) + # Get any application-specific preferences from the configuration file + config = configparser.ConfigParser() - self.statusbar = Gtk.Statusbar() - context_id = self.statusbar.get_context_id("Status") - self.statusbar.push(context_id, "No logbook is currently open.") - - # Create a Logbook so we can add/remove/edit logs and records, - # once connected to the SQLite database. - self.logbook = Logbook(self) - self.logbook.set_scrollable(True) + # Check that the configuration file actually exists (and is readable) + # otherwise, we will resort to the defaults. + have_config = (config.read(os.path.expanduser("~/.config/pyqso/preferences.ini")) != []) - self.toolbox = Toolbox(self) + self.set_size_request(800, 600) # Default to an 800 x 600 resolution. + self.set_position(Gtk.WindowPosition.CENTER) + possible_icon_paths = [os.path.join(pyqso_path, "icons", "log_64x64.png")] + for icon_path in possible_icon_paths: + try: + self.set_icon_from_file(icon_path) + except Exception as error: + print(error.message) - # Set up menu and tool bars - # These classes depend on the Logbook and Toolbox class, - # so pack the logbook and toolbox after the menu and toolbar. - self.menu = Menu(self) - self.toolbar = Toolbar(self) + # Kills the application if the close button is clicked on the main window itself. + self.connect("delete-event", Gtk.main_quit) - vbox_outer.pack_start(self.menu, False, False, 0) - vbox_outer.pack_start(self.toolbar, False, False, 0) - vbox_outer.pack_start(self.logbook, True, True, 0) - vbox_outer.pack_start(self.toolbox, True, True, 0) - vbox_outer.pack_start(self.statusbar, False, False, 0) + vbox_outer = Gtk.VBox() + self.add(vbox_outer) - self.show_all() + self.statusbar = Gtk.Statusbar() + context_id = self.statusbar.get_context_id("Status") + self.statusbar.push(context_id, "No logbook is currently open.") - if(have_config): - if(config.get("general", "show_toolbox") == "False"): + # Create a Logbook so we can add/remove/edit logs and records, + # once connected to the SQLite database. + self.logbook = Logbook(self) + self.logbook.set_scrollable(True) + + self.toolbox = Toolbox(self) + + # Set up menu and tool bars + # These classes depend on the Logbook and Toolbox class, + # so pack the logbook and toolbox after the menu and toolbar. + self.menu = Menu(self) + self.toolbar = Toolbar(self) + + vbox_outer.pack_start(self.menu, False, False, 0) + vbox_outer.pack_start(self.toolbar, False, False, 0) + vbox_outer.pack_start(self.logbook, True, True, 0) + vbox_outer.pack_start(self.toolbox, True, True, 0) + vbox_outer.pack_start(self.statusbar, False, False, 0) + + self.show_all() + + if(have_config): + if(config.get("general", "show_toolbox") == "False"): + self.toolbox.toggle_visible_callback() + else: + # Hide the Toolbox by default self.toolbox.toggle_visible_callback() - else: - # Hide the Toolbox by default - self.toolbox.toggle_visible_callback() - if(logbook_path is not None): - self.logbook.open(widget=None, path=logbook_path) + if(logbook_path is not None): + self.logbook.open(widget=None, path=logbook_path) - return + return - def show_about(self, widget): - """ Show the About dialog, which includes license information. """ - about = Gtk.AboutDialog() - about.set_modal(True) - about.set_transient_for(parent=self) - about.set_program_name("PyQSO") - about.set_version("0.3") - about.set_authors(["Christian T. Jacobs (2E0ICL)"]) - about.set_license("""This program is free software: you can redistribute it and/or modify + def show_about(self, widget): + """ Show the About dialog, which includes license information. """ + about = Gtk.AboutDialog() + about.set_modal(True) + about.set_transient_for(parent=self) + about.set_program_name("PyQSO") + about.set_version("0.3") + about.set_authors(["Christian T. Jacobs (2E0ICL)"]) + about.set_license("""This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. @@ -139,45 +141,44 @@ GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see .""") - about.set_comments("PyQSO: A contact logging tool for amateur radio operators.") - possible_icon_paths = [os.path.join(pyqso_path, "icons", "log_64x64.png")] - for icon_path in possible_icon_paths: - try: - about.set_logo(GdkPixbuf.Pixbuf.new_from_file_at_scale(icon_path, 64, 64, False)) - except Exception as error: - print(error.message) - about.run() - about.destroy() - return + about.set_comments("PyQSO: A contact logging tool for amateur radio operators.") + possible_icon_paths = [os.path.join(pyqso_path, "icons", "log_64x64.png")] + for icon_path in possible_icon_paths: + try: + about.set_logo(GdkPixbuf.Pixbuf.new_from_file_at_scale(icon_path, 64, 64, False)) + except Exception as error: + print(error.message) + about.run() + about.destroy() + return - def show_preferences(self, widget): - """ Show the Preferences dialog. Any changes made by the user after clicking the 'Ok' button are saved in the .cfg file. """ - preferences = PreferencesDialog(self) - response = preferences.run() - if(response == Gtk.ResponseType.OK): - preferences.commit() - preferences.destroy() - return + def show_preferences(self, widget): + """ Show the Preferences dialog. Any changes made by the user after clicking the 'Ok' button are saved in the .cfg file. """ + preferences = PreferencesDialog(self) + response = preferences.run() + if(response == Gtk.ResponseType.OK): + preferences.commit() + preferences.destroy() + return if(__name__ == "__main__"): - # Get any command line arguments - parser = argparse.ArgumentParser(prog="pyqso") - parser.add_argument("-d", "--debug", action="store_true", default=False, help="Enable debugging. All debugging messages will be written to pyqso.debug.") - parser.add_argument("-l", "--logbook", action="store", type=str, metavar="/path/to/my_logbook_file.db", default=None, help="Path to a Logbook file. If this file does not already exist, then it will be created.") - args = parser.parse_args() + # Get any command line arguments + parser = argparse.ArgumentParser(prog="pyqso") + parser.add_argument("-d", "--debug", action="store_true", default=False, help="Enable debugging. All debugging messages will be written to pyqso.debug.") + parser.add_argument("-l", "--logbook", action="store", type=str, metavar="/path/to/my_logbook_file.db", default=None, help="Path to a Logbook file. If this file does not already exist, then it will be created.") + args = parser.parse_args() - # Output debugging messages to a file - if(args.debug): - # Get the root logger - logger = logging.getLogger() - logger.setLevel(logging.DEBUG) - # Add a file handler - handler = logging.FileHandler("pyqso.debug", mode="w") - formatter = logging.Formatter(fmt="%(asctime)s %(levelname)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S") - handler.setFormatter(formatter) - logger.addHandler(handler) - - signal.signal(signal.SIGINT, signal.SIG_DFL) # Exit PyQSO if a SIGINT signal is captured. - application = PyQSO(args.logbook) # Populate the main window and show it - Gtk.main() # Start up the event loop! + # Output debugging messages to a file + if(args.debug): + # Get the root logger + logger = logging.getLogger() + logger.setLevel(logging.DEBUG) + # Add a file handler + handler = logging.FileHandler("pyqso.debug", mode="w") + formatter = logging.Formatter(fmt="%(asctime)s %(levelname)s: %(message)s", datefmt="%Y-%m-%d %H:%M:%S") + handler.setFormatter(formatter) + logger.addHandler(handler) + signal.signal(signal.SIGINT, signal.SIG_DFL) # Exit PyQSO if a SIGINT signal is captured. + application = PyQSO(args.logbook) # Populate the main window and show it + Gtk.main() # Start up the event loop! diff --git a/pyqso/adif.py b/pyqso/adif.py index f82b9a9..996263e 100644 --- a/pyqso/adif.py +++ b/pyqso/adif.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python3 # Copyright (C) 2012 Christian T. Jacobs. @@ -26,54 +26,54 @@ import configparser from os.path import expanduser # ADIF field names and their associated data types available in PyQSO. -AVAILABLE_FIELD_NAMES_TYPES = {"CALL": "S", - "QSO_DATE": "D", - "TIME_ON": "T", - "FREQ": "N", - "BAND": "E", - "MODE": "E", - "SUBMODE": "E", - "TX_PWR": "N", - "RST_SENT": "S", - "RST_RCVD": "S", - "QSL_SENT": "S", - "QSL_RCVD": "S", - "NOTES": "M", - "NAME": "S", - "ADDRESS": "S", - "STATE": "S", - "COUNTRY": "S", - "DXCC": "N", - "CQZ": "N", - "ITUZ": "N", - "IOTA": "C"} +AVAILABLE_FIELD_NAMES_TYPES = {"CALL": "S", + "QSO_DATE": "D", + "TIME_ON": "T", + "FREQ": "N", + "BAND": "E", + "MODE": "E", + "SUBMODE": "E", + "TX_PWR": "N", + "RST_SENT": "S", + "RST_RCVD": "S", + "QSL_SENT": "S", + "QSL_RCVD": "S", + "NOTES": "M", + "NAME": "S", + "ADDRESS": "S", + "STATE": "S", + "COUNTRY": "S", + "DXCC": "N", + "CQZ": "N", + "ITUZ": "N", + "IOTA": "C"} # Note: The logbook uses the ADIF field names for the database column names. # This list is used to display the columns in a logical order. -AVAILABLE_FIELD_NAMES_ORDERED = ["CALL", "QSO_DATE", "TIME_ON", "FREQ", "BAND", "MODE", "SUBMODE", "TX_PWR", +AVAILABLE_FIELD_NAMES_ORDERED = ["CALL", "QSO_DATE", "TIME_ON", "FREQ", "BAND", "MODE", "SUBMODE", "TX_PWR", "RST_SENT", "RST_RCVD", "QSL_SENT", "QSL_RCVD", "NOTES", "NAME", "ADDRESS", "STATE", "COUNTRY", "DXCC", "CQZ", "ITUZ", "IOTA"] # Define the more user-friendly versions of the field names. -AVAILABLE_FIELD_NAMES_FRIENDLY = {"CALL":"Callsign", - "QSO_DATE":"Date", - "TIME_ON":"Time", - "FREQ":"Frequency (MHz)", - "BAND":"Band", - "MODE":"Mode", - "SUBMODE":"Submode", - "TX_PWR":"TX Power (W)", - "RST_SENT":"RST Sent", - "RST_RCVD":"RST Received", - "QSL_SENT":"QSL Sent", - "QSL_RCVD":"QSL Received", - "NOTES":"Notes", - "NAME":"Name", - "ADDRESS":"Address", - "STATE":"State", - "COUNTRY":"Country", - "DXCC":"DXCC", - "CQZ":"CQ Zone", - "ITUZ":"ITU Zone", - "IOTA":"IOTA Designator"} +AVAILABLE_FIELD_NAMES_FRIENDLY = {"CALL": "Callsign", + "QSO_DATE": "Date", + "TIME_ON": "Time", + "FREQ": "Frequency (MHz)", + "BAND": "Band", + "MODE": "Mode", + "SUBMODE": "Submode", + "TX_PWR": "TX Power (W)", + "RST_SENT": "RST Sent", + "RST_RCVD": "RST Received", + "QSL_SENT": "QSL Sent", + "QSL_RCVD": "QSL Received", + "NOTES": "Notes", + "NAME": "Name", + "ADDRESS": "Address", + "STATE": "State", + "COUNTRY": "Country", + "DXCC": "DXCC", + "CQZ": "CQ Zone", + "ITUZ": "ITU Zone", + "IOTA": "IOTA Designator"} # A: AwardList # B: Boolean @@ -88,93 +88,93 @@ AVAILABLE_FIELD_NAMES_FRIENDLY = {"CALL":"Callsign", DATA_TYPES = ["A", "B", "N", "S", "I", "D", "T", "M", "G", "L", "E"] # All the valid modes listed in the ADIF specification. This is a dictionary with the key-value pairs holding the MODE and SUBMODE(s) respectively. -MODES = {"":("",), - "AM":("",), - "ATV":("",), - "CHIP":("", "CHIP64", "CHIP128"), - "CLO":("",), - "CONTESTI":("",), - "CW":("", "PCW"), - "DIGITALVOICE":("",), - "DOMINO":("", "DOMINOEX", "DOMINOF"), - "DSTAR":("",), - "FAX":("",), - "FM":("",), - "FSK441":("",), - "HELL":("", "FMHELL", "FSKHELL", "HELL80", "HFSK", "PSKHELL"), - "ISCAT":("", "ISCAT-A", "ISCAT-B"), - "JT4":("", "JT4A", "JT4B", "JT4C", "JT4D", "JT4E", "JT4F", "JT4G"), - "JT6M":("",), - "JT9":("",), - "JT44":("",), - "JT65":("", "JT65A", "JT65B", "JT65B2", "JT65C", "JT65C2"), - "MFSK":("", "MFSK4", "MFSK8", "MFSK11", "MFSK16", "MFSK22", "MFSK31", "MFSK32", "MFSK64", "MFSK128"), - "MT63":("",), - "OLIVIA":("", "OLIVIA 4/125", "OLIVIA 4/250", "OLIVIA 8/250", "OLIVIA 8/500", "OLIVIA 16/500", "OLIVIA 16/1000", "OLIVIA 32/1000"), - "OPERA":("", "OPERA-BEACON", "OPERA-QSO"), - "PAC":("", "PAC2", "PAC3", "PAC4"), - "PAX":("", "PAX2"), - "PKT":("",), - "PSK":("", "FSK31", "PSK10", "PSK31", "PSK63", "PSK63F", "PSK125", "PSK250", "PSK500", "PSK1000", "PSKAM10", "PSKAM31", "PSKAM50", "PSKFEC31", "QPSK31", "QPSK63", "QPSK125", "QPSK250", "QPSK500"), - "PSK2K":("",), - "Q15":("",), - "ROS":("", "ROS-EME", "ROS-HF", "ROS-MF"), - "RTTY":("", "ASCI"), - "RTTYM":("",), - "SSB":("", "LSB", "USB"), - "SSTV":("",), - "THOR":("",), - "THRB":("", "THRBX"), - "TOR":("", "AMTORFEC", "GTOR"), - "V4":("",), - "VOI":("",), - "WINMOR":("",), - "WSPR":("",) +MODES = {"": ("",), + "AM": ("",), + "ATV": ("",), + "CHIP": ("", "CHIP64", "CHIP128"), + "CLO": ("",), + "CONTESTI": ("",), + "CW": ("", "PCW"), + "DIGITALVOICE": ("",), + "DOMINO": ("", "DOMINOEX", "DOMINOF"), + "DSTAR": ("",), + "FAX": ("",), + "FM": ("",), + "FSK441": ("",), + "HELL": ("", "FMHELL", "FSKHELL", "HELL80", "HFSK", "PSKHELL"), + "ISCAT": ("", "ISCAT-A", "ISCAT-B"), + "JT4": ("", "JT4A", "JT4B", "JT4C", "JT4D", "JT4E", "JT4F", "JT4G"), + "JT6M": ("",), + "JT9": ("",), + "JT44": ("",), + "JT65": ("", "JT65A", "JT65B", "JT65B2", "JT65C", "JT65C2"), + "MFSK": ("", "MFSK4", "MFSK8", "MFSK11", "MFSK16", "MFSK22", "MFSK31", "MFSK32", "MFSK64", "MFSK128"), + "MT63": ("",), + "OLIVIA": ("", "OLIVIA 4/125", "OLIVIA 4/250", "OLIVIA 8/250", "OLIVIA 8/500", "OLIVIA 16/500", "OLIVIA 16/1000", "OLIVIA 32/1000"), + "OPERA": ("", "OPERA-BEACON", "OPERA-QSO"), + "PAC": ("", "PAC2", "PAC3", "PAC4"), + "PAX": ("", "PAX2"), + "PKT": ("",), + "PSK": ("", "FSK31", "PSK10", "PSK31", "PSK63", "PSK63F", "PSK125", "PSK250", "PSK500", "PSK1000", "PSKAM10", "PSKAM31", "PSKAM50", "PSKFEC31", "QPSK31", "QPSK63", "QPSK125", "QPSK250", "QPSK500"), + "PSK2K": ("",), + "Q15": ("",), + "ROS": ("", "ROS-EME", "ROS-HF", "ROS-MF"), + "RTTY": ("", "ASCI"), + "RTTYM": ("",), + "SSB": ("", "LSB", "USB"), + "SSTV": ("",), + "THOR": ("",), + "THRB": ("", "THRBX"), + "TOR": ("", "AMTORFEC", "GTOR"), + "V4": ("",), + "VOI": ("",), + "WINMOR": ("",), + "WSPR": ("",) } # A dictionary of all the deprecated MODE values. -MODES_DEPRECATED = {"AMTORFEC":("",), - "ASCI":("",), - "CHIP64":("",), - "CHIP128":("",), - "DOMINOF":("",), - "FMHELL":("",), - "FSK31":("",), - "GTOR":("",), - "HELL80":("",), - "HFSK":("",), - "JT4A":("",), - "JT4B":("",), - "JT4C":("",), - "JT4D":("",), - "JT4E":("",), - "JT4F":("",), - "JT4G":("",), - "JT65A":("",), - "JT65B":("",), - "JT65C":("",), - "MFSK8":("",), - "MFSK16":("",), - "PAC2":("",), - "PAC3":("",), - "PAX2":("",), - "PCW":("",), - "PSK10":("",), - "PSK31":("",), - "PSK63":("",), - "PSK63F":("",), - "PSK125":("",), - "PSKAM10":("",), - "PSKAM31":("",), - "PSKAM50":("",), - "PSKFEC31":("",), - "PSKHELL":("",), - "QPSK31":("",), - "QPSK63":("",), - "QPSK125":("",), - "THRBX":("",) +MODES_DEPRECATED = {"AMTORFEC": ("",), + "ASCI": ("",), + "CHIP64": ("",), + "CHIP128": ("",), + "DOMINOF": ("",), + "FMHELL": ("",), + "FSK31": ("",), + "GTOR": ("",), + "HELL80": ("",), + "HFSK": ("",), + "JT4A": ("",), + "JT4B": ("",), + "JT4C": ("",), + "JT4D": ("",), + "JT4E": ("",), + "JT4F": ("",), + "JT4G": ("",), + "JT65A": ("",), + "JT65B": ("",), + "JT65C": ("",), + "MFSK8": ("",), + "MFSK16": ("",), + "PAC2": ("",), + "PAC3": ("",), + "PAX2": ("",), + "PCW": ("",), + "PSK10": ("",), + "PSK31": ("",), + "PSK63": ("",), + "PSK63F": ("",), + "PSK125": ("",), + "PSKAM10": ("",), + "PSKAM31": ("",), + "PSKAM50": ("",), + "PSKFEC31": ("",), + "PSKHELL": ("",), + "QPSK31": ("",), + "QPSK63": ("",), + "QPSK125": ("",), + "THRBX": ("",) } - + # Include all deprecated modes. MODES.update(MODES_DEPRECATED) @@ -185,378 +185,379 @@ BANDS_RANGES = [(None, None), (0.136, 0.137), (0.472, 0.479), (0.501, 0.504), (1 ADIF_VERSION = "3.0.4" + class ADIF: - """ The ADIF class supplies methods for reading, parsing, and writing log files in the Amateur Data Interchange Format (ADIF). - For more information, visit http://adif.org/ """ - - def __init__(self): - """ Initialise class for I/O of files using the Amateur Data Interchange Format (ADIF). """ - logging.debug("New ADIF instance created!") - - def read(self, path): - """ Read an ADIF file and parse it. - - :arg str path: The path to the ADIF file to read. - :returns: A list of dictionaries (one dictionary per QSO), with each dictionary containing field-value pairs, e.g. {FREQ:145.500, BAND:2M, MODE:FM}. - :rtype: list - :raises IOError: if the ADIF file does not exist or cannot be read (e.g. due to lack of read permissions). - """ - logging.debug("Reading in ADIF file with path: %s..." % path) - text = "" - try: - f = open(path, mode='r', errors="replace") - text = f.read() - f.close() # Close the file, otherwise "bad things" might happen! - except IOError as e: - logging.error("I/O error %d: %s" % (e.errno, e.strerror)) - except Exception as e: - logging.error("An error occurred when reading the ADIF file.") - logging.exception(e) + """ The ADIF class supplies methods for reading, parsing, and writing log files in the Amateur Data Interchange Format (ADIF). + For more information, visit http://adif.org/ """ - records = self._parse_adi(text) + def __init__(self): + """ Initialise class for I/O of files using the Amateur Data Interchange Format (ADIF). """ + logging.debug("New ADIF instance created!") - if(records == []): - logging.warning("No records found in the file. Empty file or wrong file type?") + def read(self, path): + """ Read an ADIF file and parse it. - return records - - def _parse_adi(self, text): - """ Parse some raw text (defined in the 'text' argument) for ADIF field data. - - :arg str text: The raw text from the ADIF file to parse. - :returns: A list of dictionaries (one dictionary per QSO). Each dictionary contains the field-value pairs, e.g. {FREQ:145.500, BAND:2M, MODE:FM}. - :rtype: list - """ + :arg str path: The path to the ADIF file to read. + :returns: A list of dictionaries (one dictionary per QSO), with each dictionary containing field-value pairs, e.g. {FREQ:145.500, BAND:2M, MODE:FM}. + :rtype: list + :raises IOError: if the ADIF file does not exist or cannot be read (e.g. due to lack of read permissions). + """ + logging.debug("Reading in ADIF file with path: %s..." % path) - logging.debug("Parsing text from the ADIF file...") + text = "" + try: + f = open(path, mode='r', errors="replace") + text = f.read() + f.close() # Close the file, otherwise "bad things" might happen! + except IOError as e: + logging.error("I/O error %d: %s" % (e.errno, e.strerror)) + except Exception as e: + logging.error("An error occurred when reading the ADIF file.") + logging.exception(e) - records = [] + records = self._parse_adi(text) - # ADIF-related configuration options - config = configparser.ConfigParser() - have_config = (config.read(expanduser('~/.config/pyqso/preferences.ini')) != []) - (section, option) = ("adif", "merge_comment") - if(have_config and config.has_option(section, option) and config.get(section, option) == "True"): - merge_comment = True - else: - merge_comment = False - - # Separate the text at the or markers. - tokens = re.split('(|)', text, flags=re.IGNORECASE) - tokens.pop() # Anything after the final marker should be ignored. - - # The header might tell us the number of records, but let's not assume - # this and simply ignore it instead (if it exists). - if(re.search('', text, flags=re.IGNORECASE) is not None): - # There is a header present, so let's ignore everything - # up to and including the marker. Note that - # re.search has been used here to handle any case sensitivity. - # Previously we were checking for . is also valid - # but wasn't being detected before. - while len(tokens) > 0: - t = tokens.pop(0) - if(re.match('', t, flags=re.IGNORECASE) is not None): - break - - n_eor = 0 - n_record = 0 - records = [] - pattern = re.compile('<(.*?):(\d*).*?>([^<\t\n\r\f\v]+)') - - for t in tokens: - if(re.match('', t, flags=re.IGNORECASE) is not None): - n_eor += 1 - continue - else: - n_record += 1 - # Each record will have field names and corresponding - # data entries. Store this in a dictionary. - # Note: This is based on the code written by OK4BX. - # (http://web.bxhome.org/blog/ok4bx/2012/05/adif-parser-python) - fields_and_data_dictionary = {} - fields_and_data = pattern.findall(t) - comment = None - for fd in fields_and_data: - # Let's force all field names to be in upper case. - # This will help us later when comparing the field names - # against the available field names in the ADIF specification. - field_name = fd[0].upper() - field_data = fd[2][:int(fd[1])] + if(records == []): + logging.warning("No records found in the file. Empty file or wrong file type?") - # Combo boxes are used later on and these are case sensitive, - # so adjust the field data accordingly. - if(field_name == "BAND"): - field_data = field_data.lower() - elif(field_name == "CALL" or field_name == "MODE" or field_name == "SUBMODE"): - field_data = field_data.upper() - elif(field_name == "COMMENT"): - # Keep a copy of the COMMENT field data, in case we want to merge - # it with the NOTES field. - comment = field_data - if(field_name in AVAILABLE_FIELD_NAMES_ORDERED): - field_data_type = AVAILABLE_FIELD_NAMES_TYPES[field_name] - if(self.is_valid(field_name, field_data, field_data_type)): - # Only add the field if it is a standard ADIF field and it holds valid data. - fields_and_data_dictionary[field_name] = field_data + return records - # Merge the COMMENT field with the NOTES field, if desired and applicable. - if(merge_comment): - if("NOTES" in list(fields_and_data_dictionary.keys()) and comment): - logging.debug("Merging COMMENT field with NOTES field...") - fields_and_data_dictionary["NOTES"] += "\\n" + comment - logging.debug("Merged fields.") - elif(comment): - # Create the NOTES entry, but only store the contents of the COMMENT field. - logging.debug("The COMMENT field is present, but not the NOTES field. The NOTES field will be created and will only hold the COMMENT.") - fields_and_data_dictionary["NOTES"] = comment - else: - pass - records.append(fields_and_data_dictionary) - - assert n_eor == n_record + def _parse_adi(self, text): + """ Parse some raw text (defined in the 'text' argument) for ADIF field data. + + :arg str text: The raw text from the ADIF file to parse. + :returns: A list of dictionaries (one dictionary per QSO). Each dictionary contains the field-value pairs, e.g. {FREQ:145.500, BAND:2M, MODE:FM}. + :rtype: list + """ + + logging.debug("Parsing text from the ADIF file...") + + records = [] + + # ADIF-related configuration options + config = configparser.ConfigParser() + have_config = (config.read(expanduser('~/.config/pyqso/preferences.ini')) != []) + (section, option) = ("adif", "merge_comment") + if(have_config and config.has_option(section, option) and config.get(section, option) == "True"): + merge_comment = True + else: + merge_comment = False + + # Separate the text at the or markers. + tokens = re.split('(|)', text, flags=re.IGNORECASE) + tokens.pop() # Anything after the final marker should be ignored. + + # The header might tell us the number of records, but let's not assume + # this and simply ignore it instead (if it exists). + if(re.search('', text, flags=re.IGNORECASE) is not None): + # There is a header present, so let's ignore everything + # up to and including the marker. Note that + # re.search has been used here to handle any case sensitivity. + # Previously we were checking for . is also valid + # but wasn't being detected before. + while len(tokens) > 0: + t = tokens.pop(0) + if(re.match('', t, flags=re.IGNORECASE) is not None): + break + + n_eor = 0 + n_record = 0 + records = [] + pattern = re.compile('<(.*?):(\d*).*?>([^<\t\n\r\f\v]+)') + + for t in tokens: + if(re.match('', t, flags=re.IGNORECASE) is not None): + n_eor += 1 + continue + else: + n_record += 1 + # Each record will have field names and corresponding + # data entries. Store this in a dictionary. + # Note: This is based on the code written by OK4BX. + # (http://web.bxhome.org/blog/ok4bx/2012/05/adif-parser-python) + fields_and_data_dictionary = {} + fields_and_data = pattern.findall(t) + comment = None + for fd in fields_and_data: + # Let's force all field names to be in upper case. + # This will help us later when comparing the field names + # against the available field names in the ADIF specification. + field_name = fd[0].upper() + field_data = fd[2][:int(fd[1])] + + # Combo boxes are used later on and these are case sensitive, + # so adjust the field data accordingly. + if(field_name == "BAND"): + field_data = field_data.lower() + elif(field_name == "CALL" or field_name == "MODE" or field_name == "SUBMODE"): + field_data = field_data.upper() + elif(field_name == "COMMENT"): + # Keep a copy of the COMMENT field data, in case we want to merge + # it with the NOTES field. + comment = field_data + if(field_name in AVAILABLE_FIELD_NAMES_ORDERED): + field_data_type = AVAILABLE_FIELD_NAMES_TYPES[field_name] + if(self.is_valid(field_name, field_data, field_data_type)): + # Only add the field if it is a standard ADIF field and it holds valid data. + fields_and_data_dictionary[field_name] = field_data + + # Merge the COMMENT field with the NOTES field, if desired and applicable. + if(merge_comment): + if("NOTES" in list(fields_and_data_dictionary.keys()) and comment): + logging.debug("Merging COMMENT field with NOTES field...") + fields_and_data_dictionary["NOTES"] += "\\n" + comment + logging.debug("Merged fields.") + elif(comment): + # Create the NOTES entry, but only store the contents of the COMMENT field. + logging.debug("The COMMENT field is present, but not the NOTES field. The NOTES field will be created and will only hold the COMMENT.") + fields_and_data_dictionary["NOTES"] = comment + else: + pass + records.append(fields_and_data_dictionary) + + assert n_eor == n_record + + logging.debug("Finished parsing text.") + + return records + + def write(self, records, path): + """ Write an ADIF file containing all the QSOs in the 'records' list. + + :arg list records: The list of QSO records to write. + :arg str path: The desired path of the ADIF file to write to. + :returns: None + :raises IOError: if the ADIF file cannot be written (e.g. due to lack of write permissions). + """ + + logging.debug("Writing records to an ADIF file...") + try: + f = open(path, mode='w', errors="replace") # Open file for writing + + # First write a header containing program version, number of records, etc. + dt = datetime.now() + + f.write("""Amateur radio log file. Generated on %s. Contains %d record(s). - logging.debug("Finished parsing text.") - - return records - - def write(self, records, path): - """ Write an ADIF file containing all the QSOs in the 'records' list. - - :arg list records: The list of QSO records to write. - :arg str path: The desired path of the ADIF file to write to. - :returns: None - :raises IOError: if the ADIF file cannot be written (e.g. due to lack of write permissions). - """ - - logging.debug("Writing records to an ADIF file...") - try: - f = open(path, mode='w', errors="replace") # Open file for writing - - # First write a header containing program version, number of records, etc. - dt = datetime.now() - - f.write("""Amateur radio log file. Generated on %s. Contains %d record(s). - %s PyQSO 0.3 \n""" % (dt, len(records), len(str(ADIF_VERSION)), ADIF_VERSION)) - - # Then write each log to the file. - for r in records: - for field_name in AVAILABLE_FIELD_NAMES_ORDERED: - if(not(field_name.lower() in list(r.keys()) or field_name.upper() in list(r.keys()))): - # If the field_name does not exist in the record, then skip past it. - # Only write out the fields that exist and that have some data in them. - continue - else: - if( (r[field_name] != "NULL") and (r[field_name] != "") ): - f.write("<%s:%d>%s\n" % (field_name.lower(), len(r[field_name]), r[field_name])) - f.write("\n") - logging.debug("Finished writing records to the ADIF file.") - f.close() + # Then write each log to the file. + for r in records: + for field_name in AVAILABLE_FIELD_NAMES_ORDERED: + if(not(field_name.lower() in list(r.keys()) or field_name.upper() in list(r.keys()))): + # If the field_name does not exist in the record, then skip past it. + # Only write out the fields that exist and that have some data in them. + continue + else: + if((r[field_name] != "NULL") and (r[field_name] != "")): + f.write("<%s:%d>%s\n" % (field_name.lower(), len(r[field_name]), r[field_name])) + f.write("\n") - except IOError as e: - logging.error("I/O error %d: %s" % (e.errno, e.strerror)) - except Exception as e: # All other exceptions. - logging.error("An error occurred when writing the ADIF file.") - logging.exception(e) + logging.debug("Finished writing records to the ADIF file.") + f.close() - return + except IOError as e: + logging.error("I/O error %d: %s" % (e.errno, e.strerror)) + except Exception as e: # All other exceptions. + logging.error("An error occurred when writing the ADIF file.") + logging.exception(e) + return - def is_valid(self, field_name, data, data_type): - """ Validate the data in a field with respect to the ADIF specification. - - :arg str field_name: The name of the ADIF field. - :arg str data: The data of the ADIF field to validate. - :arg str data_type: The type of data to be validated. See http://www.adif.org/304/ADIF_304.htm#Data_Types for the full list with descriptions. - :returns: True or False to indicate whether the data is valid or not. - :rtype: bool - """ + def is_valid(self, field_name, data, data_type): + """ Validate the data in a field with respect to the ADIF specification. - logging.debug("Validating the following data in field '%s': %s" % (field_name, data)) + :arg str field_name: The name of the ADIF field. + :arg str data: The data of the ADIF field to validate. + :arg str data_type: The type of data to be validated. See http://www.adif.org/304/ADIF_304.htm#Data_Types for the full list with descriptions. + :returns: True or False to indicate whether the data is valid or not. + :rtype: bool + """ - # Allow an empty string, in case the user doesn't want - # to fill in this field. - if(data == ""): - return True + logging.debug("Validating the following data in field '%s': %s" % (field_name, data)) - if(data_type == "N"): - # Allow a decimal point before and/or after any numbers, - # but don't allow a decimal point on its own. - m = re.match(r"-?(([0-9]+\.?[0-9]*)|([0-9]*\.?[0-9]+))", data) - if(m is None): - # Did not match anything. - return False - else: - # Make sure we match the whole string, - # otherwise there may be an invalid character after the match. - return (m.group(0) == data) - - elif(data_type == "B"): - # Boolean - m = re.match(r"(Y|N)", data) - if(m is None): - return False - else: - return (m.group(0) == data) - - elif(data_type == "D"): - # Date - pattern = re.compile(r"([0-9]{4})") - m_year = pattern.match(data, 0) - if((m_year is None) or (int(m_year.group(0)) < 1930)): - # Did not match anything. - return False - else: - pattern = re.compile(r"([0-9]{2})") - m_month = pattern.match(data, 4) - if((m_month is None) or int(m_month.group(0)) > 12 or int(m_month.group(0)) < 1): - # Did not match anything. - return False - else: - pattern = re.compile(r"([0-9]{2})") - m_day = pattern.match(data, 6) - days_in_month = calendar.monthrange(int(m_year.group(0)), int(m_month.group(0))) - if((m_day is None) or int(m_day.group(0)) > days_in_month[1] or int(m_day.group(0)) < 1): - # Did not match anything. - return False - else: - # Make sure we match the whole string, - # otherwise there may be an invalid character after the match. - return (len(data) == 8) - - elif(data_type == "T"): - # Time - pattern = re.compile(r"([0-9]{2})") - m_hour = pattern.match(data, 0) - if((m_hour is None) or (int(m_hour.group(0)) < 0) or (int(m_hour.group(0)) > 23)): - # Did not match anything. - return False - else: - pattern = re.compile(r"([0-9]{2})") - m_minutes = pattern.match(data, 2) - if((m_minutes is None) or int(m_minutes.group(0)) < 0 or int(m_minutes.group(0)) > 59): - # Did not match anything. - return False - else: - if(len(data) == 4): - # HHMM format - return True - pattern = re.compile(r"([0-9]{2})") - m_seconds = pattern.match(data, 4) - if((m_seconds is None) or int(m_seconds.group(0)) < 0 or int(m_seconds.group(0)) > 59): - # Did not match anything. - return False - else: - # Make sure we match the whole string, - # otherwise there may be an invalid character after the match. - return (len(data) == 6) # HHMMSS format - - #FIXME: Need to make sure that the "S" and "M" data types accept ASCII-only characters - # in the range 32-126 inclusive. - elif(data_type == "S"): - # String - m = re.match(r"(.+)", data) - if(m is None): - return False - else: - return (m.group(0) == data) - - elif(data_type == "I"): - # IntlString - m = re.match(r"(.+)", data, re.UNICODE) - if(m is None): - return False - else: - return (m.group(0) == data) - - elif(data_type == "G"): - # IntlMultilineString - m = re.match(r"(.+(\r\n)*.*)", data, re.UNICODE) - if(m is None): - return False - else: - return (m.group(0) == data) - - elif(data_type == "M"): - # MultilineString - #m = re.match(r"(.+(\r\n)*.*)", data) - #if(m is None): - # return False - #else: - # return (m.group(0) == data) - return True - - elif(data_type == "L"): - # Location - pattern = re.compile(r"([EWNS]{1})", re.IGNORECASE) - m_directional = pattern.match(data, 0) - if(m_directional is None): - # Did not match anything. - return False - else: - pattern = re.compile(r"([0-9]{3})") - m_degrees = pattern.match(data, 1) - if((m_degrees is None) or int(m_degrees.group(0)) < 0 or int(m_degrees.group(0)) > 180): - # Did not match anything. - return False - else: - pattern = re.compile(r"([0-9]{2}\.[0-9]{3})") - m_minutes = pattern.match(data, 4) - if((m_minutes is None) or float(m_minutes.group(0)) < 0 or float(m_minutes.group(0)) > 59.999): - # Did not match anything. - return False - else: - # Make sure we match the whole string, - # otherwise there may be an invalid character after the match. - return (len(data) == 10) - - - elif(data_type == "E" or data_type == "A"): - # Enumeration, AwardList. - if(field_name == "MODE"): - return (data in list(MODES.keys())) - elif(field_name == "BAND"): - return (data in BANDS) - else: + # Allow an empty string, in case the user doesn't want + # to fill in this field. + if(data == ""): return True - else: - return True - - + if(data_type == "N"): + # Allow a decimal point before and/or after any numbers, + # but don't allow a decimal point on its own. + m = re.match(r"-?(([0-9]+\.?[0-9]*)|([0-9]*\.?[0-9]+))", data) + if(m is None): + # Did not match anything. + return False + else: + # Make sure we match the whole string, + # otherwise there may be an invalid character after the match. + return (m.group(0) == data) + + elif(data_type == "B"): + # Boolean + m = re.match(r"(Y|N)", data) + if(m is None): + return False + else: + return (m.group(0) == data) + + elif(data_type == "D"): + # Date + pattern = re.compile(r"([0-9]{4})") + m_year = pattern.match(data, 0) + if((m_year is None) or (int(m_year.group(0)) < 1930)): + # Did not match anything. + return False + else: + pattern = re.compile(r"([0-9]{2})") + m_month = pattern.match(data, 4) + if((m_month is None) or int(m_month.group(0)) > 12 or int(m_month.group(0)) < 1): + # Did not match anything. + return False + else: + pattern = re.compile(r"([0-9]{2})") + m_day = pattern.match(data, 6) + days_in_month = calendar.monthrange(int(m_year.group(0)), int(m_month.group(0))) + if((m_day is None) or int(m_day.group(0)) > days_in_month[1] or int(m_day.group(0)) < 1): + # Did not match anything. + return False + else: + # Make sure we match the whole string, + # otherwise there may be an invalid character after the match. + return (len(data) == 8) + + elif(data_type == "T"): + # Time + pattern = re.compile(r"([0-9]{2})") + m_hour = pattern.match(data, 0) + if((m_hour is None) or (int(m_hour.group(0)) < 0) or (int(m_hour.group(0)) > 23)): + # Did not match anything. + return False + else: + pattern = re.compile(r"([0-9]{2})") + m_minutes = pattern.match(data, 2) + if((m_minutes is None) or int(m_minutes.group(0)) < 0 or int(m_minutes.group(0)) > 59): + # Did not match anything. + return False + else: + if(len(data) == 4): + # HHMM format + return True + pattern = re.compile(r"([0-9]{2})") + m_seconds = pattern.match(data, 4) + if((m_seconds is None) or int(m_seconds.group(0)) < 0 or int(m_seconds.group(0)) > 59): + # Did not match anything. + return False + else: + # Make sure we match the whole string, + # otherwise there may be an invalid character after the match. + return (len(data) == 6) # HHMMSS format + + # FIXME: Need to make sure that the "S" and "M" data types accept ASCII-only characters + # in the range 32-126 inclusive. + elif(data_type == "S"): + # String + m = re.match(r"(.+)", data) + if(m is None): + return False + else: + return (m.group(0) == data) + + elif(data_type == "I"): + # IntlString + m = re.match(r"(.+)", data, re.UNICODE) + if(m is None): + return False + else: + return (m.group(0) == data) + + elif(data_type == "G"): + # IntlMultilineString + m = re.match(r"(.+(\r\n)*.*)", data, re.UNICODE) + if(m is None): + return False + else: + return (m.group(0) == data) + + elif(data_type == "M"): + # MultilineString + # m = re.match(r"(.+(\r\n)*.*)", data) + # if(m is None): + # return False + # else: + # return (m.group(0) == data) + return True + + elif(data_type == "L"): + # Location + pattern = re.compile(r"([EWNS]{1})", re.IGNORECASE) + m_directional = pattern.match(data, 0) + if(m_directional is None): + # Did not match anything. + return False + else: + pattern = re.compile(r"([0-9]{3})") + m_degrees = pattern.match(data, 1) + if((m_degrees is None) or int(m_degrees.group(0)) < 0 or int(m_degrees.group(0)) > 180): + # Did not match anything. + return False + else: + pattern = re.compile(r"([0-9]{2}\.[0-9]{3})") + m_minutes = pattern.match(data, 4) + if((m_minutes is None) or float(m_minutes.group(0)) < 0 or float(m_minutes.group(0)) > 59.999): + # Did not match anything. + return False + else: + # Make sure we match the whole string, + # otherwise there may be an invalid character after the match. + return (len(data) == 10) + + elif(data_type == "E" or data_type == "A"): + # Enumeration, AwardList. + if(field_name == "MODE"): + return (data in list(MODES.keys())) + elif(field_name == "BAND"): + return (data in BANDS) + else: + return True + + else: + return True + + class TestADIF(unittest.TestCase): - """ The unit tests for the ADIF module. """ - def setUp(self): - """ Set up the ADIF object needed for the unit tests. """ - self.adif = ADIF() + """ The unit tests for the ADIF module. """ - def test_adif_read(self): - """ Check that a single ADIF record can be read and parsed correctly. """ - f = open("ADIF.test_read.adi", 'w') - f.write("""Some test ADI data. + def setUp(self): + """ Set up the ADIF object needed for the unit tests. """ + self.adif = ADIF() + + def test_adif_read(self): + """ Check that a single ADIF record can be read and parsed correctly. """ + f = open("ADIF.test_read.adi", 'w') + f.write("""Some test ADI data. TEST40mCW 201303221955""") - f.close() - - records = self.adif.read("ADIF.test_read.adi") - expected_records = [{'TIME_ON': '1955', 'BAND': '40m', 'CALL': 'TEST', 'MODE': 'CW', 'QSO_DATE': '20130322'}] - print("Imported records: ", records) - print("Expected records: ", expected_records) - assert(len(records) == 1) - assert(len(list(records[0].keys())) == len(list(expected_records[0].keys()))) - assert(records == expected_records) + f.close() - def test_adif_read_multiple(self): - """ Check that multiple ADIF records can be read and parsed correctly. """ - f = open("ADIF.test_read_multiple.adi", 'w') - f.write("""Some test ADI data. + records = self.adif.read("ADIF.test_read.adi") + expected_records = [{'TIME_ON': '1955', 'BAND': '40m', 'CALL': 'TEST', 'MODE': 'CW', 'QSO_DATE': '20130322'}] + print("Imported records: ", records) + print("Expected records: ", expected_records) + assert(len(records) == 1) + assert(len(list(records[0].keys())) == len(list(expected_records[0].keys()))) + assert(records == expected_records) + + def test_adif_read_multiple(self): + """ Check that multiple ADIF records can be read and parsed correctly. """ + f = open("ADIF.test_read_multiple.adi", 'w') + f.write("""Some test ADI data. TEST40mCW 201303221955 @@ -565,84 +566,84 @@ class TestADIF(unittest.TestCase): 201502270820 HELLO2mFM201502270832""") - f.close() - - records = self.adif.read("ADIF.test_read_multiple.adi") - expected_records = [{'TIME_ON': '1955', 'BAND': '40m', 'CALL': 'TEST', 'MODE': 'CW', 'QSO_DATE': '20130322'}, {'TIME_ON': '0820', 'BAND': '20m', 'CALL': 'TEST2ABC', 'MODE': 'SSB', 'QSO_DATE': '20150227'}, {'TIME_ON': '0832', 'BAND': '2m', 'CALL': 'HELLO', 'MODE': 'FM', 'QSO_DATE': '20150227'}] - print("Imported records: ", records) - print("Expected records: ", expected_records) - assert(len(records) == 3) - for i in range(len(expected_records)): - assert(len(list(records[i].keys())) == len(list(expected_records[i].keys()))) - assert(records == expected_records) + f.close() - def test_adif_read_alphabet(self): - """ Check that none of the letters of the alphabet are ignored during parsing. """ - f = open("ADIF.test_read_alphabet.adi", 'w') - f.write("""Some test ADI data. + records = self.adif.read("ADIF.test_read_multiple.adi") + expected_records = [{'TIME_ON': '1955', 'BAND': '40m', 'CALL': 'TEST', 'MODE': 'CW', 'QSO_DATE': '20130322'}, {'TIME_ON': '0820', 'BAND': '20m', 'CALL': 'TEST2ABC', 'MODE': 'SSB', 'QSO_DATE': '20150227'}, {'TIME_ON': '0832', 'BAND': '2m', 'CALL': 'HELLO', 'MODE': 'FM', 'QSO_DATE': '20150227'}] + print("Imported records: ", records) + print("Expected records: ", expected_records) + assert(len(records) == 3) + for i in range(len(expected_records)): + assert(len(list(records[i].keys())) == len(list(expected_records[i].keys()))) + assert(records == expected_records) + + def test_adif_read_alphabet(self): + """ Check that none of the letters of the alphabet are ignored during parsing. """ + f = open("ADIF.test_read_alphabet.adi", 'w') + f.write("""Some test ADI data. ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ""") - f.close() - - records = self.adif.read("ADIF.test_read_alphabet.adi") - expected_records = [{'CALL': 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'}] - print("Imported records: ", records) - print("Expected records: ", expected_records) - assert(len(records) == 1) - assert(len(list(records[0].keys())) == len(list(expected_records[0].keys()))) - assert(records == expected_records) + f.close() - def test_adif_read_capitalisation(self): - """ Check that the CALL field is capitalised correctly. """ - f = open("ADIF.test_read_capitalisation.adi", 'w') - f.write("""Some test ADI data. + records = self.adif.read("ADIF.test_read_alphabet.adi") + expected_records = [{'CALL': 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ'}] + print("Imported records: ", records) + print("Expected records: ", expected_records) + assert(len(records) == 1) + assert(len(list(records[0].keys())) == len(list(expected_records[0].keys()))) + assert(records == expected_records) + + def test_adif_read_capitalisation(self): + """ Check that the CALL field is capitalised correctly. """ + f = open("ADIF.test_read_capitalisation.adi", 'w') + f.write("""Some test ADI data. test""") - f.close() - - records = self.adif.read("ADIF.test_read_capitalisation.adi") - expected_records = [{'CALL': 'TEST'}] - print("Imported records: ", records) - print("Expected records: ", expected_records) - assert(len(records) == 1) - assert(len(list(records[0].keys())) == len(list(expected_records[0].keys()))) - assert(records == expected_records) - - def test_adif_read_header_only(self): - """ Check that no records are read in if the ADIF file only contains header information. """ - f = open("ADIF.test_read_header_only.adi", 'w') - f.write("""Some test ADI data.""") - f.close() - - records = self.adif.read("ADIF.test_read_header_only.adi") - expected_records = [] - print("Imported records: ", records) - print("Expected records: ", expected_records) - assert(len(records) == 0) - assert(records == expected_records) + f.close() - def test_adif_read_no_header(self): - """ Check that an ADIF file can be parsed with no header information. """ - f = open("ADIF.test_read_no_header.adi", 'w') - f.write("""TEST40mCW201303221955""") - f.close() - - records = self.adif.read("ADIF.test_read_no_header.adi") - expected_records = [{'TIME_ON': '1955', 'BAND': '40m', 'CALL': 'TEST', 'MODE': 'CW', 'QSO_DATE': '20130322'}] - print("Imported records: ", records) - print("Expected records: ", expected_records) - assert(len(records) == 1) - assert(len(list(records[0].keys())) == len(list(expected_records[0].keys()))) - assert(records == expected_records) + records = self.adif.read("ADIF.test_read_capitalisation.adi") + expected_records = [{'CALL': 'TEST'}] + print("Imported records: ", records) + print("Expected records: ", expected_records) + assert(len(records) == 1) + assert(len(list(records[0].keys())) == len(list(expected_records[0].keys()))) + assert(records == expected_records) - def test_adif_write(self): - """ Check that records can be written to an ADIF file correctly. """ - records = [{"CALL":"TEST123", "QSO_DATE":"20120402", "TIME_ON":"1234", "FREQ":"145.500", "BAND":"2m", "MODE":"FM", "RST_SENT":"59", "RST_RCVD":"59"}, - {"CALL":"TEST123", "QSO_DATE":"20130312", "TIME_ON":"0101", "FREQ":"145.750", "BAND":"2m", "MODE":"FM"}] - self.adif.write(records, "ADIF.test_write.adi") + def test_adif_read_header_only(self): + """ Check that no records are read in if the ADIF file only contains header information. """ + f = open("ADIF.test_read_header_only.adi", 'w') + f.write("""Some test ADI data.""") + f.close() - f = open("ADIF.test_write.adi", 'r') - text = f.read() - print("File 'ADIF.test_write.adi' contains the following text:", text) - assert(""" + records = self.adif.read("ADIF.test_read_header_only.adi") + expected_records = [] + print("Imported records: ", records) + print("Expected records: ", expected_records) + assert(len(records) == 0) + assert(records == expected_records) + + def test_adif_read_no_header(self): + """ Check that an ADIF file can be parsed with no header information. """ + f = open("ADIF.test_read_no_header.adi", 'w') + f.write("""TEST40mCW201303221955""") + f.close() + + records = self.adif.read("ADIF.test_read_no_header.adi") + expected_records = [{'TIME_ON': '1955', 'BAND': '40m', 'CALL': 'TEST', 'MODE': 'CW', 'QSO_DATE': '20130322'}] + print("Imported records: ", records) + print("Expected records: ", expected_records) + assert(len(records) == 1) + assert(len(list(records[0].keys())) == len(list(expected_records[0].keys()))) + assert(records == expected_records) + + def test_adif_write(self): + """ Check that records can be written to an ADIF file correctly. """ + records = [{"CALL": "TEST123", "QSO_DATE": "20120402", "TIME_ON": "1234", "FREQ": "145.500", "BAND": "2m", "MODE": "FM", "RST_SENT": "59", "RST_RCVD": "59"}, + {"CALL": "TEST123", "QSO_DATE": "20130312", "TIME_ON": "0101", "FREQ": "145.750", "BAND": "2m", "MODE": "FM"}] + self.adif.write(records, "ADIF.test_write.adi") + + f = open("ADIF.test_write.adi", 'r') + text = f.read() + print("File 'ADIF.test_write.adi' contains the following text:", text) + assert(""" 3.0.4 PyQSO 0.3 @@ -663,27 +664,27 @@ class TestADIF(unittest.TestCase): 2m FM -""" in text) # Ignore the header line here, since it contains the date and time the ADIF file was written, which will change each time 'make unittest' is run. - f.close() +""" in text) # Ignore the header line here, since it contains the date and time the ADIF file was written, which will change each time 'make unittest' is run. + f.close() - def test_adif_write_sqlite3_Row(self): - """ Check that records can be written to an ADIF file from a test database file. """ - import sqlite3 - import os.path - self.connection = sqlite3.connect(os.path.dirname(os.path.realpath(__file__))+"/unittest_resources/test.db") - self.connection.row_factory = sqlite3.Row + def test_adif_write_sqlite3_Row(self): + """ Check that records can be written to an ADIF file from a test database file. """ + import sqlite3 + import os.path + self.connection = sqlite3.connect(os.path.dirname(os.path.realpath(__file__))+"/unittest_resources/test.db") + self.connection.row_factory = sqlite3.Row - c = self.connection.cursor() - c.execute("SELECT * FROM test") - records = c.fetchall() - print(records) + c = self.connection.cursor() + c.execute("SELECT * FROM test") + records = c.fetchall() + print(records) - self.adif.write(records, "ADIF.test_write_sqlite3_Row.adi") + self.adif.write(records, "ADIF.test_write_sqlite3_Row.adi") - f = open("ADIF.test_write_sqlite3_Row.adi", 'r') - text = f.read() - print("File 'ADIF.test_write_sqlite3_Row.adi' contains the following text:", text) - assert(""" + f = open("ADIF.test_write_sqlite3_Row.adi", 'r') + text = f.read() + print("File 'ADIF.test_write_sqlite3_Row.adi' contains the following text:", text) + assert(""" 3.0.4 PyQSO 0.3 @@ -704,18 +705,17 @@ class TestADIF(unittest.TestCase): 2m FM -""" in text) # Ignore the header line here, since it contains the date and time the ADIF file was written, which will change each time 'make unittest' is run. - f.close() +""" in text) # Ignore the header line here, since it contains the date and time the ADIF file was written, which will change each time 'make unittest' is run. + f.close() - self.connection.close() + self.connection.close() - def test_adif_is_valid(self): - """ Check that ADIF field validation is working correctly for different data types. """ - assert(self.adif.is_valid("CALL", "TEST123", "S") == True) - assert(self.adif.is_valid("QSO_DATE", "20120402", "D") == True) - assert(self.adif.is_valid("TIME_ON", "1230", "T") == True) - assert(self.adif.is_valid("TX_PWR", "5", "N") == True) + def test_adif_is_valid(self): + """ Check that ADIF field validation is working correctly for different data types. """ + assert(self.adif.is_valid("CALL", "TEST123", "S") == True) + assert(self.adif.is_valid("QSO_DATE", "20120402", "D") == True) + assert(self.adif.is_valid("TIME_ON", "1230", "T") == True) + assert(self.adif.is_valid("TX_PWR", "5", "N") == True) if(__name__ == '__main__'): - unittest.main() - + unittest.main() diff --git a/pyqso/auxiliary_dialogs.py b/pyqso/auxiliary_dialogs.py index 9713ed9..888a6f9 100644 --- a/pyqso/auxiliary_dialogs.py +++ b/pyqso/auxiliary_dialogs.py @@ -20,49 +20,53 @@ from gi.repository import Gtk import logging + def error(parent, message): - """ Display an error message. - - :arg parent: The Gtk parent window/dialog. - :arg str message: The message to display to the user. - """ - logging.error(message) - _handle_gtk_dialog(parent, Gtk.MessageType.ERROR, message, "Error") + """ Display an error message. + + :arg parent: The Gtk parent window/dialog. + :arg str message: The message to display to the user. + """ + logging.error(message) + _handle_gtk_dialog(parent, Gtk.MessageType.ERROR, message, "Error") + def info(parent, message): - """ Display some information. - - :arg parent: The Gtk parent window/dialog. - :arg str message: The message to display to the user. - """ - logging.debug(message) - _handle_gtk_dialog(parent, Gtk.MessageType.INFO, message, "Information") - + """ Display some information. + + :arg parent: The Gtk parent window/dialog. + :arg str message: The message to display to the user. + """ + logging.debug(message) + _handle_gtk_dialog(parent, Gtk.MessageType.INFO, message, "Information") + + def question(parent, message): - """ Ask the user a question. The dialog comes with 'Yes' and 'No' response buttons. - - :arg parent: The Gtk parent window/dialog. - :arg str message: The message to display to the user. - :returns: The 'yes'/'no' response from the user. - :rtype: Gtk.ResponseType - """ - return _handle_gtk_dialog(parent, Gtk.MessageType.QUESTION, message, "Question") + """ Ask the user a question. The dialog comes with 'Yes' and 'No' response buttons. + + :arg parent: The Gtk parent window/dialog. + :arg str message: The message to display to the user. + :returns: The 'yes'/'no' response from the user. + :rtype: Gtk.ResponseType + """ + return _handle_gtk_dialog(parent, Gtk.MessageType.QUESTION, message, "Question") + def _handle_gtk_dialog(parent, msgtype, message, title): - """ - Instantiate and present a dialog to the user. - - :arg parent: The Gtk parent window/dialog. - :arg Gtk.MessageType msgtype: The type of message to present to the user (e.g. a question, or error message). - :arg str message: The message to display in the dialog. - :arg str title: The title to display at the top of the dialog. - :returns: The response from the user, based on which button they pushed. - :rtype: Gtk.ResponseType - """ - bt = Gtk.ButtonsType - buttons = bt.YES_NO if msgtype == Gtk.MessageType.QUESTION else bt.OK - dialog = Gtk.MessageDialog(parent, Gtk.DialogFlags.DESTROY_WITH_PARENT, - msgtype, buttons, message, title=title) - response = dialog.run() - dialog.destroy() - return response + """ + Instantiate and present a dialog to the user. + + :arg parent: The Gtk parent window/dialog. + :arg Gtk.MessageType msgtype: The type of message to present to the user (e.g. a question, or error message). + :arg str message: The message to display in the dialog. + :arg str title: The title to display at the top of the dialog. + :returns: The response from the user, based on which button they pushed. + :rtype: Gtk.ResponseType + """ + bt = Gtk.ButtonsType + buttons = bt.YES_NO if msgtype == Gtk.MessageType.QUESTION else bt.OK + dialog = Gtk.MessageDialog(parent, Gtk.DialogFlags.DESTROY_WITH_PARENT, + msgtype, buttons, message, title=title) + response = dialog.run() + dialog.destroy() + return response diff --git a/pyqso/awards.py b/pyqso/awards.py index 276c748..1657819 100644 --- a/pyqso/awards.py +++ b/pyqso/awards.py @@ -20,90 +20,91 @@ from gi.repository import Gtk import logging + class Awards(Gtk.VBox): - """ A tool for tracking progress towards an award. Currently this only supports the DXCC award. - For more information visit http://www.arrl.org/dxcc """ - - def __init__(self, parent): - """ Set up a table for progress tracking purposes. - - :arg parent: The parent Gtk window. - """ - #TODO: This only considers the DXCC award for now. - logging.debug("New Awards instance created!") - - Gtk.VBox.__init__(self, spacing=2) - self.parent = parent + """ A tool for tracking progress towards an award. Currently this only supports the DXCC award. + For more information visit http://www.arrl.org/dxcc """ - self.bands = ["70cm", "2m", "6m", "10m", "12m", "15m", "17m", "20m", "30m", "40m", "80m", "160m"] - self.modes = ["Phone", "CW", "Digital", "Mixed"] - - data_types = [str] + [int]*len(self.bands) - self.awards = Gtk.ListStore(*data_types) + def __init__(self, parent): + """ Set up a table for progress tracking purposes. - # The main table for the awards - self.treeview = Gtk.TreeView(self.awards) - # A separate, empty column just for the mode names - renderer = Gtk.CellRendererText() - column = Gtk.TreeViewColumn("Modes", renderer, text=0) - column.set_clickable(False) - self.treeview.append_column(column) - # Now for all the bands... - logging.debug("Initialising the columns in the awards table.") - for i in range(0, len(self.bands)): - renderer = Gtk.CellRendererText() - column = Gtk.TreeViewColumn(self.bands[i], renderer, text=i+1) - column.set_min_width(40) - column.set_clickable(False) - self.treeview.append_column(column) + :arg parent: The parent Gtk window. + """ + # TODO: This only considers the DXCC award for now. + logging.debug("New Awards instance created!") - # Add a label to inform the user that this only considers the DXCC award for now. - label = Gtk.Label(halign=Gtk.Align.START) - label.set_markup("%s" % "DXCC Award") - self.pack_start(label, False, False, 4) - # Show the table in the Awards tab - self.add(self.treeview) - self.show_all() + Gtk.VBox.__init__(self, spacing=2) - logging.debug("Awards table set up successfully.") + self.parent = parent - self.count() + self.bands = ["70cm", "2m", "6m", "10m", "12m", "15m", "17m", "20m", "30m", "40m", "80m", "160m"] + self.modes = ["Phone", "CW", "Digital", "Mixed"] - return + data_types = [str] + [int]*len(self.bands) + self.awards = Gtk.ListStore(*data_types) - def count(self): - """ Update the table for progress tracking. """ - - logging.debug("Counting the band/mode combinations for the awards table...") - # Wipe everything and start again - self.awards.clear() - # For each mode, add a new list for holding the totals, and initialise the values to zero. - count = [] - for i in range(0, len(self.bands)): - count.append([0]*len(self.bands)) + # The main table for the awards + self.treeview = Gtk.TreeView(self.awards) + # A separate, empty column just for the mode names + renderer = Gtk.CellRendererText() + column = Gtk.TreeViewColumn("Modes", renderer, text=0) + column.set_clickable(False) + self.treeview.append_column(column) + # Now for all the bands... + logging.debug("Initialising the columns in the awards table.") + for i in range(0, len(self.bands)): + renderer = Gtk.CellRendererText() + column = Gtk.TreeViewColumn(self.bands[i], renderer, text=i+1) + column.set_min_width(40) + column.set_clickable(False) + self.treeview.append_column(column) - for log in self.parent.logbook.logs: - records = log.get_all_records() - if(records is not None): - for r in records: - if(r["BAND"] is not None and r["MODE"] is not None): - if(r["BAND"].lower() in self.bands and r["MODE"] != ""): - band = self.bands.index(r["BAND"].lower()) - # Phone modes - if(r["MODE"].upper() in ["FM", "AM", "SSB", "SSTV"]): - count[0][band] += 1 - elif(r["MODE"].upper() == "CW"): - count[1][band] += 1 - else: - #FIXME: This assumes that all the other modes in the ADIF list are digital modes. Is this the case? - count[2][band] += 1 - count[3][band] += 1 # Keep the total of each column in the "Mixed" mode - else: - logging.error("Could not update the awards table for '%s' because of a database error." % log.name) - # Insert the rows containing the totals - for i in range(0, len(self.modes)): - self.awards.append([self.modes[i]] + count[i]) - logging.debug("Awards table updated.") - return + # Add a label to inform the user that this only considers the DXCC award for now. + label = Gtk.Label(halign=Gtk.Align.START) + label.set_markup("%s" % "DXCC Award") + self.pack_start(label, False, False, 4) + # Show the table in the Awards tab + self.add(self.treeview) + self.show_all() + logging.debug("Awards table set up successfully.") + + self.count() + + return + + def count(self): + """ Update the table for progress tracking. """ + + logging.debug("Counting the band/mode combinations for the awards table...") + # Wipe everything and start again + self.awards.clear() + # For each mode, add a new list for holding the totals, and initialise the values to zero. + count = [] + for i in range(0, len(self.bands)): + count.append([0]*len(self.bands)) + + for log in self.parent.logbook.logs: + records = log.get_all_records() + if(records is not None): + for r in records: + if(r["BAND"] is not None and r["MODE"] is not None): + if(r["BAND"].lower() in self.bands and r["MODE"] != ""): + band = self.bands.index(r["BAND"].lower()) + # Phone modes + if(r["MODE"].upper() in ["FM", "AM", "SSB", "SSTV"]): + count[0][band] += 1 + elif(r["MODE"].upper() == "CW"): + count[1][band] += 1 + else: + # FIXME: This assumes that all the other modes in the ADIF list are digital modes. Is this the case? + count[2][band] += 1 + count[3][band] += 1 # Keep the total of each column in the "Mixed" mode + else: + logging.error("Could not update the awards table for '%s' because of a database error." % log.name) + # Insert the rows containing the totals + for i in range(0, len(self.modes)): + self.awards.append([self.modes[i]] + count[i]) + logging.debug("Awards table updated.") + return diff --git a/pyqso/callsign_lookup.py b/pyqso/callsign_lookup.py index dfb65b1..0f4631d 100644 --- a/pyqso/callsign_lookup.py +++ b/pyqso/callsign_lookup.py @@ -25,388 +25,393 @@ from xml.dom import minidom from pyqso.auxiliary_dialogs import * + class CallsignLookupQRZ(): - """ Use qrz.com to lookup details about a particular callsign. """ - def __init__(self, parent): - """ Initialise a new callsign lookup handler. - - :arg parent: The parent Gtk dialog. - """ - self.parent = parent - self.connection = None - self.session_key = None - return + """ Use qrz.com to lookup details about a particular callsign. """ - def connect(self, username, password): - """ Initiate a session with the qrz.com server. Hopefully this will provide a session key. - - :arg str username: The username of the qrz.com user account. - :arg str password: The password of the qrz.com user account. - :returns: True if a successful connection was made to the server, and False otherwise. - :rtype: bool - """ - logging.debug("Connecting to the qrz.com server...") - try: - self.connection = http.client.HTTPConnection('xmldata.qrz.com') - request = '/xml/current/?username=%s;password=%s;agent=pyqso' % (username, password) - self.connection.request('GET', request) - response = self.connection.getresponse() - except: - error(parent=self.parent, message="Could not connect to the qrz.com server. Check connection to the internets?") - return False + def __init__(self, parent): + """ Initialise a new callsign lookup handler. - xml_data = minidom.parseString(response.read()) - session_node = xml_data.getElementsByTagName('Session')[0] # There should only be one Session element - session_key_node = session_node.getElementsByTagName('Key') - if(len(session_key_node) > 0): - self.session_key = session_key_node[0].firstChild.nodeValue - logging.debug("Successfully connected to the qrz.com server...") - connected = True - else: - connected = False + :arg parent: The parent Gtk dialog. + """ + self.parent = parent + self.connection = None + self.session_key = None + return - # If there are any errors or warnings, print them out - session_error_node = session_node.getElementsByTagName('Error') - if(len(session_error_node) > 0): - session_error = session_error_node[0].firstChild.nodeValue - error(parent=self.parent, message="qrz.com session error: "+session_error) + def connect(self, username, password): + """ Initiate a session with the qrz.com server. Hopefully this will provide a session key. - return connected + :arg str username: The username of the qrz.com user account. + :arg str password: The password of the qrz.com user account. + :returns: True if a successful connection was made to the server, and False otherwise. + :rtype: bool + """ + logging.debug("Connecting to the qrz.com server...") + try: + self.connection = http.client.HTTPConnection('xmldata.qrz.com') + request = '/xml/current/?username=%s;password=%s;agent=pyqso' % (username, password) + self.connection.request('GET', request) + response = self.connection.getresponse() + except: + error(parent=self.parent, message="Could not connect to the qrz.com server. Check connection to the internets?") + return False - def lookup(self, full_callsign, ignore_prefix_suffix = True): - """ Parse the XML tree that is returned from the qrz.com XML server to obtain the NAME, ADDRESS, STATE, COUNTRY, DXCC, CQZ, ITUZ, and IOTA field data (if present). - - :arg str full_callsign: The callsign to look up (without any prefix/suffix stripping). - :arg bool ignore_prefix_suffix: True if callsign prefixes/suffixes should be removed prior to querying the server, False otherwise. - :returns: The data in a dictionary called fields_and_data. - :rtype: dict - """ - - logging.debug("Looking up callsign. The full callsign (with a prefix and/or suffix) is %s" % full_callsign) - - # Remove any prefix or suffix from the callsign before performing the lookup. - if(ignore_prefix_suffix): - callsign = strip(full_callsign) - else: - callsign = full_callsign - - # Commence lookup. - fields_and_data = {"NAME":"", "ADDRESS":"", "STATE":"", "COUNTRY":"", "DXCC":"", "CQZ":"", "ITUZ":"", "IOTA":""} - if(self.session_key): - request = '/xml/current/?s=%s;callsign=%s' % (self.session_key, callsign) - self.connection.request('GET', request) - response = self.connection.getresponse() + xml_data = minidom.parseString(response.read()) + session_node = xml_data.getElementsByTagName('Session')[0] # There should only be one Session element + session_key_node = session_node.getElementsByTagName('Key') + if(len(session_key_node) > 0): + self.session_key = session_key_node[0].firstChild.nodeValue + logging.debug("Successfully connected to the qrz.com server...") + connected = True + else: + connected = False - xml_data = minidom.parseString(response.read()) - callsign_node = xml_data.getElementsByTagName('Callsign') - if(len(callsign_node) > 0): - callsign_node = callsign_node[0] # There should only be a maximum of one Callsign element + # If there are any errors or warnings, print them out + session_error_node = session_node.getElementsByTagName('Error') + if(len(session_error_node) > 0): + session_error = session_error_node[0].firstChild.nodeValue + error(parent=self.parent, message="qrz.com session error: "+session_error) - callsign_fname_node = callsign_node.getElementsByTagName('fname') - callsign_name_node = callsign_node.getElementsByTagName('name') - if(len(callsign_fname_node) > 0): - fields_and_data["NAME"] = callsign_fname_node[0].firstChild.nodeValue - if(len(callsign_name_node) > 0): # Add the surname, if present - fields_and_data["NAME"] = fields_and_data["NAME"] + " " + callsign_name_node[0].firstChild.nodeValue + return connected - callsign_addr1_node = callsign_node.getElementsByTagName('addr1') - callsign_addr2_node = callsign_node.getElementsByTagName('addr2') - if(len(callsign_addr1_node) > 0): - fields_and_data["ADDRESS"] = callsign_addr1_node[0].firstChild.nodeValue - if(len(callsign_addr2_node) > 0): # Add the second line of the address, if present - fields_and_data["ADDRESS"] = (fields_and_data["ADDRESS"] + ", " if len(callsign_addr1_node) > 0 else "") + callsign_addr2_node[0].firstChild.nodeValue + def lookup(self, full_callsign, ignore_prefix_suffix=True): + """ Parse the XML tree that is returned from the qrz.com XML server to obtain the NAME, ADDRESS, STATE, COUNTRY, DXCC, CQZ, ITUZ, and IOTA field data (if present). - callsign_state_node = callsign_node.getElementsByTagName('state') - if(len(callsign_state_node) > 0): - fields_and_data["STATE"] = callsign_state_node[0].firstChild.nodeValue + :arg str full_callsign: The callsign to look up (without any prefix/suffix stripping). + :arg bool ignore_prefix_suffix: True if callsign prefixes/suffixes should be removed prior to querying the server, False otherwise. + :returns: The data in a dictionary called fields_and_data. + :rtype: dict + """ - callsign_country_node = callsign_node.getElementsByTagName('country') - if(len(callsign_country_node) > 0): - fields_and_data["COUNTRY"] = callsign_country_node[0].firstChild.nodeValue + logging.debug("Looking up callsign. The full callsign (with a prefix and/or suffix) is %s" % full_callsign) - callsign_ccode_node = callsign_node.getElementsByTagName('ccode') - if(len(callsign_ccode_node) > 0): - fields_and_data["DXCC"] = callsign_ccode_node[0].firstChild.nodeValue + # Remove any prefix or suffix from the callsign before performing the lookup. + if(ignore_prefix_suffix): + callsign = strip(full_callsign) + else: + callsign = full_callsign - callsign_cqzone_node = callsign_node.getElementsByTagName('cqzone') - if(len(callsign_cqzone_node) > 0): - fields_and_data["CQZ"] = callsign_cqzone_node[0].firstChild.nodeValue + # Commence lookup. + fields_and_data = {"NAME": "", "ADDRESS": "", "STATE": "", "COUNTRY": "", "DXCC": "", "CQZ": "", "ITUZ": "", "IOTA": ""} + if(self.session_key): + request = '/xml/current/?s=%s;callsign=%s' % (self.session_key, callsign) + self.connection.request('GET', request) + response = self.connection.getresponse() - callsign_ituzone_node = callsign_node.getElementsByTagName('ituzone') - if(len(callsign_ituzone_node) > 0): - fields_and_data["ITUZ"] = callsign_ituzone_node[0].firstChild.nodeValue + xml_data = minidom.parseString(response.read()) + callsign_node = xml_data.getElementsByTagName('Callsign') + if(len(callsign_node) > 0): + callsign_node = callsign_node[0] # There should only be a maximum of one Callsign element - callsign_iota_node = callsign_node.getElementsByTagName('iota') - if(len(callsign_iota_node) > 0): - fields_and_data["IOTA"] = callsign_iota_node[0].firstChild.nodeValue - else: - # If there is no Callsign element, then print out the error message in the Session element - session_node = xml_data.getElementsByTagName('Session') - if(len(session_node) > 0): - session_error_node = session_node[0].getElementsByTagName('Error') - if(len(session_error_node) > 0): - session_error = session_error_node[0].firstChild.nodeValue - error(parent=self.parent, message=session_error) - # Return empty strings for the field data - logging.debug("Callsign lookup complete. Returning data...") - return fields_and_data + callsign_fname_node = callsign_node.getElementsByTagName('fname') + callsign_name_node = callsign_node.getElementsByTagName('name') + if(len(callsign_fname_node) > 0): + fields_and_data["NAME"] = callsign_fname_node[0].firstChild.nodeValue + if(len(callsign_name_node) > 0): # Add the surname, if present + fields_and_data["NAME"] = fields_and_data["NAME"] + " " + callsign_name_node[0].firstChild.nodeValue + + callsign_addr1_node = callsign_node.getElementsByTagName('addr1') + callsign_addr2_node = callsign_node.getElementsByTagName('addr2') + if(len(callsign_addr1_node) > 0): + fields_and_data["ADDRESS"] = callsign_addr1_node[0].firstChild.nodeValue + if(len(callsign_addr2_node) > 0): # Add the second line of the address, if present + fields_and_data["ADDRESS"] = (fields_and_data["ADDRESS"] + ", " if len(callsign_addr1_node) > 0 else "") + callsign_addr2_node[0].firstChild.nodeValue + + callsign_state_node = callsign_node.getElementsByTagName('state') + if(len(callsign_state_node) > 0): + fields_and_data["STATE"] = callsign_state_node[0].firstChild.nodeValue + + callsign_country_node = callsign_node.getElementsByTagName('country') + if(len(callsign_country_node) > 0): + fields_and_data["COUNTRY"] = callsign_country_node[0].firstChild.nodeValue + + callsign_ccode_node = callsign_node.getElementsByTagName('ccode') + if(len(callsign_ccode_node) > 0): + fields_and_data["DXCC"] = callsign_ccode_node[0].firstChild.nodeValue + + callsign_cqzone_node = callsign_node.getElementsByTagName('cqzone') + if(len(callsign_cqzone_node) > 0): + fields_and_data["CQZ"] = callsign_cqzone_node[0].firstChild.nodeValue + + callsign_ituzone_node = callsign_node.getElementsByTagName('ituzone') + if(len(callsign_ituzone_node) > 0): + fields_and_data["ITUZ"] = callsign_ituzone_node[0].firstChild.nodeValue + + callsign_iota_node = callsign_node.getElementsByTagName('iota') + if(len(callsign_iota_node) > 0): + fields_and_data["IOTA"] = callsign_iota_node[0].firstChild.nodeValue + else: + # If there is no Callsign element, then print out the error message in the Session element + session_node = xml_data.getElementsByTagName('Session') + if(len(session_node) > 0): + session_error_node = session_node[0].getElementsByTagName('Error') + if(len(session_error_node) > 0): + session_error = session_error_node[0].firstChild.nodeValue + error(parent=self.parent, message=session_error) + # Return empty strings for the field data + logging.debug("Callsign lookup complete. Returning data...") + return fields_and_data class CallsignLookupHamQTH(): - """ Use hamqth.com to lookup details about a particular callsign. """ - def __init__(self, parent): - self.parent = parent - self.connection = None - self.session_id = None - return + """ Use hamqth.com to lookup details about a particular callsign. """ - def connect(self, username, password): - """ Initiate a session with the hamqth.com server. Hopefully this will provide a session key. - - :arg str username: The username of the hamqth.com user account. - :arg str password: The password of the hamqth.com user account. - :returns: True if a successful connection was made to the server, and False otherwise. - :rtype: bool - """ + def __init__(self, parent): + self.parent = parent + self.connection = None + self.session_id = None + return - logging.debug("Connecting to the hamqth.com server...") - try: - self.connection = http.client.HTTPConnection('www.hamqth.com') - request = '/xml.php?u=%s&p=%s' % (username, password) - self.connection.request('GET', request) - response = self.connection.getresponse() - except: - error(parent=self.parent, message="Could not connect to the hamqth.com server. Check connection to the internets?") - return False + def connect(self, username, password): + """ Initiate a session with the hamqth.com server. Hopefully this will provide a session key. - xml_data = minidom.parseString(response.read()) - session_node = xml_data.getElementsByTagName('session')[0] # There should only be one Session element - session_id_node = session_node.getElementsByTagName('session_id') - if(len(session_id_node) > 0): - self.session_id = session_id_node[0].firstChild.nodeValue - logging.debug("Successfully connected to the hamqth.com server...") - connected = True - else: - connected = False + :arg str username: The username of the hamqth.com user account. + :arg str password: The password of the hamqth.com user account. + :returns: True if a successful connection was made to the server, and False otherwise. + :rtype: bool + """ - # If there are any errors or warnings, print them out - session_error_node = session_node.getElementsByTagName('error') - if(len(session_error_node) > 0): - session_error = session_error_node[0].firstChild.nodeValue - error(parent=self.parent, message="hamqth.com session error: "+session_error) + logging.debug("Connecting to the hamqth.com server...") + try: + self.connection = http.client.HTTPConnection('www.hamqth.com') + request = '/xml.php?u=%s&p=%s' % (username, password) + self.connection.request('GET', request) + response = self.connection.getresponse() + except: + error(parent=self.parent, message="Could not connect to the hamqth.com server. Check connection to the internets?") + return False - return connected + xml_data = minidom.parseString(response.read()) + session_node = xml_data.getElementsByTagName('session')[0] # There should only be one Session element + session_id_node = session_node.getElementsByTagName('session_id') + if(len(session_id_node) > 0): + self.session_id = session_id_node[0].firstChild.nodeValue + logging.debug("Successfully connected to the hamqth.com server...") + connected = True + else: + connected = False - def lookup(self, full_callsign, ignore_prefix_suffix = True): - """ Parse the XML tree that is returned from the hamqth.com XML server to obtain the NAME, ADDRESS, STATE, COUNTRY, DXCC, CQZ, ITUZ, and IOTA field data (if present), + # If there are any errors or warnings, print them out + session_error_node = session_node.getElementsByTagName('error') + if(len(session_error_node) > 0): + session_error = session_error_node[0].firstChild.nodeValue + error(parent=self.parent, message="hamqth.com session error: "+session_error) - :arg str full_callsign: The callsign to look up (without any prefix/suffix stripping). - :arg bool ignore_prefix_suffix: True if callsign prefixes/suffixes should be removed prior to querying the server, False otherwise. - :returns: The data in a dictionary called fields_and_data. - :rtype: dict - """ - - logging.debug("Looking up callsign. The full callsign (with a prefix and/or suffix) is %s" % full_callsign) - - # Remove any prefix or suffix from the callsign before performing the lookup. - if(ignore_prefix_suffix): - callsign = strip(full_callsign) - else: - callsign = full_callsign - - # Commence lookup. - fields_and_data = {"NAME":"", "ADDRESS":"", "STATE":"", "COUNTRY":"", "DXCC":"", "CQZ":"", "ITUZ":"", "IOTA":""} - if(self.session_id): - request = '/xml.php?id=%s&callsign=%s&prg=pyqso' % (self.session_id, callsign) - self.connection.request('GET', request) - response = self.connection.getresponse() + return connected - xml_data = minidom.parseString(response.read()) - search_node = xml_data.getElementsByTagName('search') - if(len(search_node) > 0): - search_node = search_node[0] # There should only be a maximum of one Callsign element + def lookup(self, full_callsign, ignore_prefix_suffix=True): + """ Parse the XML tree that is returned from the hamqth.com XML server to obtain the NAME, ADDRESS, STATE, COUNTRY, DXCC, CQZ, ITUZ, and IOTA field data (if present), - search_name_node = search_node.getElementsByTagName('nick') - if(len(search_name_node) > 0): - fields_and_data["NAME"] = search_name_node[0].firstChild.nodeValue + :arg str full_callsign: The callsign to look up (without any prefix/suffix stripping). + :arg bool ignore_prefix_suffix: True if callsign prefixes/suffixes should be removed prior to querying the server, False otherwise. + :returns: The data in a dictionary called fields_and_data. + :rtype: dict + """ - search_addr1_node = search_node.getElementsByTagName('adr_street1') - search_addr2_node = search_node.getElementsByTagName('adr_street2') - if(len(search_addr1_node) > 0): - fields_and_data["ADDRESS"] = search_addr1_node[0].firstChild.nodeValue - if(len(search_addr2_node) > 0): # Add the second line of the address, if present - fields_and_data["ADDRESS"] = (fields_and_data["ADDRESS"] + ", " if len(search_addr1_node) > 0 else "") + search_addr2_node[0].firstChild.nodeValue + logging.debug("Looking up callsign. The full callsign (with a prefix and/or suffix) is %s" % full_callsign) - search_state_node = search_node.getElementsByTagName('us_state') - if(len(search_state_node) > 0): - fields_and_data["STATE"] = search_state_node[0].firstChild.nodeValue + # Remove any prefix or suffix from the callsign before performing the lookup. + if(ignore_prefix_suffix): + callsign = strip(full_callsign) + else: + callsign = full_callsign - search_country_node = search_node.getElementsByTagName('country') - if(len(search_country_node) > 0): - fields_and_data["COUNTRY"] = search_country_node[0].firstChild.nodeValue + # Commence lookup. + fields_and_data = {"NAME": "", "ADDRESS": "", "STATE": "", "COUNTRY": "", "DXCC": "", "CQZ": "", "ITUZ": "", "IOTA": ""} + if(self.session_id): + request = '/xml.php?id=%s&callsign=%s&prg=pyqso' % (self.session_id, callsign) + self.connection.request('GET', request) + response = self.connection.getresponse() - search_cqzone_node = search_node.getElementsByTagName('cq') - if(len(search_cqzone_node) > 0): - fields_and_data["CQZ"] = search_cqzone_node[0].firstChild.nodeValue + xml_data = minidom.parseString(response.read()) + search_node = xml_data.getElementsByTagName('search') + if(len(search_node) > 0): + search_node = search_node[0] # There should only be a maximum of one Callsign element - search_ituzone_node = search_node.getElementsByTagName('itu') - if(len(search_ituzone_node) > 0): - fields_and_data["ITUZ"] = search_ituzone_node[0].firstChild.nodeValue + search_name_node = search_node.getElementsByTagName('nick') + if(len(search_name_node) > 0): + fields_and_data["NAME"] = search_name_node[0].firstChild.nodeValue - search_iota_node = search_node.getElementsByTagName('grid') - if(len(search_iota_node) > 0): - fields_and_data["IOTA"] = search_iota_node[0].firstChild.nodeValue - else: - # If there is no Callsign element, then print out the error message in the Session element - session_node = xml_data.getElementsByTagName('session') - if(len(session_node) > 0): - session_error_node = session_node[0].getElementsByTagName('error') - if(len(session_error_node) > 0): - session_error = session_error_node[0].firstChild.nodeValue - error(parent=self.parent, message=session_error) - # Return empty strings for the field data - logging.debug("Callsign lookup complete. Returning data...") - return fields_and_data + search_addr1_node = search_node.getElementsByTagName('adr_street1') + search_addr2_node = search_node.getElementsByTagName('adr_street2') + if(len(search_addr1_node) > 0): + fields_and_data["ADDRESS"] = search_addr1_node[0].firstChild.nodeValue + if(len(search_addr2_node) > 0): # Add the second line of the address, if present + fields_and_data["ADDRESS"] = (fields_and_data["ADDRESS"] + ", " if len(search_addr1_node) > 0 else "") + search_addr2_node[0].firstChild.nodeValue + + search_state_node = search_node.getElementsByTagName('us_state') + if(len(search_state_node) > 0): + fields_and_data["STATE"] = search_state_node[0].firstChild.nodeValue + + search_country_node = search_node.getElementsByTagName('country') + if(len(search_country_node) > 0): + fields_and_data["COUNTRY"] = search_country_node[0].firstChild.nodeValue + + search_cqzone_node = search_node.getElementsByTagName('cq') + if(len(search_cqzone_node) > 0): + fields_and_data["CQZ"] = search_cqzone_node[0].firstChild.nodeValue + + search_ituzone_node = search_node.getElementsByTagName('itu') + if(len(search_ituzone_node) > 0): + fields_and_data["ITUZ"] = search_ituzone_node[0].firstChild.nodeValue + + search_iota_node = search_node.getElementsByTagName('grid') + if(len(search_iota_node) > 0): + fields_and_data["IOTA"] = search_iota_node[0].firstChild.nodeValue + else: + # If there is no Callsign element, then print out the error message in the Session element + session_node = xml_data.getElementsByTagName('session') + if(len(session_node) > 0): + session_error_node = session_node[0].getElementsByTagName('error') + if(len(session_error_node) > 0): + session_error = session_error_node[0].firstChild.nodeValue + error(parent=self.parent, message=session_error) + # Return empty strings for the field data + logging.debug("Callsign lookup complete. Returning data...") + return fields_and_data def strip(full_callsign): - """ Remove any prefixes or suffixes from a callsign. - - :arg str full_callsign: The callsign to be considered for prefix/suffix removal. - :returns: The callsign with prefixes/suffixes removed. - :rtype: str - """ - - components = full_callsign.split("/") # We assume that prefixes or suffixes come before/after a forward slash character "/". - suffixes = ["P", "M", "A", "PM", "MM", "AM", "QRP"] - try: - if(len(components) == 3): - # We have both a prefix and a suffix. - callsign = components[1] - - elif(len(components) == 2): - if(components[1].upper() in suffixes or components[1].lower() in suffixes): - # If the last part of the full_callsign is a valid suffix, then use the part before that. - callsign = components[0] - logging.debug("Suffix %s found. Callsign to lookup is %s" % (components[1], callsign)) - else: - # We have a prefix, so take the part after the first "/". + """ Remove any prefixes or suffixes from a callsign. + + :arg str full_callsign: The callsign to be considered for prefix/suffix removal. + :returns: The callsign with prefixes/suffixes removed. + :rtype: str + """ + + components = full_callsign.split("/") # We assume that prefixes or suffixes come before/after a forward slash character "/". + suffixes = ["P", "M", "A", "PM", "MM", "AM", "QRP"] + try: + if(len(components) == 3): + # We have both a prefix and a suffix. callsign = components[1] - logging.debug("Prefix %s found. Callsign to lookup is %s" % (components[0], callsign)) - - elif(len(components) == 1): - # We have neither a prefix nor a suffix, so use the full_callsign. - callsign = full_callsign - logging.debug("No prefix or suffix found. Callsign to lookup is %s" % callsign) - - else: - raise ValueError - except ValueError: - callsign = full_callsign - return callsign - + + elif(len(components) == 2): + if(components[1].upper() in suffixes or components[1].lower() in suffixes): + # If the last part of the full_callsign is a valid suffix, then use the part before that. + callsign = components[0] + logging.debug("Suffix %s found. Callsign to lookup is %s" % (components[1], callsign)) + else: + # We have a prefix, so take the part after the first "/". + callsign = components[1] + logging.debug("Prefix %s found. Callsign to lookup is %s" % (components[0], callsign)) + + elif(len(components) == 1): + # We have neither a prefix nor a suffix, so use the full_callsign. + callsign = full_callsign + logging.debug("No prefix or suffix found. Callsign to lookup is %s" % callsign) + + else: + raise ValueError + except ValueError: + callsign = full_callsign + return callsign + + class TestCallsignLookup(unittest.TestCase): - """ The unit tests for the CallsignLookup class. """ - def setUp(self): - """ Set up the objects needed for the unit tests. """ - self.qrz = CallsignLookupQRZ(parent=None) - self.hamqth = CallsignLookupHamQTH(parent=None) + """ The unit tests for the CallsignLookup class. """ - def tearDown(self): - """ Destroy any unit test resources. """ - pass + def setUp(self): + """ Set up the objects needed for the unit tests. """ + self.qrz = CallsignLookupQRZ(parent=None) + self.hamqth = CallsignLookupHamQTH(parent=None) - def test_strip(self): - """ Check that a callsign with a prefix and a suffix is stripped correctly. """ - callsign = "EA3/MYCALL/MM" - assert strip(callsign) == "MYCALL" - - def test_strip_prefix_only(self): - """ Check that a callsign with only a prefix is stripped correctly. """ - callsign = "EA3/MYCALL" - assert strip(callsign) == "MYCALL" - - def test_strip_suffix_only(self): - """ Check that a callsign with only a suffix is stripped correctly. """ - callsign = "MYCALL/M" - assert strip(callsign) == "MYCALL" + def tearDown(self): + """ Destroy any unit test resources. """ + pass - def test_strip_no_prefix_or_suffix(self): - """ Check that a callsign with no prefix or suffix remains unmodified. """ - callsign = "MYCALL" - assert strip(callsign) == "MYCALL" + def test_strip(self): + """ Check that a callsign with a prefix and a suffix is stripped correctly. """ + callsign = "EA3/MYCALL/MM" + assert strip(callsign) == "MYCALL" - def test_qrz_connect(self): - """ Check the example response from the qrz.com server, and make sure the session key has been correctly extracted. """ + def test_strip_prefix_only(self): + """ Check that a callsign with only a prefix is stripped correctly. """ + callsign = "EA3/MYCALL" + assert strip(callsign) == "MYCALL" - http.client.HTTPConnection = unittest.mock.Mock(spec=http.client.HTTPConnection) - http.client.HTTPResponse = unittest.mock.Mock(spec=http.client.HTTPResponse) - connection = http.client.HTTPConnection() - response = http.client.HTTPResponse() + def test_strip_suffix_only(self): + """ Check that a callsign with only a suffix is stripped correctly. """ + callsign = "MYCALL/M" + assert strip(callsign) == "MYCALL" - response.read.return_value = b'\n\n\n3b1fd1d3ba495189984f93ff67bd45b6\n61\nnon-subscriber\nSun Nov 22 21:25:34 2015\ncpu: 0.147s\n\n\n' - connection.getresponse.return_value = response + def test_strip_no_prefix_or_suffix(self): + """ Check that a callsign with no prefix or suffix remains unmodified. """ + callsign = "MYCALL" + assert strip(callsign) == "MYCALL" - result = self.qrz.connect("hello", "world") - assert(result) - assert(self.qrz.session_key == "3b1fd1d3ba495189984f93ff67bd45b6") + def test_qrz_connect(self): + """ Check the example response from the qrz.com server, and make sure the session key has been correctly extracted. """ - def test_qrz_lookup(self): - """ Check the example callsign lookup response from the qrz.com server, and make sure the callsign information has been correctly extracted. """ + http.client.HTTPConnection = unittest.mock.Mock(spec=http.client.HTTPConnection) + http.client.HTTPResponse = unittest.mock.Mock(spec=http.client.HTTPResponse) + connection = http.client.HTTPConnection() + response = http.client.HTTPResponse() - http.client.HTTPConnection = unittest.mock.Mock(spec=http.client.HTTPConnection) - http.client.HTTPResponse = unittest.mock.Mock(spec=http.client.HTTPResponse) - connection = http.client.HTTPConnection() - response = http.client.HTTPResponse() + response.read.return_value = b'\n\n\n3b1fd1d3ba495189984f93ff67bd45b6\n61\nnon-subscriber\nSun Nov 22 21:25:34 2015\ncpu: 0.147s\n\n\n' + connection.getresponse.return_value = response - response.read.return_value = b'\n\n\nMYCALL\nFIRSTNAME\nLASTNAME\nADDRESS2\nCOUNTRY\n\n\n3b1fd1d3ba495189984f93ff67bd45b6\n61\nnon-subscriber\nA subscription is required to access the complete record.\nSun Nov 22 21:34:46 2015\ncpu: 0.026s\n\n\n' - connection.getresponse.return_value = response + result = self.qrz.connect("hello", "world") + assert(result) + assert(self.qrz.session_key == "3b1fd1d3ba495189984f93ff67bd45b6") - self.qrz.connection = connection - self.qrz.session_key = "3b1fd1d3ba495189984f93ff67bd45b6" - fields_and_data = self.qrz.lookup("MYCALL") - assert(fields_and_data["NAME"] == "FIRSTNAME LASTNAME") - assert(fields_and_data["ADDRESS"] == "ADDRESS2") - assert(fields_and_data["COUNTRY"] == "COUNTRY") + def test_qrz_lookup(self): + """ Check the example callsign lookup response from the qrz.com server, and make sure the callsign information has been correctly extracted. """ - def test_hamqth_connect(self): - """ Check the example response from the hamqth.com server, and make sure the session ID has been correctly extracted. """ + http.client.HTTPConnection = unittest.mock.Mock(spec=http.client.HTTPConnection) + http.client.HTTPResponse = unittest.mock.Mock(spec=http.client.HTTPResponse) + connection = http.client.HTTPConnection() + response = http.client.HTTPResponse() - http.client.HTTPConnection = unittest.mock.Mock(spec=http.client.HTTPConnection) - http.client.HTTPResponse = unittest.mock.Mock(spec=http.client.HTTPResponse) - connection = http.client.HTTPConnection() - response = http.client.HTTPResponse() + response.read.return_value = b'\n\n\nMYCALL\nFIRSTNAME\nLASTNAME\nADDRESS2\nCOUNTRY\n\n\n3b1fd1d3ba495189984f93ff67bd45b6\n61\nnon-subscriber\nA subscription is required to access the complete record.\nSun Nov 22 21:34:46 2015\ncpu: 0.026s\n\n\n' + connection.getresponse.return_value = response - response.read.return_value = b'\n\n\n09b0ae90050be03c452ad235a1f2915ad684393c\n\n\n' - connection.getresponse.return_value = response + self.qrz.connection = connection + self.qrz.session_key = "3b1fd1d3ba495189984f93ff67bd45b6" + fields_and_data = self.qrz.lookup("MYCALL") + assert(fields_and_data["NAME"] == "FIRSTNAME LASTNAME") + assert(fields_and_data["ADDRESS"] == "ADDRESS2") + assert(fields_and_data["COUNTRY"] == "COUNTRY") - result = self.hamqth.connect("hello", "world") - assert(result) - assert(self.hamqth.session_id == "09b0ae90050be03c452ad235a1f2915ad684393c") + def test_hamqth_connect(self): + """ Check the example response from the hamqth.com server, and make sure the session ID has been correctly extracted. """ - def test_hamqth_lookup(self): - """ Check the example callsign lookup response from the hamqth.com server, and make sure the callsign information has been correctly extracted. """ + http.client.HTTPConnection = unittest.mock.Mock(spec=http.client.HTTPConnection) + http.client.HTTPResponse = unittest.mock.Mock(spec=http.client.HTTPResponse) + connection = http.client.HTTPConnection() + response = http.client.HTTPResponse() - http.client.HTTPConnection = unittest.mock.Mock(spec=http.client.HTTPConnection) - http.client.HTTPResponse = unittest.mock.Mock(spec=http.client.HTTPResponse) - connection = http.client.HTTPConnection() - response = http.client.HTTPResponse() + response.read.return_value = b'\n\n\n09b0ae90050be03c452ad235a1f2915ad684393c\n\n\n' + connection.getresponse.return_value = response - response.read.return_value = b'\n\n\nMYCALL\nNAME\nCOUNTRY\nITU\nCQ\nGRID\nADDRESS\n\n\n' - connection.getresponse.return_value = response + result = self.hamqth.connect("hello", "world") + assert(result) + assert(self.hamqth.session_id == "09b0ae90050be03c452ad235a1f2915ad684393c") + + def test_hamqth_lookup(self): + """ Check the example callsign lookup response from the hamqth.com server, and make sure the callsign information has been correctly extracted. """ + + http.client.HTTPConnection = unittest.mock.Mock(spec=http.client.HTTPConnection) + http.client.HTTPResponse = unittest.mock.Mock(spec=http.client.HTTPResponse) + connection = http.client.HTTPConnection() + response = http.client.HTTPResponse() + + response.read.return_value = b'\n\n\nMYCALL\nNAME\nCOUNTRY\nITU\nCQ\nGRID\nADDRESS\n\n\n' + connection.getresponse.return_value = response + + self.hamqth.connection = connection + self.hamqth.session_id = "09b0ae90050be03c452ad235a1f2915ad684393c" + fields_and_data = self.hamqth.lookup("MYCALL") + assert(fields_and_data["NAME"] == "NAME") + assert(fields_and_data["ADDRESS"] == "ADDRESS") + assert(fields_and_data["COUNTRY"] == "COUNTRY") + assert(fields_and_data["CQZ"] == "CQ") + assert(fields_and_data["ITUZ"] == "ITU") + assert(fields_and_data["IOTA"] == "GRID") - self.hamqth.connection = connection - self.hamqth.session_id = "09b0ae90050be03c452ad235a1f2915ad684393c" - fields_and_data = self.hamqth.lookup("MYCALL") - assert(fields_and_data["NAME"] == "NAME") - assert(fields_and_data["ADDRESS"] == "ADDRESS") - assert(fields_and_data["COUNTRY"] == "COUNTRY") - assert(fields_and_data["CQZ"] == "CQ") - assert(fields_and_data["ITUZ"] == "ITU") - assert(fields_and_data["IOTA"] == "GRID") - if(__name__ == '__main__'): - unittest.main() + unittest.main() diff --git a/pyqso/dx_cluster.py b/pyqso/dx_cluster.py index f21b0e8..c23bb8f 100644 --- a/pyqso/dx_cluster.py +++ b/pyqso/dx_cluster.py @@ -29,322 +29,326 @@ from pyqso.telnet_connection_dialog import * BOOKMARKS_FILE = os.path.expanduser('~/.config/pyqso/bookmarks.ini') + class DXCluster(Gtk.VBox): - """ A tool for connecting to a DX cluster (specifically Telnet-based DX clusters). """ - - def __init__(self, parent): - """ Set up the DX cluster's Gtk.VBox, and set up a timer so that PyQSO can retrieve new data from the Telnet server every few seconds. - - :arg parent: The parent Gtk window. - """ - logging.debug("Setting up the DX cluster...") - Gtk.VBox.__init__(self, spacing=2) - self.connection = None - self.parent = parent + """ A tool for connecting to a DX cluster (specifically Telnet-based DX clusters). """ - # Set up the menubar - self.menubar = Gtk.MenuBar() - - self.items = {} - - ###### CONNECTION ###### - mitem_connection = Gtk.MenuItem(label="Connection") - self.menubar.append(mitem_connection) - subm_connection = Gtk.Menu() - mitem_connection.set_submenu(subm_connection) + def __init__(self, parent): + """ Set up the DX cluster's Gtk.VBox, and set up a timer so that PyQSO can retrieve new data from the Telnet server every few seconds. - # Connect - mitem_connect = Gtk.ImageMenuItem(label="Connect to Telnet Server") - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_CONNECT, Gtk.IconSize.MENU) - mitem_connect.set_image(icon) - subm_connection.append(mitem_connect) - self.items["CONNECT"] = mitem_connect + :arg parent: The parent Gtk window. + """ + logging.debug("Setting up the DX cluster...") + Gtk.VBox.__init__(self, spacing=2) - subm_connect = Gtk.Menu() - - ## New - mitem_new = Gtk.MenuItem(label="New...") - mitem_new.connect("activate", self.new_server) - subm_connect.append(mitem_new) + self.connection = None + self.parent = parent - ## From Bookmark - mitem_bookmark = Gtk.MenuItem(label="From Bookmark") - self.subm_bookmarks = Gtk.Menu() - mitem_bookmark.set_submenu(self.subm_bookmarks) - self._populate_bookmarks() - subm_connect.append(mitem_bookmark) - - mitem_connect.set_submenu(subm_connect) + # Set up the menubar + self.menubar = Gtk.MenuBar() - # Disconnect - mitem_disconnect = Gtk.ImageMenuItem(label="Disconnect from Telnet Server") - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_DISCONNECT, Gtk.IconSize.MENU) - mitem_disconnect.set_image(icon) - mitem_disconnect.connect("activate", self.telnet_disconnect) - subm_connection.append(mitem_disconnect) - self.items["DISCONNECT"] = mitem_disconnect + self.items = {} - self.pack_start(self.menubar, False, False, 0) + # CONNECTION ###### + mitem_connection = Gtk.MenuItem(label="Connection") + self.menubar.append(mitem_connection) + subm_connection = Gtk.Menu() + mitem_connection.set_submenu(subm_connection) - # A TextView object to display the output from the Telnet server. - self.renderer = Gtk.TextView() - self.renderer.set_editable(False) - self.renderer.set_cursor_visible(False) - sw = Gtk.ScrolledWindow() - sw.set_shadow_type(Gtk.ShadowType.ETCHED_IN) - sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - sw.add(self.renderer) - self.buffer = self.renderer.get_buffer() - self.pack_start(sw, True, True, 0) + # Connect + mitem_connect = Gtk.ImageMenuItem(label="Connect to Telnet Server") + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_CONNECT, Gtk.IconSize.MENU) + mitem_connect.set_image(icon) + subm_connection.append(mitem_connect) + self.items["CONNECT"] = mitem_connect - # Set up the command box. - self.commandbox = Gtk.HBox(spacing=2) - self.command = Gtk.Entry() - self.commandbox.pack_start(self.command, True, True, 0) - self.send = Gtk.Button(label="Send Command") - self.send.connect("clicked", self.telnet_send_command) - self.commandbox.pack_start(self.send, False, False, 0) - self.pack_start(self.commandbox, False, False, 0) - - self.set_items_sensitive(True) - - self.show_all() + subm_connect = Gtk.Menu() - logging.debug("DX cluster ready!") + # New + mitem_new = Gtk.MenuItem(label="New...") + mitem_new.connect("activate", self.new_server) + subm_connect.append(mitem_new) - return + # From Bookmark + mitem_bookmark = Gtk.MenuItem(label="From Bookmark") + self.subm_bookmarks = Gtk.Menu() + mitem_bookmark.set_submenu(self.subm_bookmarks) + self._populate_bookmarks() + subm_connect.append(mitem_bookmark) + + mitem_connect.set_submenu(subm_connect) + + # Disconnect + mitem_disconnect = Gtk.ImageMenuItem(label="Disconnect from Telnet Server") + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_DISCONNECT, Gtk.IconSize.MENU) + mitem_disconnect.set_image(icon) + mitem_disconnect.connect("activate", self.telnet_disconnect) + subm_connection.append(mitem_disconnect) + self.items["DISCONNECT"] = mitem_disconnect + + self.pack_start(self.menubar, False, False, 0) + + # A TextView object to display the output from the Telnet server. + self.renderer = Gtk.TextView() + self.renderer.set_editable(False) + self.renderer.set_cursor_visible(False) + sw = Gtk.ScrolledWindow() + sw.set_shadow_type(Gtk.ShadowType.ETCHED_IN) + sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + sw.add(self.renderer) + self.buffer = self.renderer.get_buffer() + self.pack_start(sw, True, True, 0) + + # Set up the command box. + self.commandbox = Gtk.HBox(spacing=2) + self.command = Gtk.Entry() + self.commandbox.pack_start(self.command, True, True, 0) + self.send = Gtk.Button(label="Send Command") + self.send.connect("clicked", self.telnet_send_command) + self.commandbox.pack_start(self.send, False, False, 0) + self.pack_start(self.commandbox, False, False, 0) + + self.set_items_sensitive(True) + + self.show_all() + + logging.debug("DX cluster ready!") + + return + + def new_server(self, widget=None): + """ Get Telnet server host and login details specified in the Gtk.Entry boxes in the TelnetConnectionDialog and attempt a connection. """ + dialog = TelnetConnectionDialog(self.parent) + response = dialog.run() + + if(response == Gtk.ResponseType.OK): + connection_info = dialog.get_connection_info() + host = connection_info["HOST"].get_text() + port = connection_info["PORT"].get_text() + username = connection_info["USERNAME"].get_text() + password = connection_info["PASSWORD"].get_text() + + # Save the server details in a new bookmark, if desired. + if(connection_info["BOOKMARK"].get_active()): + try: + config = configparser.ConfigParser() + config.read(BOOKMARKS_FILE) + + # Use the host name as the bookmark's identifier. + try: + config.add_section(host) + except configparser.DuplicateSectionError: + # If the hostname already exists, assume the user wants to update the port number, username and/or password. + logging.warning("A server with hostname '%s' already exists. Over-writing existing details..." % (host)) + config.set(host, "host", host) + config.set(host, "port", port) + config.set(host, "username", username) + config.set(host, "password", password) + + # Write the bookmarks to file. + if not os.path.exists(os.path.expanduser('~/.config/pyqso')): + os.makedirs(os.path.expanduser('~/.config/pyqso')) + with open(BOOKMARKS_FILE, 'w') as f: + config.write(f) + + self._populate_bookmarks() + + except IOError: + # Maybe the bookmarks file could not be written to? + logging.error("Bookmark could not be saved. Check bookmarks file permissions? Going ahead with the server connection anyway...") + + dialog.destroy() - def new_server(self, widget=None): - """ Get Telnet server host and login details specified in the Gtk.Entry boxes in the TelnetConnectionDialog and attempt a connection. """ - dialog = TelnetConnectionDialog(self.parent) - response = dialog.run() - - if(response == Gtk.ResponseType.OK): - connection_info = dialog.get_connection_info() - host = connection_info["HOST"].get_text() - port = connection_info["PORT"].get_text() - username = connection_info["USERNAME"].get_text() - password = connection_info["PASSWORD"].get_text() - - # Save the server details in a new bookmark, if desired. - if(connection_info["BOOKMARK"].get_active()): try: - config = configparser.ConfigParser() - config.read(BOOKMARKS_FILE) - - # Use the host name as the bookmark's identifier. - try: - config.add_section(host) - except configparser.DuplicateSectionError: - # If the hostname already exists, assume the user wants to update the port number, username and/or password. - logging.warning("A server with hostname '%s' already exists. Over-writing existing details..." % (host)) - config.set(host, "host", host) - config.set(host, "port", port) - config.set(host, "username", username) - config.set(host, "password", password) + # Convert port (currently of type str) into an int. + port = int(port) + # Attempt a connection with the server. + self.telnet_connect(host, port, username, password) + except ValueError as e: + logging.error("Could not convert the server's port information to an integer.") + logging.exception(e) - # Write the bookmarks to file. - if not os.path.exists(os.path.expanduser('~/.config/pyqso')): - os.makedirs(os.path.expanduser('~/.config/pyqso')) - with open(BOOKMARKS_FILE, 'w') as f: - config.write(f) - - self._populate_bookmarks() + else: + dialog.destroy() + return - except IOError: - # Maybe the bookmarks file could not be written to? - logging.error("Bookmark could not be saved. Check bookmarks file permissions? Going ahead with the server connection anyway...") - - dialog.destroy() - - try: - # Convert port (currently of type str) into an int. - port = int(port) - # Attempt a connection with the server. + def _populate_bookmarks(self): + """ Populate the list of bookmarked Telnet servers in the menu. """ + config = configparser.ConfigParser() + have_config = (config.read(BOOKMARKS_FILE) != []) + + if(have_config): + try: + # Clear the menu of all current bookmarks. + for i in self.subm_bookmarks.get_children(): + self.subm_bookmarks.remove(i) + + # Add all bookmarks in the config file. + for bookmark in config.sections(): + mitem = Gtk.MenuItem(label=bookmark) + mitem.connect("activate", self.bookmarked_server, bookmark) + self.subm_bookmarks.append(mitem) + + except Exception as e: + logging.error("An error occurred whilst populating the DX cluster bookmarks menu.") + logging.exception(e) + + self.show_all() # Need to do this to update the bookmarks list in the menu. + + return + + def bookmarked_server(self, widget, name): + """ Get Telnet server host and login details from an existing bookmark and attempt a connection. + + :arg str name: The name of the bookmark. This is the same as the server's hostname. + """ + + config = configparser.ConfigParser() + have_config = (config.read(BOOKMARKS_FILE) != []) + try: + if(not have_config): + raise IOError("The bookmark's details could not be loaded.") + + host = config.get(name, "host") + port = int(config.get(name, "port")) + username = config.get(name, "username") + password = config.get(name, "password") self.telnet_connect(host, port, username, password) - except ValueError as e: - logging.error("Could not convert the server's port information to an integer.") + + except ValueError as e: + # This exception may occur when casting the port (which is a str) to an int. + logging.exception(e) + except IOError as e: + logging.exception(e) + except Exception as e: + logging.error("Could not connect to Telnet server '%s'" % name) logging.exception(e) - else: - dialog.destroy() - return + return - def _populate_bookmarks(self): - """ Populate the list of bookmarked Telnet servers in the menu. """ - config = configparser.ConfigParser() - have_config = (config.read(BOOKMARKS_FILE) != []) + def telnet_connect(self, host, port=23, username=None, password=None): + """ Connect to a user-specified Telnet server. - if(have_config): - try: - # Clear the menu of all current bookmarks. - for i in self.subm_bookmarks.get_children(): - self.subm_bookmarks.remove(i) + :arg str host: The Telnet server's hostname. + :arg int port: The Telnet server's port number. If no port is specified, the default Telnet server port of 23 will be used. + :arg str username: The user's username. This is an optional argument. + :arg str password: The user's password. This is an optional argument. + """ - # Add all bookmarks in the config file. - for bookmark in config.sections(): - mitem = Gtk.MenuItem(label=bookmark) - mitem.connect("activate", self.bookmarked_server, bookmark) - self.subm_bookmarks.append(mitem) + if(host == "" or host is None): + logging.error("No Telnet server specified.") + return + if(port == "" or port is None): + port = 23 # Use the default Telnet port - except Exception as e: - logging.error("An error occurred whilst populating the DX cluster bookmarks menu.") + try: + self.connection = telnetlib.Telnet(host, port) + + if(username): + self.connection.read_until("login: ".encode()) + self.connection.write((username + "\n").encode()) + if(password): + self.connection.read_until("password: ".encode()) + self.connection.write((password + "\n").encode()) + except Exception as e: + logging.error("Could not create a connection to the Telnet server") logging.exception(e) + self.connection = None + return - self.show_all() # Need to do this to update the bookmarks list in the menu. + self.set_items_sensitive(False) - return + self.check_io_event = GObject.timeout_add(1000, self._on_telnet_io) - def bookmarked_server(self, widget, name): - """ Get Telnet server host and login details from an existing bookmark and attempt a connection. - - :arg str name: The name of the bookmark. This is the same as the server's hostname. - """ - - config = configparser.ConfigParser() - have_config = (config.read(BOOKMARKS_FILE) != []) - try: - if(not have_config): - raise IOError("The bookmark's details could not be loaded.") - - host = config.get(name, "host") - port = int(config.get(name, "port")) - username = config.get(name, "username") - password = config.get(name, "password") - self.telnet_connect(host, port, username, password) - - except ValueError as e: - # This exception may occur when casting the port (which is a str) to an int. - logging.exception(e) - except IOError as e: - logging.exception(e) - except Exception as e: - logging.error("Could not connect to Telnet server '%s'" % name) - logging.exception(e) + return - return - - def telnet_connect(self, host, port=23, username=None, password=None): - """ Connect to a user-specified Telnet server. - - :arg str host: The Telnet server's hostname. - :arg int port: The Telnet server's port number. If no port is specified, the default Telnet server port of 23 will be used. - :arg str username: The user's username. This is an optional argument. - :arg str password: The user's password. This is an optional argument. - """ + def telnet_disconnect(self, widget=None): + """ Disconnect from a Telnet server and remove the I/O timer. """ + if(self.connection): + self.connection.close() + self.buffer.set_text("") + self.connection = None + self.set_items_sensitive(True) - if(host == "" or host is None): - logging.error("No Telnet server specified.") - return - if(port == "" or port is None): - port = 23 # Use the default Telnet port - - try: - self.connection = telnetlib.Telnet(host, port) - - if(username): - self.connection.read_until("login: ".encode()) - self.connection.write((username + "\n").encode()) - if(password): - self.connection.read_until("password: ".encode()) - self.connection.write((password + "\n").encode()) - except Exception as e: - logging.error("Could not create a connection to the Telnet server") - logging.exception(e) - self.connection = None - return - - self.set_items_sensitive(False) - - self.check_io_event = GObject.timeout_add(1000, self._on_telnet_io) - - return - - def telnet_disconnect(self, widget=None): - """ Disconnect from a Telnet server and remove the I/O timer. """ - if(self.connection): - self.connection.close() - self.buffer.set_text("") - self.connection = None - self.set_items_sensitive(True) - - # Stop checking for server output once disconnected. - try: - GObject.source_remove(self.check_io_event) - except AttributeError: - # This may happen if a connection hasn't yet been established. - pass - - return - - def telnet_send_command(self, widget=None): - """ Send the user-specified command in the Gtk.Entry box to the Telnet server (if PyQSO is connected to one). """ - if(self.connection): - self.connection.write((self.command.get_text() + "\n").encode()) - self.command.set_text("") - return - - def _on_telnet_io(self): - """ Retrieve any new data from the Telnet server and print it out in the Gtk.TextView widget. - - :returns: Always returns True to satisfy the GObject timer. - :rtype: bool - """ - if(self.connection): - text = self.connection.read_very_eager().decode() - try: - text = text.replace("\u0007", "") # Remove the BEL Unicode character from the end of the line - except UnicodeDecodeError: + # Stop checking for server output once disconnected. + try: + GObject.source_remove(self.check_io_event) + except AttributeError: + # This may happen if a connection hasn't yet been established. pass - - # Allow auto-scrolling to the new text entry if the focus is already at - # the very end of the Gtk.TextView. Otherwise, don't auto-scroll - # in case the user is reading something further up. - # Note: This is based on the code from http://forums.gentoo.org/viewtopic-t-445598-view-next.html - end_iter = self.buffer.get_end_iter() - end_mark = self.buffer.create_mark(None, end_iter) - self.renderer.move_mark_onscreen(end_mark) - at_end = self.buffer.get_iter_at_mark(end_mark).equal(end_iter) - self.buffer.insert(end_iter, text) - if(at_end): + + return + + def telnet_send_command(self, widget=None): + """ Send the user-specified command in the Gtk.Entry box to the Telnet server (if PyQSO is connected to one). """ + if(self.connection): + self.connection.write((self.command.get_text() + "\n").encode()) + self.command.set_text("") + return + + def _on_telnet_io(self): + """ Retrieve any new data from the Telnet server and print it out in the Gtk.TextView widget. + + :returns: Always returns True to satisfy the GObject timer. + :rtype: bool + """ + if(self.connection): + text = self.connection.read_very_eager().decode() + try: + text = text.replace("\u0007", "") # Remove the BEL Unicode character from the end of the line + except UnicodeDecodeError: + pass + + # Allow auto-scrolling to the new text entry if the focus is already at + # the very end of the Gtk.TextView. Otherwise, don't auto-scroll + # in case the user is reading something further up. + # Note: This is based on the code from http://forums.gentoo.org/viewtopic-t-445598-view-next.html + end_iter = self.buffer.get_end_iter() end_mark = self.buffer.create_mark(None, end_iter) - self.renderer.scroll_mark_onscreen(end_mark) + self.renderer.move_mark_onscreen(end_mark) + at_end = self.buffer.get_iter_at_mark(end_mark).equal(end_iter) + self.buffer.insert(end_iter, text) + if(at_end): + end_mark = self.buffer.create_mark(None, end_iter) + self.renderer.scroll_mark_onscreen(end_mark) - return True + return True + + def set_items_sensitive(self, sensitive): + """ Enable/disable the relevant buttons for connecting/disconnecting from a DX cluster, so that users cannot click the connect button if PyQSO is already connected. + + :arg bool sensitive: If True, enable the Connect button and disable the Disconnect button. If False, vice versa. + """ + self.items["CONNECT"].set_sensitive(sensitive) + self.items["DISCONNECT"].set_sensitive(not sensitive) + self.send.set_sensitive(not sensitive) + return - def set_items_sensitive(self, sensitive): - """ Enable/disable the relevant buttons for connecting/disconnecting from a DX cluster, so that users cannot click the connect button if PyQSO is already connected. - - :arg bool sensitive: If True, enable the Connect button and disable the Disconnect button. If False, vice versa. - """ - self.items["CONNECT"].set_sensitive(sensitive) - self.items["DISCONNECT"].set_sensitive(not sensitive) - self.send.set_sensitive(not sensitive) - return class TestDXCluster(unittest.TestCase): - """ The unit tests for the DXCluster class. """ - def setUp(self): - """ Set up the objects needed for the unit tests. """ - self.dxcluster = DXCluster(parent=None) + """ The unit tests for the DXCluster class. """ - def tearDown(self): - """ Destroy any unit test resources. """ - pass + def setUp(self): + """ Set up the objects needed for the unit tests. """ + self.dxcluster = DXCluster(parent=None) - def test_on_telnet_io(self): - """ Check that the response from the Telnet server can be correctly decoded. """ + def tearDown(self): + """ Destroy any unit test resources. """ + pass + + def test_on_telnet_io(self): + """ Check that the response from the Telnet server can be correctly decoded. """ + + telnetlib.Telnet = unittest.mock.Mock(spec=telnetlib.Telnet) + connection = telnetlib.Telnet("hello", "world") + connection.read_very_eager.return_value = b"Test message from the Telnet server." + self.dxcluster.connection = connection + result = self.dxcluster._on_telnet_io() + assert(result) - telnetlib.Telnet = unittest.mock.Mock(spec=telnetlib.Telnet) - connection = telnetlib.Telnet("hello", "world") - connection.read_very_eager.return_value = b"Test message from the Telnet server." - self.dxcluster.connection = connection - result = self.dxcluster._on_telnet_io() - assert(result) - if(__name__ == '__main__'): - unittest.main() + unittest.main() diff --git a/pyqso/grey_line.py b/pyqso/grey_line.py index c45e596..415d0af 100644 --- a/pyqso/grey_line.py +++ b/pyqso/grey_line.py @@ -21,74 +21,75 @@ from gi.repository import Gtk, GObject import logging from datetime import datetime try: - import numpy - logging.info("Using version %s of numpy." % (numpy.__version__)) - import matplotlib - logging.info("Using version %s of matplotlib." % (matplotlib.__version__)) - matplotlib.use('Agg') - matplotlib.rcParams['font.size'] = 10.0 - import mpl_toolkits.basemap - logging.info("Using version %s of mpl_toolkits.basemap." % (mpl_toolkits.basemap.__version__)) - from matplotlib.backends.backend_gtk3cairo import FigureCanvasGTK3Cairo as FigureCanvas - have_necessary_modules = True + import numpy + logging.info("Using version %s of numpy." % (numpy.__version__)) + import matplotlib + logging.info("Using version %s of matplotlib." % (matplotlib.__version__)) + matplotlib.use('Agg') + matplotlib.rcParams['font.size'] = 10.0 + import mpl_toolkits.basemap + logging.info("Using version %s of mpl_toolkits.basemap." % (mpl_toolkits.basemap.__version__)) + from matplotlib.backends.backend_gtk3cairo import FigureCanvasGTK3Cairo as FigureCanvas + have_necessary_modules = True except ImportError as e: - logging.warning(e) - logging.warning("Could not import a non-standard Python module needed by the GreyLine class, or the version of the non-standard module is too old. Check that all the PyQSO dependencies are satisfied.") - have_necessary_modules = False + logging.warning(e) + logging.warning("Could not import a non-standard Python module needed by the GreyLine class, or the version of the non-standard module is too old. Check that all the PyQSO dependencies are satisfied.") + have_necessary_modules = False + class GreyLine(Gtk.VBox): - """ A tool for visualising the grey line. """ - - def __init__(self, parent): - """ Set up the drawing canvas and the timer which will re-plot the grey line every 30 minutes. - - :arg parent: The parent Gtk window. - """ - logging.debug("Setting up the grey line...") - Gtk.VBox.__init__(self, spacing=2) - self.parent = parent - if(have_necessary_modules): - self.fig = matplotlib.figure.Figure() - self.canvas = FigureCanvas(self.fig) # For embedding in the Gtk application - self.pack_start(self.canvas, True, True, 0) - self.refresh_event = GObject.timeout_add(1800000, self.draw) # Re-draw the grey line automatically after 30 minutes (if the grey line tool is visible). + """ A tool for visualising the grey line. """ - self.show_all() + def __init__(self, parent): + """ Set up the drawing canvas and the timer which will re-plot the grey line every 30 minutes. - logging.debug("Grey line ready!") + :arg parent: The parent Gtk window. + """ + logging.debug("Setting up the grey line...") + Gtk.VBox.__init__(self, spacing=2) + self.parent = parent - return + if(have_necessary_modules): + self.fig = matplotlib.figure.Figure() + self.canvas = FigureCanvas(self.fig) # For embedding in the Gtk application + self.pack_start(self.canvas, True, True, 0) + self.refresh_event = GObject.timeout_add(1800000, self.draw) # Re-draw the grey line automatically after 30 minutes (if the grey line tool is visible). - def draw(self): - """ Draw the world map and the grey line on top of it. - - :returns: Always returns True to satisfy the GObject timer, unless the necessary GreyLine dependencies are not satisfied (in which case, the method returns False so as to not re-draw the canvas). - :rtype: bool - """ + self.show_all() - if(have_necessary_modules): - if(self.parent.toolbox.tools.get_current_page() != 1 or not self.parent.toolbox.get_visible()): - # Don't re-draw if the grey line is not visible. - return True # We need to return True in case this is method was called by a timer event. - else: - logging.debug("Drawing the grey line...") - # Re-draw the grey line - self.fig.clf() - sub = self.fig.add_subplot(111) + logging.debug("Grey line ready!") - # Draw the map of the world. This is based on the example from: - # http://matplotlib.org/basemap/users/examples.html - m = mpl_toolkits.basemap.Basemap(projection='mill', lon_0=0, ax=sub, resolution='c', fix_aspect=False) - m.drawcountries(linewidth=0.5) - m.drawcoastlines(linewidth=0.5) - m.drawparallels(numpy.arange(-90, 90, 30), labels=[1, 0, 0, 0]) - m.drawmeridians(numpy.arange(m.lonmin, m.lonmax+30, 60), labels=[0, 0, 0, 1]) - m.drawmapboundary(fill_color='lightblue') - m.fillcontinents(color='darkgreen', lake_color='lightblue') - m.nightshade(datetime.utcnow()) # Add in the grey line using UTC time. Note that this requires NetCDF. - logging.debug("Grey line drawn.") - return True - else: - return False # Don't try to re-draw the canvas if the necessary modules to do so could not be imported. + return + def draw(self): + """ Draw the world map and the grey line on top of it. + + :returns: Always returns True to satisfy the GObject timer, unless the necessary GreyLine dependencies are not satisfied (in which case, the method returns False so as to not re-draw the canvas). + :rtype: bool + """ + + if(have_necessary_modules): + if(self.parent.toolbox.tools.get_current_page() != 1 or not self.parent.toolbox.get_visible()): + # Don't re-draw if the grey line is not visible. + return True # We need to return True in case this is method was called by a timer event. + else: + logging.debug("Drawing the grey line...") + # Re-draw the grey line + self.fig.clf() + sub = self.fig.add_subplot(111) + + # Draw the map of the world. This is based on the example from: + # http://matplotlib.org/basemap/users/examples.html + m = mpl_toolkits.basemap.Basemap(projection='mill', lon_0=0, ax=sub, resolution='c', fix_aspect=False) + m.drawcountries(linewidth=0.5) + m.drawcoastlines(linewidth=0.5) + m.drawparallels(numpy.arange(-90, 90, 30), labels=[1, 0, 0, 0]) + m.drawmeridians(numpy.arange(m.lonmin, m.lonmax+30, 60), labels=[0, 0, 0, 1]) + m.drawmapboundary(fill_color='lightblue') + m.fillcontinents(color='darkgreen', lake_color='lightblue') + m.nightshade(datetime.utcnow()) # Add in the grey line using UTC time. Note that this requires NetCDF. + logging.debug("Grey line drawn.") + return True + else: + return False # Don't try to re-draw the canvas if the necessary modules to do so could not be imported. diff --git a/pyqso/log.py b/pyqso/log.py index a091d90..8a5eb8e 100644 --- a/pyqso/log.py +++ b/pyqso/log.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python3 # Copyright (C) 2013 Christian T. Jacobs. @@ -25,431 +25,434 @@ import unittest from pyqso.adif import AVAILABLE_FIELD_NAMES_ORDERED from pyqso.record_dialog import * + class Log(Gtk.ListStore): - """ A single log inside of the whole logbook. A Log object can store multiple Record objects. """ - - def __init__(self, connection, name): - """ Set up a new Log object. - - :arg connection: An sqlite database connection. - :arg str name: The name of the log (i.e. the sqlite table name). - """ - # The ListStore constructor needs to know the data types of the columns. - # The index is always an integer. We will assume the fields are strings. - data_types = [int] + [str]*len(AVAILABLE_FIELD_NAMES_ORDERED) - # Call the constructor of the super class (Gtk.ListStore) - Gtk.ListStore.__init__(self, *data_types) + """ A single log inside of the whole logbook. A Log object can store multiple Record objects. """ - self.connection = connection - self.name = name - - logging.debug("New Log instance created!") - return + def __init__(self, connection, name): + """ Set up a new Log object. - def populate(self): - """ Remove everything in the Gtk.ListStore that is rendered already (via the TreeView), and start afresh. """ + :arg connection: An sqlite database connection. + :arg str name: The name of the log (i.e. the sqlite table name). + """ - logging.debug("Populating '%s'..." % self.name) - self.add_missing_db_columns() - self.clear() - records = self.get_all_records() - if(records is not None): - for r in records: - liststore_entry = [r["id"]] - for field_name in AVAILABLE_FIELD_NAMES_ORDERED: - # Note: r may contain column names that are not in AVAILABLE_FIELD_NAMES_ORDERED, - # so we need to loop over and only select those that are, since the ListStore will - # expect a specific number of columns. - liststore_entry.append(r[field_name]) + # The ListStore constructor needs to know the data types of the columns. + # The index is always an integer. We will assume the fields are strings. + data_types = [int] + [str]*len(AVAILABLE_FIELD_NAMES_ORDERED) + # Call the constructor of the super class (Gtk.ListStore) + Gtk.ListStore.__init__(self, *data_types) + + self.connection = connection + self.name = name + + logging.debug("New Log instance created!") + return + + def populate(self): + """ Remove everything in the Gtk.ListStore that is rendered already (via the TreeView), and start afresh. """ + + logging.debug("Populating '%s'..." % self.name) + self.add_missing_db_columns() + self.clear() + records = self.get_all_records() + if(records is not None): + for r in records: + liststore_entry = [r["id"]] + for field_name in AVAILABLE_FIELD_NAMES_ORDERED: + # Note: r may contain column names that are not in AVAILABLE_FIELD_NAMES_ORDERED, + # so we need to loop over and only select those that are, since the ListStore will + # expect a specific number of columns. + liststore_entry.append(r[field_name]) + self.append(liststore_entry) + logging.debug("Finished populating '%s'." % self.name) + else: + logging.error("Could not populate '%s' because of a database error." % self.name) + return + + def add_missing_db_columns(self): + """ Check whether each field name in AVAILABLE_FIELD_NAMES_ORDERED is in the database table. If not, add it + (with all entries being set to an empty string initially). + + :raises sqlite.Error, IndexError: if the existing database column names could not be obtained, or missing column names could not be added. + """ + logging.debug("Adding any missing database columns...") + + # Get all the column names in the current database table. + column_names = [] + try: + with self.connection: + c = self.connection.cursor() + c.execute("PRAGMA table_info(%s)" % self.name) + result = c.fetchall() + for t in result: + column_names.append(t[1].upper()) + except (sqlite.Error, IndexError) as e: + logging.exception(e) + logging.error("Could not obtain the database column names.") + return + + for field_name in AVAILABLE_FIELD_NAMES_ORDERED: + if(not(field_name in column_names)): + try: + with self.connection: + c.execute("ALTER TABLE %s ADD COLUMN %s TEXT DEFAULT \"\"" % (self.name, field_name.lower())) + except sqlite.Error as e: + logging.exception(e) + logging.error("Could not add the missing database column '%s'." % field_name) + pass + logging.debug("Finished adding any missing database columns.") + return + + def add_record(self, fields_and_data): + """ Add a record (or multiple records) to the log. + + :arg fields_and_data: A list of dictionaries (or possibly just a single dictionary), with each dictionary representing a single QSO, to be added to the log. + """ + logging.debug("Adding record(s) to log...") + + # If a dictionary is given, assume that we only have one record to add. + if isinstance(fields_and_data, dict): + fields_and_data = [fields_and_data] + + with self.connection: + # Get all the column names in the current database table. + c = self.connection.cursor() + c.execute("PRAGMA table_info(%s)" % self.name) + column_names = c.fetchall() + # Get the index of the last inserted record in the database. + c.execute('SELECT max(id) FROM %s' % self.name) + last_index = c.fetchone()[0] + if last_index is None: + # Assume no records are currently present. + last_index = 0 + + # A list of all the database entries, to be inserted in one go into the database. + database_entries = [] + + # Construct the SQL query. + query = "INSERT INTO %s VALUES (NULL" % self.name + for i in range(len(column_names)-1): # -1 here because we don't want to count the database's 'id' field. + query = query + ",?" + query = query + ")" + + # Gather all the records (making sure that the entries of each record are in the correct order). + for r in range(len(fields_and_data)): + # What if the database columns are not necessarily in the same order as (or even exist in) AVAILABLE_FIELD_NAMES_ORDERED? + # PyQSO handles this here, but needs a separate list (called database_entry) to successfully perform the SQL query. + database_entry = [] + for t in column_names: + column_name = str(t[1]) # 't' here is a tuple + if((column_name.upper() in AVAILABLE_FIELD_NAMES_ORDERED) and (column_name.upper() in list(fields_and_data[r].keys()))): + database_entry.append(fields_and_data[r][column_name.upper()]) + else: + if(column_name != "id"): # Ignore the row index field. This is a special case since it's not in AVAILABLE_FIELD_NAMES_ORDERED. + database_entry.append("") + database_entries.append(database_entry) + + # Add the data to the ListStore as well. + liststore_entry = [] + field_names = AVAILABLE_FIELD_NAMES_ORDERED + for i in range(0, len(field_names)): + if(field_names[i] in list(fields_and_data[r].keys())): + liststore_entry.append(fields_and_data[r][field_names[i]]) + else: + liststore_entry.append("") + + # Add the record's index. + index = last_index + (r+1) # +1 here because r begins at zero, and we don't want to count the already-present record with index last_index. + liststore_entry.insert(0, index) self.append(liststore_entry) - logging.debug("Finished populating '%s'." % self.name) - else: - logging.error("Could not populate '%s' because of a database error." % self.name) - return - def add_missing_db_columns(self): - """ Check whether each field name in AVAILABLE_FIELD_NAMES_ORDERED is in the database table. If not, add it - (with all entries being set to an empty string initially). - - :raises sqlite.Error, IndexError: if the existing database column names could not be obtained, or missing column names could not be added. - """ - logging.debug("Adding any missing database columns...") + # Execute the query. + with self.connection: + c.executemany(query, database_entries) - # Get all the column names in the current database table. - column_names = [] - try: - with self.connection: - c = self.connection.cursor() - c.execute("PRAGMA table_info(%s)" % self.name) - result = c.fetchall() - for t in result: - column_names.append(t[1].upper()) - except (sqlite.Error, IndexError) as e: - logging.exception(e) - logging.error("Could not obtain the database column names.") - return + logging.debug("Successfully added the record(s) to the log.") + return - for field_name in AVAILABLE_FIELD_NAMES_ORDERED: - if(not(field_name in column_names)): - try: - with self.connection: - c.execute("ALTER TABLE %s ADD COLUMN %s TEXT DEFAULT \"\"" % (self.name, field_name.lower())) - except sqlite.Error as e: - logging.exception(e) - logging.error("Could not add the missing database column '%s'." % field_name) - pass - logging.debug("Finished adding any missing database columns.") - return + def delete_record(self, index, iter=None): + """ Delete a specified record from the log. The corresponding record is also deleted from the Gtk.ListStore data structure. - def add_record(self, fields_and_data): - """ Add a record (or multiple records) to the log. - - :arg fields_and_data: A list of dictionaries (or possibly just a single dictionary), with each dictionary representing a single QSO, to be added to the log. - """ - logging.debug("Adding record(s) to log...") - - # If a dictionary is given, assume that we only have one record to add. - if isinstance(fields_and_data, dict): - fields_and_data = [fields_and_data] - - with self.connection: - # Get all the column names in the current database table. - c = self.connection.cursor() - c.execute("PRAGMA table_info(%s)" % self.name) - column_names = c.fetchall() - # Get the index of the last inserted record in the database. - c.execute('SELECT max(id) FROM %s' % self.name) - last_index = c.fetchone()[0] - if last_index is None: - # Assume no records are currently present. - last_index = 0 - - # A list of all the database entries, to be inserted in one go into the database. - database_entries = [] + :arg int index: The index of the record in the SQL database. + :arg iter: iter should always be given. It is given a default value of None for unit testing purposes only. + :raises sqlite.Error, IndexError: if the record could not be deleted. + """ + logging.debug("Deleting record from log...") + # Get the selected row in the logbook + try: + with self.connection: + c = self.connection.cursor() + query = "DELETE FROM %s" % self.name + c.execute(query+" WHERE id=?", [index]) + if(iter is not None): + self.remove(iter) + logging.debug("Successfully deleted the record from the log.") + except (sqlite.Error, IndexError) as e: + logging.exception(e) + logging.error("Could not delete the record from the log.") + return - # Construct the SQL query. - query = "INSERT INTO %s VALUES (NULL" % self.name - for i in range(len(column_names)-1): # -1 here because we don't want to count the database's 'id' field. - query = query + ",?" - query = query + ")" - - # Gather all the records (making sure that the entries of each record are in the correct order). - for r in range(len(fields_and_data)): - # What if the database columns are not necessarily in the same order as (or even exist in) AVAILABLE_FIELD_NAMES_ORDERED? - # PyQSO handles this here, but needs a separate list (called database_entry) to successfully perform the SQL query. - database_entry = [] - for t in column_names: - column_name = str(t[1]) # 't' here is a tuple - if( (column_name.upper() in AVAILABLE_FIELD_NAMES_ORDERED) and (column_name.upper() in list(fields_and_data[r].keys())) ): - database_entry.append(fields_and_data[r][column_name.upper()]) - else: - if(column_name != "id"): # Ignore the row index field. This is a special case since it's not in AVAILABLE_FIELD_NAMES_ORDERED. - database_entry.append("") - database_entries.append(database_entry) - - # Add the data to the ListStore as well. - liststore_entry = [] - field_names = AVAILABLE_FIELD_NAMES_ORDERED - for i in range(0, len(field_names)): - if(field_names[i] in list(fields_and_data[r].keys())): - liststore_entry.append(fields_and_data[r][field_names[i]]) - else: - liststore_entry.append("") + def edit_record(self, index, field_name, data, iter=None, column_index=None): + """ Edit a specified record by replacing the current data in a specified field with the data provided. - # Add the record's index. - index = last_index + (r+1) # +1 here because r begins at zero, and we don't want to count the already-present record with index last_index. - liststore_entry.insert(0, index) - self.append(liststore_entry) - - # Execute the query. - with self.connection: - c.executemany(query, database_entries) - - logging.debug("Successfully added the record(s) to the log.") - return + :arg int index: The index of the record in the SQL database. + :arg str field_name: The name of the field whose data should be modified. + :arg str data: The data that should replace the current data in the field. + :arg iter: Should always be given. A default value of None is used for unit testing purposes only. + :arg column_index: Should always be given. A default value of None is used for unit testing purposes only. + :raises sqlite.Error, IndexError: if the record could not be edited. + """ + logging.debug("Editing field '%s' in record %d..." % (field_name, index)) + try: + with self.connection: + c = self.connection.cursor() + query = "UPDATE %s SET %s" % (self.name, field_name) + query = query + "=? WHERE id=?" + c.execute(query, [data, index]) # First update the SQL database... + if(iter is not None and column_index is not None): + self.set(iter, column_index, data) # ...and then the ListStore. + logging.debug("Successfully edited field '%s' in record %d in the log." % (field_name, index)) + except (sqlite.Error, IndexError) as e: + logging.exception(e) + logging.error("Could not edit field %s in record %d in the log." % (field_name, index)) + return - def delete_record(self, index, iter=None): - """ Delete a specified record from the log. The corresponding record is also deleted from the Gtk.ListStore data structure. - - :arg int index: The index of the record in the SQL database. - :arg iter: iter should always be given. It is given a default value of None for unit testing purposes only. - :raises sqlite.Error, IndexError: if the record could not be deleted. - """ - logging.debug("Deleting record from log...") - # Get the selected row in the logbook - try: - with self.connection: - c = self.connection.cursor() - query = "DELETE FROM %s" % self.name - c.execute(query+" WHERE id=?", [index]) - if(iter is not None): - self.remove(iter) - logging.debug("Successfully deleted the record from the log.") - except (sqlite.Error, IndexError) as e: - logging.exception(e) - logging.error("Could not delete the record from the log.") - return + def remove_duplicates(self): + """ Remove any duplicate records from the log. - def edit_record(self, index, field_name, data, iter=None, column_index=None): - """ Edit a specified record by replacing the current data in a specified field with the data provided. - - :arg int index: The index of the record in the SQL database. - :arg str field_name: The name of the field whose data should be modified. - :arg str data: The data that should replace the current data in the field. - :arg iter: Should always be given. A default value of None is used for unit testing purposes only. - :arg column_index: Should always be given. A default value of None is used for unit testing purposes only. - :raises sqlite.Error, IndexError: if the record could not be edited. - """ - logging.debug("Editing field '%s' in record %d..." % (field_name, index)) - try: - with self.connection: - c = self.connection.cursor() - query = "UPDATE %s SET %s" % (self.name, field_name) - query = query + "=? WHERE id=?" - c.execute(query, [data, index]) # First update the SQL database... - if(iter is not None and column_index is not None): - self.set(iter, column_index, data) # ...and then the ListStore. - logging.debug("Successfully edited field '%s' in record %d in the log." % (field_name, index)) - except (sqlite.Error, IndexError) as e: - logging.exception(e) - logging.error("Could not edit field %s in record %d in the log." % (field_name, index)) - return + :returns: The total number of duplicates, and the number of duplicates that were successfully removed. Hopefully these will be the same. + :rtype: tuple + """ + duplicates = self.get_duplicates() + if(len(duplicates) == 0): + return (0, 0) # Nothing to do here. - def remove_duplicates(self): - """ Remove any duplicate records from the log. - - :returns: The total number of duplicates, and the number of duplicates that were successfully removed. Hopefully these will be the same. - :rtype: tuple - """ - duplicates = self.get_duplicates() - if(len(duplicates) == 0): - return (0, 0) # Nothing to do here. + removed = 0 # Count the number of records that are removed. Hopefully this will be the same as len(duplicates). + while removed != len(duplicates): # Unfortunately, in certain cases, extra passes may be necessary to ensure that all duplicates are removed. + path = Gtk.TreePath(0) # Start with the first row in the log. + iter = self.get_iter(path) + while iter is not None: + row_index = self.get_value(iter, 0) # Get the index. + if(row_index in duplicates): # Is this a duplicate row? If so, delete it. + self.delete_record(row_index, iter) + removed += 1 + iter = self.iter_next(iter) # Move on to the next row, until iter_next returns None. - removed = 0 # Count the number of records that are removed. Hopefully this will be the same as len(duplicates). - while removed != len(duplicates): # Unfortunately, in certain cases, extra passes may be necessary to ensure that all duplicates are removed. - path = Gtk.TreePath(0) # Start with the first row in the log. - iter = self.get_iter(path) - while iter is not None: - row_index = self.get_value(iter, 0) # Get the index. - if(row_index in duplicates): # Is this a duplicate row? If so, delete it. - self.delete_record(row_index, iter) - removed += 1 - iter = self.iter_next(iter) # Move on to the next row, until iter_next returns None. + assert(removed == len(duplicates)) + return (len(duplicates), removed) - assert(removed == len(duplicates)) - return (len(duplicates), removed) + def get_duplicates(self): + """ Find the duplicates in the log, based on the CALL, QSO_DATE, TIME_ON, FREQ and MODE fields. - def get_duplicates(self): - """ Find the duplicates in the log, based on the CALL, QSO_DATE, TIME_ON, FREQ and MODE fields. - - :returns: A list of row IDs corresponding to the duplicate records. - :rtype: list - """ - duplicates = [] - try: - with self.connection: - c = self.connection.cursor() - c.execute( - """SELECT rowid FROM %s WHERE rowid NOT IN + :returns: A list of row IDs corresponding to the duplicate records. + :rtype: list + """ + duplicates = [] + try: + with self.connection: + c = self.connection.cursor() + c.execute( + """SELECT rowid FROM %s WHERE rowid NOT IN ( SELECT MIN(rowid) FROM %s GROUP BY call, qso_date, time_on, freq, mode )""" % (self.name, self.name)) - result = c.fetchall() - for rowid in result: - duplicates.append(rowid[0]) # Get the integer from inside the tuple. - except (sqlite.Error, IndexError) as e: - logging.exception(e) - return duplicates - - def get_record_by_index(self, index): - """ Return a record with a given index in the log. - - :arg int index: The index of the record in the SQL database. - :returns: The desired record, represented by a dictionary of field-value pairs. - :rtype: dict - """ - try: - with self.connection: - c = self.connection.cursor() - query = "SELECT * FROM %s WHERE id=?" % self.name - c.execute(query, [index]) - return c.fetchone() - except sqlite.Error as e: - logging.exception(e) - return None + result = c.fetchall() + for rowid in result: + duplicates.append(rowid[0]) # Get the integer from inside the tuple. + except (sqlite.Error, IndexError) as e: + logging.exception(e) + return duplicates - def get_all_records(self): - """ Return a list of all the records in the log. - - :returns: A list of all the records in the log. Each record is represented by a dictionary. - :rtype: dict - """ - try: - with self.connection: - c = self.connection.cursor() - c.execute("SELECT * FROM %s" % self.name) - return c.fetchall() - except sqlite.Error as e: - logging.exception(e) - return None + def get_record_by_index(self, index): + """ Return a record with a given index in the log. + + :arg int index: The index of the record in the SQL database. + :returns: The desired record, represented by a dictionary of field-value pairs. + :rtype: dict + """ + try: + with self.connection: + c = self.connection.cursor() + query = "SELECT * FROM %s WHERE id=?" % self.name + c.execute(query, [index]) + return c.fetchone() + except sqlite.Error as e: + logging.exception(e) + return None + + def get_all_records(self): + """ Return a list of all the records in the log. + + :returns: A list of all the records in the log. Each record is represented by a dictionary. + :rtype: dict + """ + try: + with self.connection: + c = self.connection.cursor() + c.execute("SELECT * FROM %s" % self.name) + return c.fetchall() + except sqlite.Error as e: + logging.exception(e) + return None + + def get_number_of_records(self): + """ Return the total number of records in the log. + + :returns: The total number of records in the log. + :rtype: int + """ + try: + with self.connection: + c = self.connection.cursor() + c.execute("SELECT Count(*) FROM %s" % self.name) + return c.fetchone()[0] + except (sqlite.Error, IndexError) as e: + logging.exception(e) + return None - def get_number_of_records(self): - """ Return the total number of records in the log. - - :returns: The total number of records in the log. - :rtype: int - """ - try: - with self.connection: - c = self.connection.cursor() - c.execute("SELECT Count(*) FROM %s" % self.name) - return c.fetchone()[0] - except (sqlite.Error, IndexError) as e: - logging.exception(e) - return None class TestLog(unittest.TestCase): - def setUp(self): - self.connection = sqlite.connect(":memory:") - self.connection.row_factory = sqlite.Row + def setUp(self): + self.connection = sqlite.connect(":memory:") + self.connection.row_factory = sqlite.Row - self.field_names = ["CALL", "QSO_DATE", "TIME_ON", "FREQ", "BAND", "MODE", "RST_SENT", "RST_RCVD"] - self.fields_and_data = {"CALL":"TEST123", "QSO_DATE":"20130312", "TIME_ON":"1234", "FREQ":"145.500", "BAND":"2m", "MODE":"FM", "RST_SENT":"59", "RST_RCVD":"59"} + self.field_names = ["CALL", "QSO_DATE", "TIME_ON", "FREQ", "BAND", "MODE", "RST_SENT", "RST_RCVD"] + self.fields_and_data = {"CALL": "TEST123", "QSO_DATE": "20130312", "TIME_ON": "1234", "FREQ": "145.500", "BAND": "2m", "MODE": "FM", "RST_SENT": "59", "RST_RCVD": "59"} - c = self.connection.cursor() - query = "CREATE TABLE test (id INTEGER PRIMARY KEY AUTOINCREMENT" - for field_name in self.field_names: - s = ", %s TEXT" % field_name.lower() - query = query + s - query = query + ")" - c.execute(query) + c = self.connection.cursor() + query = "CREATE TABLE test (id INTEGER PRIMARY KEY AUTOINCREMENT" + for field_name in self.field_names: + s = ", %s TEXT" % field_name.lower() + query = query + s + query = query + ")" + c.execute(query) - self.log = Log(self.connection, "test") + self.log = Log(self.connection, "test") - def tearDown(self): - self.connection.close() + def tearDown(self): + self.connection.close() - def test_log_add_missing_db_columns(self): + def test_log_add_missing_db_columns(self): - column_names_before = [] - column_names_after = [] + column_names_before = [] + column_names_after = [] - c = self.connection.cursor() - c.execute("PRAGMA table_info(test)") - result = c.fetchall() - for t in result: - column_names_before.append(t[1].upper()) + c = self.connection.cursor() + c.execute("PRAGMA table_info(test)") + result = c.fetchall() + for t in result: + column_names_before.append(t[1].upper()) - self.log.add_missing_db_columns() + self.log.add_missing_db_columns() - c.execute("PRAGMA table_info(test)") - result = c.fetchall() - for t in result: - column_names_after.append(t[1].upper()) + c.execute("PRAGMA table_info(test)") + result = c.fetchall() + for t in result: + column_names_after.append(t[1].upper()) - print("Column names before: ", column_names_before) - print("Column names after: ", column_names_after) + print("Column names before: ", column_names_before) + print("Column names after: ", column_names_after) - assert(len(column_names_before) == len(self.field_names) + 1) # Added 1 here because of the "ID" column in all database tables. - assert(len(column_names_after) == len(AVAILABLE_FIELD_NAMES_ORDERED) + 1) - for field_name in AVAILABLE_FIELD_NAMES_ORDERED: - assert(field_name in column_names_after) + assert(len(column_names_before) == len(self.field_names) + 1) # Added 1 here because of the "ID" column in all database tables. + assert(len(column_names_after) == len(AVAILABLE_FIELD_NAMES_ORDERED) + 1) + for field_name in AVAILABLE_FIELD_NAMES_ORDERED: + assert(field_name in column_names_after) - def test_log_add_record(self): - self.log.add_record(self.fields_and_data) - c = self.connection.cursor() - c.execute("SELECT * FROM test") - records = c.fetchall() - - assert len(records) == 1 - - for field_name in self.field_names: - print(self.fields_and_data[field_name], records[0][field_name]) - assert self.fields_and_data[field_name] == records[0][field_name] + def test_log_add_record(self): + self.log.add_record(self.fields_and_data) + c = self.connection.cursor() + c.execute("SELECT * FROM test") + records = c.fetchall() - def test_log_delete_record(self): - query = "INSERT INTO test VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, ?)" - c = self.connection.cursor() - c.execute(query, (self.fields_and_data["CALL"], self.fields_and_data["QSO_DATE"], self.fields_and_data["TIME_ON"], self.fields_and_data["FREQ"], self.fields_and_data["BAND"], self.fields_and_data["MODE"], self.fields_and_data["RST_SENT"], self.fields_and_data["RST_RCVD"])) + assert len(records) == 1 - c.execute("SELECT * FROM test") - records_before = c.fetchall() + for field_name in self.field_names: + print(self.fields_and_data[field_name], records[0][field_name]) + assert self.fields_and_data[field_name] == records[0][field_name] - self.log.delete_record(1) + def test_log_delete_record(self): + query = "INSERT INTO test VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, ?)" + c = self.connection.cursor() + c.execute(query, (self.fields_and_data["CALL"], self.fields_and_data["QSO_DATE"], self.fields_and_data["TIME_ON"], self.fields_and_data["FREQ"], self.fields_and_data["BAND"], self.fields_and_data["MODE"], self.fields_and_data["RST_SENT"], self.fields_and_data["RST_RCVD"])) - c.execute("SELECT * FROM test") - records_after = c.fetchall() + c.execute("SELECT * FROM test") + records_before = c.fetchall() - assert(len(records_before) == 1) - assert(len(records_after) == 0) - - def test_log_edit_record(self): - query = "INSERT INTO test VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, ?)" - c = self.connection.cursor() - c.execute(query, (self.fields_and_data["CALL"], self.fields_and_data["QSO_DATE"], self.fields_and_data["TIME_ON"], self.fields_and_data["FREQ"], self.fields_and_data["BAND"], self.fields_and_data["MODE"], self.fields_and_data["RST_SENT"], self.fields_and_data["RST_RCVD"])) + self.log.delete_record(1) - c.execute("SELECT * FROM test") - record_before = c.fetchall()[0] + c.execute("SELECT * FROM test") + records_after = c.fetchall() - self.log.edit_record(1, "CALL", "TEST456") - self.log.edit_record(1, "FREQ", "145.450") + assert(len(records_before) == 1) + assert(len(records_after) == 0) - c.execute("SELECT * FROM test") - record_after = c.fetchall()[0] + def test_log_edit_record(self): + query = "INSERT INTO test VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, ?)" + c = self.connection.cursor() + c.execute(query, (self.fields_and_data["CALL"], self.fields_and_data["QSO_DATE"], self.fields_and_data["TIME_ON"], self.fields_and_data["FREQ"], self.fields_and_data["BAND"], self.fields_and_data["MODE"], self.fields_and_data["RST_SENT"], self.fields_and_data["RST_RCVD"])) - assert(record_before["CALL"] == "TEST123") - assert(record_after["CALL"] == "TEST456") - assert(record_before["FREQ"] == "145.500") - assert(record_after["FREQ"] == "145.450") + c.execute("SELECT * FROM test") + record_before = c.fetchall()[0] - def test_log_get_record_by_index(self): - query = "INSERT INTO test VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, ?)" - c = self.connection.cursor() - c.execute(query, (self.fields_and_data["CALL"], self.fields_and_data["QSO_DATE"], self.fields_and_data["TIME_ON"], self.fields_and_data["FREQ"], self.fields_and_data["BAND"], self.fields_and_data["MODE"], self.fields_and_data["RST_SENT"], self.fields_and_data["RST_RCVD"])) + self.log.edit_record(1, "CALL", "TEST456") + self.log.edit_record(1, "FREQ", "145.450") - record = self.log.get_record_by_index(1) - print("Contents of retrieved record: ", record) - for field_name in list(record.keys()): - if(field_name.upper() == "ID"): - continue - else: - assert(record[field_name.upper()] == self.fields_and_data[field_name.upper()]) - assert(len(record) == len(self.fields_and_data) + 1) + c.execute("SELECT * FROM test") + record_after = c.fetchall()[0] - def test_log_get_all_records(self): - query = "INSERT INTO test VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, ?)" - c = self.connection.cursor() - # Add the same record twice - c.execute(query, (self.fields_and_data["CALL"], self.fields_and_data["QSO_DATE"], self.fields_and_data["TIME_ON"], self.fields_and_data["FREQ"], self.fields_and_data["BAND"], self.fields_and_data["MODE"], self.fields_and_data["RST_SENT"], self.fields_and_data["RST_RCVD"])) - c.execute(query, (self.fields_and_data["CALL"], self.fields_and_data["QSO_DATE"], self.fields_and_data["TIME_ON"], self.fields_and_data["FREQ"], self.fields_and_data["BAND"], self.fields_and_data["MODE"], self.fields_and_data["RST_SENT"], self.fields_and_data["RST_RCVD"])) + assert(record_before["CALL"] == "TEST123") + assert(record_after["CALL"] == "TEST456") + assert(record_before["FREQ"] == "145.500") + assert(record_after["FREQ"] == "145.450") - records = self.log.get_all_records() - print("Contents of all retrieved records: ", records) - assert(len(records) == 2) # There should be 2 records - for field_name in self.field_names: - assert(records[0][field_name] == self.fields_and_data[field_name]) - assert(records[1][field_name] == self.fields_and_data[field_name]) + def test_log_get_record_by_index(self): + query = "INSERT INTO test VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, ?)" + c = self.connection.cursor() + c.execute(query, (self.fields_and_data["CALL"], self.fields_and_data["QSO_DATE"], self.fields_and_data["TIME_ON"], self.fields_and_data["FREQ"], self.fields_and_data["BAND"], self.fields_and_data["MODE"], self.fields_and_data["RST_SENT"], self.fields_and_data["RST_RCVD"])) - def test_log_get_number_of_records(self): - query = "INSERT INTO test VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, ?)" - c = self.connection.cursor() - # Add the same record twice - c.execute(query, (self.fields_and_data["CALL"], self.fields_and_data["QSO_DATE"], self.fields_and_data["TIME_ON"], self.fields_and_data["FREQ"], self.fields_and_data["BAND"], self.fields_and_data["MODE"], self.fields_and_data["RST_SENT"], self.fields_and_data["RST_RCVD"])) - c.execute(query, (self.fields_and_data["CALL"], self.fields_and_data["QSO_DATE"], self.fields_and_data["TIME_ON"], self.fields_and_data["FREQ"], self.fields_and_data["BAND"], self.fields_and_data["MODE"], self.fields_and_data["RST_SENT"], self.fields_and_data["RST_RCVD"])) + record = self.log.get_record_by_index(1) + print("Contents of retrieved record: ", record) + for field_name in list(record.keys()): + if(field_name.upper() == "ID"): + continue + else: + assert(record[field_name.upper()] == self.fields_and_data[field_name.upper()]) + assert(len(record) == len(self.fields_and_data) + 1) - number_of_records = self.log.get_number_of_records() - print("Number of records in the log: ", number_of_records) - assert(number_of_records == 2) # There should be 2 records + def test_log_get_all_records(self): + query = "INSERT INTO test VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, ?)" + c = self.connection.cursor() + # Add the same record twice + c.execute(query, (self.fields_and_data["CALL"], self.fields_and_data["QSO_DATE"], self.fields_and_data["TIME_ON"], self.fields_and_data["FREQ"], self.fields_and_data["BAND"], self.fields_and_data["MODE"], self.fields_and_data["RST_SENT"], self.fields_and_data["RST_RCVD"])) + c.execute(query, (self.fields_and_data["CALL"], self.fields_and_data["QSO_DATE"], self.fields_and_data["TIME_ON"], self.fields_and_data["FREQ"], self.fields_and_data["BAND"], self.fields_and_data["MODE"], self.fields_and_data["RST_SENT"], self.fields_and_data["RST_RCVD"])) - def test_log_get_duplicates(self): - query = "INSERT INTO test VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, ?)" - c = self.connection.cursor() - n = 5 # The total number of records to insert. - for i in range(0, n): - c.execute(query, (self.fields_and_data["CALL"], self.fields_and_data["QSO_DATE"], self.fields_and_data["TIME_ON"], self.fields_and_data["FREQ"], self.fields_and_data["BAND"], self.fields_and_data["MODE"], self.fields_and_data["RST_SENT"], self.fields_and_data["RST_RCVD"])) - assert len(self.log.get_duplicates()) == n-1 # Expecting n-1 duplicates. + records = self.log.get_all_records() + print("Contents of all retrieved records: ", records) + assert(len(records) == 2) # There should be 2 records + for field_name in self.field_names: + assert(records[0][field_name] == self.fields_and_data[field_name]) + assert(records[1][field_name] == self.fields_and_data[field_name]) + + def test_log_get_number_of_records(self): + query = "INSERT INTO test VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, ?)" + c = self.connection.cursor() + # Add the same record twice + c.execute(query, (self.fields_and_data["CALL"], self.fields_and_data["QSO_DATE"], self.fields_and_data["TIME_ON"], self.fields_and_data["FREQ"], self.fields_and_data["BAND"], self.fields_and_data["MODE"], self.fields_and_data["RST_SENT"], self.fields_and_data["RST_RCVD"])) + c.execute(query, (self.fields_and_data["CALL"], self.fields_and_data["QSO_DATE"], self.fields_and_data["TIME_ON"], self.fields_and_data["FREQ"], self.fields_and_data["BAND"], self.fields_and_data["MODE"], self.fields_and_data["RST_SENT"], self.fields_and_data["RST_RCVD"])) + + number_of_records = self.log.get_number_of_records() + print("Number of records in the log: ", number_of_records) + assert(number_of_records == 2) # There should be 2 records + + def test_log_get_duplicates(self): + query = "INSERT INTO test VALUES (NULL, ?, ?, ?, ?, ?, ?, ?, ?)" + c = self.connection.cursor() + n = 5 # The total number of records to insert. + for i in range(0, n): + c.execute(query, (self.fields_and_data["CALL"], self.fields_and_data["QSO_DATE"], self.fields_and_data["TIME_ON"], self.fields_and_data["FREQ"], self.fields_and_data["BAND"], self.fields_and_data["MODE"], self.fields_and_data["RST_SENT"], self.fields_and_data["RST_RCVD"])) + assert len(self.log.get_duplicates()) == n-1 # Expecting n-1 duplicates. if(__name__ == '__main__'): - unittest.main() + unittest.main() diff --git a/pyqso/log_name_dialog.py b/pyqso/log_name_dialog.py index 645fa90..74f5603 100644 --- a/pyqso/log_name_dialog.py +++ b/pyqso/log_name_dialog.py @@ -20,47 +20,47 @@ from gi.repository import Gtk import logging + class LogNameDialog(Gtk.Dialog): - """ A Gtk.Dialog where a user can specify the name of a Log object. """ - - def __init__(self, parent, title=None, name=None): - """ Create and show the log name dialog to the user. - - :arg parent: The parent Gtk window. - :arg title: The title of the dialog. If this is None, it is assumed that a new log is going to be created. - :arg name: The existing name of the Log object. Defaults to None if not specified (because the Log does not yet exist). - """ - if(title is None): - title = "New Log" - else: - title = title - Gtk.Dialog.__init__(self, title=title, parent=parent, flags=Gtk.DialogFlags.DESTROY_WITH_PARENT, buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OK, Gtk.ResponseType.OK)) + """ A Gtk.Dialog where a user can specify the name of a Log object. """ - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label("Log Name:") - label.set_alignment(0, 0.5) - hbox_temp.pack_start(label, False, False, 6) - self.entry = Gtk.Entry() - if(name is not None): - self.entry.set_text(name) - hbox_temp.pack_start(self.entry, False, False, 6) - self.vbox.pack_start(hbox_temp, False, False, 6) + def __init__(self, parent, title=None, name=None): + """ Create and show the log name dialog to the user. - self.show_all() + :arg parent: The parent Gtk window. + :arg title: The title of the dialog. If this is None, it is assumed that a new log is going to be created. + :arg name: The existing name of the Log object. Defaults to None if not specified (because the Log does not yet exist). + """ - logging.debug("New LogNameDialog instance created!") + if(title is None): + title = "New Log" + else: + title = title + Gtk.Dialog.__init__(self, title=title, parent=parent, flags=Gtk.DialogFlags.DESTROY_WITH_PARENT, buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OK, Gtk.ResponseType.OK)) - return + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label("Log Name:") + label.set_alignment(0, 0.5) + hbox_temp.pack_start(label, False, False, 6) + self.entry = Gtk.Entry() + if(name is not None): + self.entry.set_text(name) + hbox_temp.pack_start(self.entry, False, False, 6) + self.vbox.pack_start(hbox_temp, False, False, 6) - def get_log_name(self): - """ Return the log name specified in the Gtk.Entry box by the user. - - :returns: The log's name. - :rtype: str - """ + self.show_all() - logging.debug("Retrieving the log name from the LogNameDialog...") - return self.entry.get_text() + logging.debug("New LogNameDialog instance created!") + return + def get_log_name(self): + """ Return the log name specified in the Gtk.Entry box by the user. + + :returns: The log's name. + :rtype: str + """ + + logging.debug("Retrieving the log name from the LogNameDialog...") + return self.entry.get_text() diff --git a/pyqso/logbook.py b/pyqso/logbook.py index ad79d85..7c4275e 100644 --- a/pyqso/logbook.py +++ b/pyqso/logbook.py @@ -26,1256 +26,1262 @@ import configparser import numpy try: - from matplotlib.backends.backend_gtk3cairo import FigureCanvasGTK3Cairo as FigureCanvas - from matplotlib.figure import Figure - from matplotlib.dates import DateFormatter, MonthLocator, DayLocator - have_matplotlib = True + from matplotlib.backends.backend_gtk3cairo import FigureCanvasGTK3Cairo as FigureCanvas + from matplotlib.figure import Figure + from matplotlib.dates import DateFormatter, MonthLocator, DayLocator + have_matplotlib = True except ImportError as e: - logging.warning(e) - logging.warning("Could not import matplotlib, so you will not be able to plot annual logbook statistics. Check that all the PyQSO dependencies are satisfied.") - have_matplotlib = False - + logging.warning(e) + logging.warning("Could not import matplotlib, so you will not be able to plot annual logbook statistics. Check that all the PyQSO dependencies are satisfied.") + have_matplotlib = False + from pyqso.adif import * from pyqso.log import * from pyqso.log_name_dialog import * from pyqso.auxiliary_dialogs import * + class Logbook(Gtk.Notebook): - """ A Logbook object can store multiple Log objects. """ - - def __init__(self, parent): - """ Create a new Logbook object and initialise the list of Logs. - - :arg parent: The parent Gtk window. - """ - - Gtk.Notebook.__init__(self) - self.parent = parent - self.connection = None - self.summary = {} - self.logs = [] - logging.debug("New Logbook instance created!") - return - - def new(self, widget=None): - """ Create a new logbook, and open it. """ - - # Get the new file's path from a dialog. - dialog = Gtk.FileChooserDialog("Create a New SQLite Database File", - None, - Gtk.FileChooserAction.SAVE, - (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, - Gtk.STOCK_SAVE, Gtk.ResponseType.OK)) - dialog.set_do_overwrite_confirmation(True) + """ A Logbook object can store multiple Log objects. """ - response = dialog.run() - if(response == Gtk.ResponseType.OK): - path = dialog.get_filename() - else: - path = None - dialog.destroy() + def __init__(self, parent): + """ Create a new Logbook object and initialise the list of Logs. - if(path is None): # If the Cancel button has been clicked, path will still be None - logging.debug("No file path specified.") - return - else: - # Clear the contents of the file, in case the file exists already. - open(path, 'w').close() - # Open the new logbook, ready for use. - self.open(path=path) - return - - def open(self, widget=None, path=None): - """ Open a logbook, and render all the logs within it. - - :arg str path: An optional argument containing the database file location, if already known. If this is None, a file selection dialog will appear. - """ + :arg parent: The parent Gtk window. + """ - if(path is None): - # If no path has been provided, get one from a "File Open" dialog. - dialog = Gtk.FileChooserDialog("Open SQLite Database File", - None, - Gtk.FileChooserAction.OPEN, - (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, - Gtk.STOCK_OPEN, Gtk.ResponseType.OK)) - - response = dialog.run() - if(response == Gtk.ResponseType.OK): + Gtk.Notebook.__init__(self) + + self.parent = parent + self.connection = None + self.summary = {} + self.logs = [] + logging.debug("New Logbook instance created!") + return + + def new(self, widget=None): + """ Create a new logbook, and open it. """ + + # Get the new file's path from a dialog. + dialog = Gtk.FileChooserDialog("Create a New SQLite Database File", + None, + Gtk.FileChooserAction.SAVE, + (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_SAVE, Gtk.ResponseType.OK)) + dialog.set_do_overwrite_confirmation(True) + + response = dialog.run() + if(response == Gtk.ResponseType.OK): path = dialog.get_filename() - dialog.destroy() - - if(path is None): # If the Cancel button has been clicked, path will still be None + else: + path = None + dialog.destroy() + + if(path is None): # If the Cancel button has been clicked, path will still be None logging.debug("No file path specified.") return - - connected = self.db_connect(path) - if(connected): - # If the connection setup was successful, then open all the logs in the database - - self.path = path - - logging.debug("Trying to retrieve all the logs in the logbook...") - self.logs = [] # A fresh stack of Log objects - try: - with self.connection: - c = self.connection.cursor() - c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT GLOB 'sqlite_*'") - for name in c: - l = Log(self.connection, name[0]) - l.populate() - self.logs.append(l) - except (sqlite.Error, IndexError) as e: - logging.exception(e) - error(parent = self.parent, message = "Oops! Something went wrong when trying to retrieve the logs from the logbook. Perhaps the logbook file is encrypted, corrupted, or in the wrong format?") - return + else: + # Clear the contents of the file, in case the file exists already. + open(path, 'w').close() + # Open the new logbook, ready for use. + self.open(path=path) + return - logging.debug("All logs retrieved successfully. Now attempting to render them all in the Gtk.Notebook...") - # For rendering the logs. One treeview and one treeselection per Log. - self.treeview = [] - self.treeselection = [] - self.sorter = [] - self.filter = [] - self._create_summary_page() - self._create_dummy_page() + def open(self, widget=None, path=None): + """ Open a logbook, and render all the logs within it. - # FIXME: This is an unfortunate work-around. If the area around the "+/New Log" button - # is clicked, PyQSO will change to an empty page. This signal is used to stop this from happening. - self.connect("switch-page", self._on_switch_page) + :arg str path: An optional argument containing the database file location, if already known. If this is None, a file selection dialog will appear. + """ - for i in range(len(self.logs)): - self._render_log(i) - logging.debug("All logs rendered successfully.") + if(path is None): + # If no path has been provided, get one from a "File Open" dialog. + dialog = Gtk.FileChooserDialog("Open SQLite Database File", + None, + Gtk.FileChooserAction.OPEN, + (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_OPEN, Gtk.ResponseType.OK)) - self.update_summary() - self.parent.toolbox.awards.count() - - context_id = self.parent.statusbar.get_context_id("Status") - self.parent.statusbar.push(context_id, "Logbook: %s" % self.path) - self.parent.toolbar.set_logbook_button_sensitive(False) - self.parent.menu.set_logbook_item_sensitive(False) - self.parent.menu.set_log_items_sensitive(True) - self.parent.toolbar.filter_source.set_sensitive(True) - - self.show_all() - - else: - logging.debug("Not connected to a logbook. No logs were opened.") - - return - - def close(self, widget=None): - """ Close the logbook that is currently open. """ - - disconnected = self.db_disconnect() - if(disconnected): - logging.debug("Closing all logs in the logbook...") - while(self.get_n_pages() > 0): - # Once a page is removed, the other pages get re-numbered, - # so a 'for' loop isn't the best option here. - self.remove_page(0) - logging.debug("All logs now closed.") - - context_id = self.parent.statusbar.get_context_id("Status") - self.parent.statusbar.push(context_id, "No logbook is currently open.") - self.parent.toolbar.set_logbook_button_sensitive(True) - self.parent.menu.set_logbook_item_sensitive(True) - self.parent.menu.set_log_items_sensitive(False) - self.parent.toolbar.filter_source.set_sensitive(False) - else: - logging.debug("Unable to disconnect from the database. No logs were closed.") - return - - def db_connect(self, path): - """ Create an SQL database connection to the Logbook's data source. - - :arg str path: The path of the database file. - """ - - logging.debug("Attempting to connect to the logbook database...") - # Try setting up the SQL database connection - try: - self.db_disconnect() # Destroy any existing connections first. - self.connection = sqlite.connect(path) - self.connection.row_factory = sqlite.Row - except sqlite.Error as e: - # PyQSO can't connect to the database. - logging.exception(e) - error(parent=self.parent, message="PyQSO cannot connect to the database. Check file permissions?") - return False - - logging.debug("Database connection created successfully!") - return True - - def db_disconnect(self): - """ Destroy the connection to the Logbook's data source. - - :returns: True if the connection was successfully destroyed, and False otherwise. - :rtype: bool - """ - - logging.debug("Cleaning up any existing database connections...") - if(self.connection): - try: - self.connection.close() - except sqlite.Error as e: - logging.exception(e) - return False - else: - logging.debug("Already disconnected. Nothing to do here.") - return True - - def _create_dummy_page(self): - """ Create a blank page in the Gtk.Notebook for the "+" (New Log) tab. """ - - blank_treeview = Gtk.TreeView() - # Allow the Log to be scrolled up/down - sw = Gtk.ScrolledWindow() - sw.set_shadow_type(Gtk.ShadowType.ETCHED_IN) - sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - sw.add(blank_treeview) - vbox = Gtk.VBox() - vbox.pack_start(sw, True, True, 0) - - # Add a "+" button to the tab - hbox = Gtk.HBox(False, 0) - icon = Gtk.Image.new_from_stock(Gtk.STOCK_ADD, Gtk.IconSize.MENU) - button = Gtk.Button() - button.set_relief(Gtk.ReliefStyle.NONE) - button.set_focus_on_click(False) - button.connect("clicked", self.new_log) - button.add(icon) - button.set_tooltip_text('New Log') - hbox.pack_start(button, False, False, 0) - hbox.show_all() - vbox.show_all() - - self.insert_page(vbox, hbox, 1) - self.show_all() - self.set_current_page(0) - return - - def _create_summary_page(self): - """ Create a summary page containing the number of logs in the logbook, and the logbook's modification date. """ - - vbox = Gtk.VBox() - - # Database name in large font at the top of the summary page - hbox = Gtk.HBox() - label = Gtk.Label(halign=Gtk.Align.START) - label.set_markup("%s" % basename(self.path)) - hbox.pack_start(label, False, False, 6) - vbox.pack_start(hbox, False, False, 4) - - hbox = Gtk.HBox() - label = Gtk.Label("Number of logs: ", halign=Gtk.Align.START) - hbox.pack_start(label, False, False, 6) - self.summary["LOG_COUNT"] = Gtk.Label("0") - hbox.pack_start(self.summary["LOG_COUNT"], False, False, 4) - vbox.pack_start(hbox, False, False, 4) - - hbox = Gtk.HBox() - label = Gtk.Label("Total number of QSOs: ", halign=Gtk.Align.START) - hbox.pack_start(label, False, False, 6) - self.summary["QSO_COUNT"] = Gtk.Label("0") - hbox.pack_start(self.summary["QSO_COUNT"], False, False, 4) - vbox.pack_start(hbox, False, False, 4) - - hbox = Gtk.HBox() - label = Gtk.Label("Date modified: ", halign=Gtk.Align.START) - hbox.pack_start(label, False, False, 6) - self.summary["DATE_MODIFIED"] = Gtk.Label("0") - hbox.pack_start(self.summary["DATE_MODIFIED"], False, False, 4) - vbox.pack_start(hbox, False, False, 4) - - hseparator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) - vbox.pack_start(hseparator, False, False, 4) - - # Yearly statistics - config = configparser.ConfigParser() - have_config = (config.read(expanduser('~/.config/pyqso/preferences.ini')) != []) - (section, option) = ("general", "show_yearly_statistics") - if(have_config and config.has_option(section, option)): - if(config.get("general", "show_yearly_statistics") == "True" and have_matplotlib): - hbox = Gtk.HBox() - label = Gtk.Label("Display statistics for year: ", halign=Gtk.Align.START) - hbox.pack_start(label, False, False, 6) - self.summary["YEAR_SELECT"] = Gtk.ComboBoxText() - min_year, max_year = self._find_year_bounds() - if min_year and max_year: - for year in range(max_year, min_year-1, -1): - self.summary["YEAR_SELECT"].append_text(str(year)) - self.summary["YEAR_SELECT"].append_text("") - self.summary["YEAR_SELECT"].connect("changed", self._on_year_changed) - hbox.pack_start(self.summary["YEAR_SELECT"], False, False, 6) - vbox.pack_start(hbox, False, False, 4) - - self.summary["YEARLY_STATISTICS"] = Figure() - canvas = FigureCanvas(self.summary["YEARLY_STATISTICS"]) - canvas.set_size_request(400,400) - canvas.show() - vbox.pack_start(canvas, True, True, 4) - - # Summary tab label and icon. - hbox = Gtk.HBox(False, 0) - label = Gtk.Label("Summary ") - icon = Gtk.Image.new_from_stock(Gtk.STOCK_INDEX, Gtk.IconSize.MENU) - hbox.pack_start(label, False, False, 0) - hbox.pack_start(icon, False, False, 0) - hbox.show_all() - - self.insert_page(vbox, hbox, 0) # Append as a new tab - self.show_all() - - return - - def _on_year_changed(self, combo): - """ Re-plot the statistics for the year selected by the user. """ - - # Clear figure - self.summary["YEARLY_STATISTICS"].clf() - self.summary["YEARLY_STATISTICS"].canvas.draw() - - # Get year to show statistics for. - year = combo.get_active_text() - try: - year = int(year) - except ValueError: - # Empty year string. - return - - # Number of contacts made each month - contact_count_plot = self.summary["YEARLY_STATISTICS"].add_subplot(121) - contact_count = self._get_annual_contact_count(year) - - # x-axis formatting based on the date - contact_count_plot.bar(contact_count.keys(), list(contact_count.values()), color="k", width=15, align="center") - formatter = DateFormatter("%b") - contact_count_plot.xaxis.set_major_formatter(formatter) - month_locator = MonthLocator() - contact_count_plot.xaxis.set_major_locator(month_locator) - contact_count_plot.set_ylabel("Number of QSOs") - - # Set x-axis upper limit based on the current month. - month = datetime.now().month - contact_count_plot.xaxis_date() - contact_count_plot.set_xlim([date(year-1, 12, 16), date(year, 12, 15)]) # Make a bit of space either side of January and December of the selected year. - - # Pie chart of all the modes used. - mode_count_plot = self.summary["YEARLY_STATISTICS"].add_subplot(122) - mode_count = self._get_annual_mode_count(year) - (patches, texts, autotexts) = mode_count_plot.pie(list(mode_count.values()), labels=mode_count.keys(), autopct='%1.1f%%', shadow=False) - for p in patches: - # Make the patches partially transparent. - p.set_alpha(0.75) - mode_count_plot.set_title("Modes used") - - self.summary["YEARLY_STATISTICS"].canvas.draw() - - return - - def _find_year_bounds(self): - """ Find the years of the oldest and newest QSOs across all logs in the logbook. """ - - c = self.connection.cursor() - max_years = [] - min_years = [] - for log in self.logs: - query = "SELECT min(QSO_DATE), max(QSO_DATE) FROM %s" % (log.name) - c.execute(query) - years = c.fetchone() - if years[0] and years[1]: - min_years.append(int(years[0][:4])) - max_years.append(int(years[1][:4])) - - if len(min_years) == 0 or max_years == 0: - return None, None - else: - # Return the min and max across all logs. - return min(min_years), max(max_years) - - def _get_annual_contact_count(self, year): - """ Find the total number of contacts made in each month in the specified year. """ - - contact_count = {} - c = self.connection.cursor() - - for log in self.logs: - query = "SELECT QSO_DATE, count(QSO_DATE) FROM %s WHERE QSO_DATE >= %d0101 AND QSO_DATE < %d0101 GROUP by QSO_DATE" % (log.name, year, year+1) - c.execute(query) - xy = c.fetchall() - - for i in range(len(xy)): - date_str = xy[i][0] - y = int(date_str[0:4]) - m = int(date_str[4:6]) - date = datetime(y, m, 1) # Collect all contacts together by month. - if date in contact_count.keys(): - contact_count[date] += xy[i][1] - else: - contact_count[date] = xy[i][1] - - return contact_count - - def _get_annual_mode_count(self, year): - """ Find the total number of contacts made with each mode in a specified year. """ - - mode_count = {} - - for log in self.logs: - query = "SELECT MODE, count(MODE) FROM %s WHERE QSO_DATE >= %d0101 GROUP by MODE" % (log.name, year) - c = self.connection.cursor() - c.execute(query) - xy = c.fetchall() - - for i in range(len(xy)): - mode = xy[i][0] - if mode == "": - mode = "Unspecified" - - # Add to running total - if mode in mode_count.keys(): - mode_count[mode] += xy[i][1] - else: - mode_count[mode] = xy[i][1] - - return mode_count - - def update_summary(self): - """ Update the information presented on the summary page. """ - - self.summary["LOG_COUNT"].set_label(str(self.get_number_of_logs())) - self.summary["QSO_COUNT"].set_label(str(self.get_number_of_qsos())) - try: - t = datetime.fromtimestamp(getmtime(self.path)).strftime("%d %B %Y @ %H:%M") - self.summary["DATE_MODIFIED"].set_label(str(t)) - except (IOError, OSError) as e: - logging.exception(e) - return - - def _on_switch_page(self, widget, label, new_page): - """ Handle a tab/page change, and enable/disable the relevant Record-related buttons. """ - - if(new_page == self.get_n_pages()-1): # The last (right-most) tab is the "New Log" tab. - self.stop_emission("switch-page") - - # Disable the record buttons if a log page is not selected. - if(new_page == 0): - self.parent.toolbar.set_record_buttons_sensitive(False) - self.parent.menu.set_record_items_sensitive(False) - else: - self.parent.toolbar.set_record_buttons_sensitive(True) - self.parent.menu.set_record_items_sensitive(True) - return - - def new_log(self, widget=None): - """ Create a new log in the logbook. """ - - if(self.connection is None): - return - exists = True - dialog = LogNameDialog(self.parent) - while(exists): - response = dialog.run() - if(response == Gtk.ResponseType.OK): - log_name = dialog.get_log_name() - try: - with self.connection: - c = self.connection.cursor() - query = "CREATE TABLE %s (id INTEGER PRIMARY KEY AUTOINCREMENT" % log_name - for field_name in AVAILABLE_FIELD_NAMES_ORDERED: - s = ", %s TEXT" % field_name.lower() - query = query + s - query = query + ")" - c.execute(query) - exists = False - except sqlite.Error as e: - logging.exception(e) - # Data is not valid - inform the user. - error(parent=self.parent, message="Database error. Try another log name.") - exists = True - else: + response = dialog.run() + if(response == Gtk.ResponseType.OK): + path = dialog.get_filename() dialog.destroy() - return - dialog.destroy() + if(path is None): # If the Cancel button has been clicked, path will still be None + logging.debug("No file path specified.") + return - l = Log(self.connection, log_name) # Empty log - l.populate() + connected = self.db_connect(path) + if(connected): + # If the connection setup was successful, then open all the logs in the database - self.logs.append(l) - self._render_log(self.get_number_of_logs()-1) - self.update_summary() + self.path = path - self.set_current_page(self.get_number_of_logs()) - return + logging.debug("Trying to retrieve all the logs in the logbook...") + self.logs = [] # A fresh stack of Log objects + try: + with self.connection: + c = self.connection.cursor() + c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT GLOB 'sqlite_*'") + for name in c: + l = Log(self.connection, name[0]) + l.populate() + self.logs.append(l) + except (sqlite.Error, IndexError) as e: + logging.exception(e) + error(parent=self.parent, message="Oops! Something went wrong when trying to retrieve the logs from the logbook. Perhaps the logbook file is encrypted, corrupted, or in the wrong format?") + return - def delete_log(self, widget, page=None): - """ Delete the log that is currently selected in the logbook. - - :arg Gtk.Widget page: An optional argument corresponding to the currently-selected page/tab. - """ - if(self.connection is None): - return - - if(page is None): - page_index = self.get_current_page() # Gets the index of the selected tab in the logbook - if(page_index == 0): # If we are on the Summary page... - logging.debug("No log currently selected!") - return - else: - page = self.get_nth_page(page_index) # Gets the Gtk.VBox of the selected tab in the logbook + logging.debug("All logs retrieved successfully. Now attempting to render them all in the Gtk.Notebook...") + # For rendering the logs. One treeview and one treeselection per Log. + self.treeview = [] + self.treeselection = [] + self.sorter = [] + self.filter = [] + self._create_summary_page() + self._create_dummy_page() - log_index = self._get_log_index(name=page.get_name()) - log = self.logs[log_index] - - # We also need the page's index in order to remove it using remove_page below. - # This may not be the same as what self.get_current_page() returns. - page_index = self.page_num(page) - - if(page_index == 0 or page_index == self.get_n_pages()-1): # Only the "New Log" tab is present (i.e. no actual logs in the logbook) - logging.debug("No logs to delete!") - return + # FIXME: This is an unfortunate work-around. If the area around the "+/New Log" button + # is clicked, PyQSO will change to an empty page. This signal is used to stop this from happening. + self.connect("switch-page", self._on_switch_page) - response = question(parent=self.parent, message="Are you sure you want to delete log %s?" % log.name) - if(response == Gtk.ResponseType.YES): - try: - with self.connection: - c = self.connection.cursor() - c.execute("DROP TABLE %s" % log.name) - except sqlite.Error as e: + for i in range(len(self.logs)): + self._render_log(i) + logging.debug("All logs rendered successfully.") + + self.update_summary() + self.parent.toolbox.awards.count() + + context_id = self.parent.statusbar.get_context_id("Status") + self.parent.statusbar.push(context_id, "Logbook: %s" % self.path) + self.parent.toolbar.set_logbook_button_sensitive(False) + self.parent.menu.set_logbook_item_sensitive(False) + self.parent.menu.set_log_items_sensitive(True) + self.parent.toolbar.filter_source.set_sensitive(True) + + self.show_all() + + else: + logging.debug("Not connected to a logbook. No logs were opened.") + + return + + def close(self, widget=None): + """ Close the logbook that is currently open. """ + + disconnected = self.db_disconnect() + if(disconnected): + logging.debug("Closing all logs in the logbook...") + while(self.get_n_pages() > 0): + # Once a page is removed, the other pages get re-numbered, + # so a 'for' loop isn't the best option here. + self.remove_page(0) + logging.debug("All logs now closed.") + + context_id = self.parent.statusbar.get_context_id("Status") + self.parent.statusbar.push(context_id, "No logbook is currently open.") + self.parent.toolbar.set_logbook_button_sensitive(True) + self.parent.menu.set_logbook_item_sensitive(True) + self.parent.menu.set_log_items_sensitive(False) + self.parent.toolbar.filter_source.set_sensitive(False) + else: + logging.debug("Unable to disconnect from the database. No logs were closed.") + return + + def db_connect(self, path): + """ Create an SQL database connection to the Logbook's data source. + + :arg str path: The path of the database file. + """ + + logging.debug("Attempting to connect to the logbook database...") + # Try setting up the SQL database connection + try: + self.db_disconnect() # Destroy any existing connections first. + self.connection = sqlite.connect(path) + self.connection.row_factory = sqlite.Row + except sqlite.Error as e: + # PyQSO can't connect to the database. logging.exception(e) - error(parent=self.parent, message="Database error. Could not delete the log.") + error(parent=self.parent, message="PyQSO cannot connect to the database. Check file permissions?") + return False + + logging.debug("Database connection created successfully!") + return True + + def db_disconnect(self): + """ Destroy the connection to the Logbook's data source. + + :returns: True if the connection was successfully destroyed, and False otherwise. + :rtype: bool + """ + + logging.debug("Cleaning up any existing database connections...") + if(self.connection): + try: + self.connection.close() + except sqlite.Error as e: + logging.exception(e) + return False + else: + logging.debug("Already disconnected. Nothing to do here.") + return True + + def _create_dummy_page(self): + """ Create a blank page in the Gtk.Notebook for the "+" (New Log) tab. """ + + blank_treeview = Gtk.TreeView() + # Allow the Log to be scrolled up/down + sw = Gtk.ScrolledWindow() + sw.set_shadow_type(Gtk.ShadowType.ETCHED_IN) + sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + sw.add(blank_treeview) + vbox = Gtk.VBox() + vbox.pack_start(sw, True, True, 0) + + # Add a "+" button to the tab + hbox = Gtk.HBox(False, 0) + icon = Gtk.Image.new_from_stock(Gtk.STOCK_ADD, Gtk.IconSize.MENU) + button = Gtk.Button() + button.set_relief(Gtk.ReliefStyle.NONE) + button.set_focus_on_click(False) + button.connect("clicked", self.new_log) + button.add(icon) + button.set_tooltip_text('New Log') + hbox.pack_start(button, False, False, 0) + hbox.show_all() + vbox.show_all() + + self.insert_page(vbox, hbox, 1) + self.show_all() + self.set_current_page(0) + return + + def _create_summary_page(self): + """ Create a summary page containing the number of logs in the logbook, and the logbook's modification date. """ + + vbox = Gtk.VBox() + + # Database name in large font at the top of the summary page + hbox = Gtk.HBox() + label = Gtk.Label(halign=Gtk.Align.START) + label.set_markup("%s" % basename(self.path)) + hbox.pack_start(label, False, False, 6) + vbox.pack_start(hbox, False, False, 4) + + hbox = Gtk.HBox() + label = Gtk.Label("Number of logs: ", halign=Gtk.Align.START) + hbox.pack_start(label, False, False, 6) + self.summary["LOG_COUNT"] = Gtk.Label("0") + hbox.pack_start(self.summary["LOG_COUNT"], False, False, 4) + vbox.pack_start(hbox, False, False, 4) + + hbox = Gtk.HBox() + label = Gtk.Label("Total number of QSOs: ", halign=Gtk.Align.START) + hbox.pack_start(label, False, False, 6) + self.summary["QSO_COUNT"] = Gtk.Label("0") + hbox.pack_start(self.summary["QSO_COUNT"], False, False, 4) + vbox.pack_start(hbox, False, False, 4) + + hbox = Gtk.HBox() + label = Gtk.Label("Date modified: ", halign=Gtk.Align.START) + hbox.pack_start(label, False, False, 6) + self.summary["DATE_MODIFIED"] = Gtk.Label("0") + hbox.pack_start(self.summary["DATE_MODIFIED"], False, False, 4) + vbox.pack_start(hbox, False, False, 4) + + hseparator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) + vbox.pack_start(hseparator, False, False, 4) + + # Yearly statistics + config = configparser.ConfigParser() + have_config = (config.read(expanduser('~/.config/pyqso/preferences.ini')) != []) + (section, option) = ("general", "show_yearly_statistics") + if(have_config and config.has_option(section, option)): + if(config.get("general", "show_yearly_statistics") == "True" and have_matplotlib): + hbox = Gtk.HBox() + label = Gtk.Label("Display statistics for year: ", halign=Gtk.Align.START) + hbox.pack_start(label, False, False, 6) + self.summary["YEAR_SELECT"] = Gtk.ComboBoxText() + min_year, max_year = self._find_year_bounds() + if min_year and max_year: + for year in range(max_year, min_year-1, -1): + self.summary["YEAR_SELECT"].append_text(str(year)) + self.summary["YEAR_SELECT"].append_text("") + self.summary["YEAR_SELECT"].connect("changed", self._on_year_changed) + hbox.pack_start(self.summary["YEAR_SELECT"], False, False, 6) + vbox.pack_start(hbox, False, False, 4) + + self.summary["YEARLY_STATISTICS"] = Figure() + canvas = FigureCanvas(self.summary["YEARLY_STATISTICS"]) + canvas.set_size_request(400, 400) + canvas.show() + vbox.pack_start(canvas, True, True, 4) + + # Summary tab label and icon. + hbox = Gtk.HBox(False, 0) + label = Gtk.Label("Summary ") + icon = Gtk.Image.new_from_stock(Gtk.STOCK_INDEX, Gtk.IconSize.MENU) + hbox.pack_start(label, False, False, 0) + hbox.pack_start(icon, False, False, 0) + hbox.show_all() + + self.insert_page(vbox, hbox, 0) # Append as a new tab + self.show_all() + + return + + def _on_year_changed(self, combo): + """ Re-plot the statistics for the year selected by the user. """ + + # Clear figure + self.summary["YEARLY_STATISTICS"].clf() + self.summary["YEARLY_STATISTICS"].canvas.draw() + + # Get year to show statistics for. + year = combo.get_active_text() + try: + year = int(year) + except ValueError: + # Empty year string. return - self.logs.pop(log_index) - # Remove the log from the renderers too - self.treeview.pop(log_index) - self.treeselection.pop(log_index) - self.sorter.pop(log_index) - self.filter.pop(log_index) - # And finally remove the tab in the Logbook - self.remove_page(page_index) + # Number of contacts made each month + contact_count_plot = self.summary["YEARLY_STATISTICS"].add_subplot(121) + contact_count = self._get_annual_contact_count(year) - self.update_summary() - self.parent.toolbox.awards.count() - return + # x-axis formatting based on the date + contact_count_plot.bar(contact_count.keys(), list(contact_count.values()), color="k", width=15, align="center") + formatter = DateFormatter("%b") + contact_count_plot.xaxis.set_major_formatter(formatter) + month_locator = MonthLocator() + contact_count_plot.xaxis.set_major_locator(month_locator) + contact_count_plot.set_ylabel("Number of QSOs") - def filter_logs(self, widget=None): - """ Re-filter all the logs when the user-defined expression is changed. """ - for i in range(0, len(self.filter)): - self.filter[i].refilter() - return + # Set x-axis upper limit based on the current month. + month = datetime.now().month + contact_count_plot.xaxis_date() + contact_count_plot.set_xlim([date(year-1, 12, 16), date(year, 12, 15)]) # Make a bit of space either side of January and December of the selected year. - def _filter_by_callsign(self, model, iter, data): - """ Filter all the logs in the logbook by the callsign field, based on a user-defined expression. - - :arg Gtk.TreeModel model: The model used to filter the log data. - :arg Gtk.TreeIter iter: A pointer to a particular row in the model. - :arg data: The user-defined expression to filter by. - :returns: True if a record matches the expression, or if there is nothing to filter. Otherwise, returns False. - :rtype: bool - """ - value = model.get_value(iter, 1) - callsign = self.parent.toolbar.filter_source.get_text() - - if(callsign is None or callsign == ""): - # If there is nothing to filter with, then show all the records! - return True - else: - # This should be case insensitive. - # Also, we could use value[:][0:len(callsign))] if we wanted to match from the very start of each callsign. - return callsign.upper() in value or callsign.lower() in value + # Pie chart of all the modes used. + mode_count_plot = self.summary["YEARLY_STATISTICS"].add_subplot(122) + mode_count = self._get_annual_mode_count(year) + (patches, texts, autotexts) = mode_count_plot.pie(list(mode_count.values()), labels=mode_count.keys(), autopct='%1.1f%%', shadow=False) + for p in patches: + # Make the patches partially transparent. + p.set_alpha(0.75) + mode_count_plot.set_title("Modes used") - def _render_log(self, index): - """ Render a Log in the Gtk.Notebook. - - :arg int index: The index of the Log (in the list of Logs) to render. - """ - self.filter.append(self.logs[index].filter_new(root=None)) - # Set the callsign column as the column we want to filter by - self.filter[index].set_visible_func(self._filter_by_callsign, data=None) - self.sorter.append(Gtk.TreeModelSort(model=self.filter[index])) - self.sorter[index].set_sort_column_id(0, Gtk.SortType.ASCENDING) + self.summary["YEARLY_STATISTICS"].canvas.draw() - self.treeview.append(Gtk.TreeView(self.sorter[index])) - self.treeview[index].set_grid_lines(Gtk.TreeViewGridLines.BOTH) - self.treeview[index].connect("row-activated", self.edit_record_callback) - self.treeselection.append(self.treeview[index].get_selection()) - self.treeselection[index].set_mode(Gtk.SelectionMode.SINGLE) - # Allow the Log to be scrolled up/down - sw = Gtk.ScrolledWindow() - sw.set_shadow_type(Gtk.ShadowType.ETCHED_IN) - sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - sw.add(self.treeview[index]) - vbox = Gtk.VBox() - vbox.set_name(self.logs[index].name) # Set a name for the tab itself so we can match it up with the associated Log object later. - vbox.pack_start(sw, True, True, 0) + return - # Add a close button to the tab - hbox = Gtk.HBox(False, 0) - label = Gtk.Label(self.logs[index].name) - hbox.pack_start(label, False, False, 0) - hbox.show_all() + def _find_year_bounds(self): + """ Find the years of the oldest and newest QSOs across all logs in the logbook. """ - self.insert_page(vbox, hbox, index+1) # Append the new log as a new tab + c = self.connection.cursor() + max_years = [] + min_years = [] + for log in self.logs: + query = "SELECT min(QSO_DATE), max(QSO_DATE) FROM %s" % (log.name) + c.execute(query) + years = c.fetchone() + if years[0] and years[1]: + min_years.append(int(years[0][:4])) + max_years.append(int(years[1][:4])) - # The first column of the logbook will always be the unique record index. - # Let's append this separately to the field names. - renderer = Gtk.CellRendererText() - column = Gtk.TreeViewColumn("Index", renderer, text=0) - column.set_resizable(True) - column.set_min_width(50) - column.set_clickable(True) - column.set_sort_order(Gtk.SortType.ASCENDING) - column.set_sort_indicator(True) - column.connect("clicked", self.sort_log, 0) - self.treeview[index].append_column(column) - - # Set up column names for each selected field - field_names = AVAILABLE_FIELD_NAMES_ORDERED - for i in range(0, len(field_names)): - renderer = Gtk.CellRendererText() - column = Gtk.TreeViewColumn(AVAILABLE_FIELD_NAMES_FRIENDLY[field_names[i]], renderer, text=i+1) - column.set_resizable(True) - column.set_min_width(50) - column.set_clickable(True) + if len(min_years) == 0 or max_years == 0: + return None, None + else: + # Return the min and max across all logs. + return min(min_years), max(max_years) - # Special cases - if(field_names[i] == "NOTES"): - # Give the 'Notes' column some extra space, since this is likely to contain some long sentences... - column.set_min_width(300) - # ... but don't let it automatically re-size itself. - column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) + def _get_annual_contact_count(self, year): + """ Find the total number of contacts made in each month in the specified year. """ - column.connect("clicked", self.sort_log, i+1) + contact_count = {} + c = self.connection.cursor() - config = configparser.ConfigParser() - have_config = (config.read(expanduser('~/.config/pyqso/preferences.ini')) != []) - (section, option) = ("view", AVAILABLE_FIELD_NAMES_ORDERED[i].lower()) - if(have_config and config.has_option(section, option)): - column.set_visible(config.get(section, option) == "True") - self.treeview[index].append_column(column) + for log in self.logs: + query = "SELECT QSO_DATE, count(QSO_DATE) FROM %s WHERE QSO_DATE >= %d0101 AND QSO_DATE < %d0101 GROUP by QSO_DATE" % (log.name, year, year+1) + c.execute(query) + xy = c.fetchall() - self.show_all() - return + for i in range(len(xy)): + date_str = xy[i][0] + y = int(date_str[0:4]) + m = int(date_str[4:6]) + date = datetime(y, m, 1) # Collect all contacts together by month. + if date in contact_count.keys(): + contact_count[date] += xy[i][1] + else: + contact_count[date] = xy[i][1] - def _compare_date_and_time(self, model, row1, row2, user_data): - """ Compare two rows (let's call them A and B) in a Gtk.ListStore, and sort by both date and time. - - :arg Gtk.TreeModel model: The model used to sort the log data. - :arg Gtk.TreeIter row1: The pointer to row A. - :arg Gtk.TreeIter row2: The pointer to row B. - :arg user_data: The specific column from which to retrieve data for rows A and B. - :returns: 1 if Row B's date/time is more recent than Row A's; 0 if both dates and times are the same; -1 if Row A's date/time is more recent than Row B's. - :rtype: int - """ - date1 = model.get_value(row1, user_data[0]) - date2 = model.get_value(row2, user_data[0]) - time1 = model.get_value(row1, user_data[1]) - time2 = model.get_value(row2, user_data[1]) - if(date1 < date2): - return 1 - elif(date1 == date2): - # If the dates are the same, then let's also sort by time. - if(time1 > time2): - return -1 - elif(time1 == time2): - return 0 - else: + return contact_count + + def _get_annual_mode_count(self, year): + """ Find the total number of contacts made with each mode in a specified year. """ + + mode_count = {} + + for log in self.logs: + query = "SELECT MODE, count(MODE) FROM %s WHERE QSO_DATE >= %d0101 GROUP by MODE" % (log.name, year) + c = self.connection.cursor() + c.execute(query) + xy = c.fetchall() + + for i in range(len(xy)): + mode = xy[i][0] + if mode == "": + mode = "Unspecified" + + # Add to running total + if mode in mode_count.keys(): + mode_count[mode] += xy[i][1] + else: + mode_count[mode] = xy[i][1] + + return mode_count + + def update_summary(self): + """ Update the information presented on the summary page. """ + + self.summary["LOG_COUNT"].set_label(str(self.get_number_of_logs())) + self.summary["QSO_COUNT"].set_label(str(self.get_number_of_qsos())) + try: + t = datetime.fromtimestamp(getmtime(self.path)).strftime("%d %B %Y @ %H:%M") + self.summary["DATE_MODIFIED"].set_label(str(t)) + except (IOError, OSError) as e: + logging.exception(e) + return + + def _on_switch_page(self, widget, label, new_page): + """ Handle a tab/page change, and enable/disable the relevant Record-related buttons. """ + + if(new_page == self.get_n_pages()-1): # The last (right-most) tab is the "New Log" tab. + self.stop_emission("switch-page") + + # Disable the record buttons if a log page is not selected. + if(new_page == 0): + self.parent.toolbar.set_record_buttons_sensitive(False) + self.parent.menu.set_record_items_sensitive(False) + else: + self.parent.toolbar.set_record_buttons_sensitive(True) + self.parent.menu.set_record_items_sensitive(True) + return + + def new_log(self, widget=None): + """ Create a new log in the logbook. """ + + if(self.connection is None): + return + exists = True + dialog = LogNameDialog(self.parent) + while(exists): + response = dialog.run() + if(response == Gtk.ResponseType.OK): + log_name = dialog.get_log_name() + try: + with self.connection: + c = self.connection.cursor() + query = "CREATE TABLE %s (id INTEGER PRIMARY KEY AUTOINCREMENT" % log_name + for field_name in AVAILABLE_FIELD_NAMES_ORDERED: + s = ", %s TEXT" % field_name.lower() + query = query + s + query = query + ")" + c.execute(query) + exists = False + except sqlite.Error as e: + logging.exception(e) + # Data is not valid - inform the user. + error(parent=self.parent, message="Database error. Try another log name.") + exists = True + else: + dialog.destroy() + return + + dialog.destroy() + + l = Log(self.connection, log_name) # Empty log + l.populate() + + self.logs.append(l) + self._render_log(self.get_number_of_logs()-1) + self.update_summary() + + self.set_current_page(self.get_number_of_logs()) + return + + def delete_log(self, widget, page=None): + """ Delete the log that is currently selected in the logbook. + + :arg Gtk.Widget page: An optional argument corresponding to the currently-selected page/tab. + """ + if(self.connection is None): + return + + if(page is None): + page_index = self.get_current_page() # Gets the index of the selected tab in the logbook + if(page_index == 0): # If we are on the Summary page... + logging.debug("No log currently selected!") + return + else: + page = self.get_nth_page(page_index) # Gets the Gtk.VBox of the selected tab in the logbook + + log_index = self._get_log_index(name=page.get_name()) + log = self.logs[log_index] + + # We also need the page's index in order to remove it using remove_page below. + # This may not be the same as what self.get_current_page() returns. + page_index = self.page_num(page) + + if(page_index == 0 or page_index == self.get_n_pages()-1): # Only the "New Log" tab is present (i.e. no actual logs in the logbook) + logging.debug("No logs to delete!") + return + + response = question(parent=self.parent, message="Are you sure you want to delete log %s?" % log.name) + if(response == Gtk.ResponseType.YES): + try: + with self.connection: + c = self.connection.cursor() + c.execute("DROP TABLE %s" % log.name) + except sqlite.Error as e: + logging.exception(e) + error(parent=self.parent, message="Database error. Could not delete the log.") + return + + self.logs.pop(log_index) + # Remove the log from the renderers too + self.treeview.pop(log_index) + self.treeselection.pop(log_index) + self.sorter.pop(log_index) + self.filter.pop(log_index) + # And finally remove the tab in the Logbook + self.remove_page(page_index) + + self.update_summary() + self.parent.toolbox.awards.count() + return + + def filter_logs(self, widget=None): + """ Re-filter all the logs when the user-defined expression is changed. """ + for i in range(0, len(self.filter)): + self.filter[i].refilter() + return + + def _filter_by_callsign(self, model, iter, data): + """ Filter all the logs in the logbook by the callsign field, based on a user-defined expression. + + :arg Gtk.TreeModel model: The model used to filter the log data. + :arg Gtk.TreeIter iter: A pointer to a particular row in the model. + :arg data: The user-defined expression to filter by. + :returns: True if a record matches the expression, or if there is nothing to filter. Otherwise, returns False. + :rtype: bool + """ + value = model.get_value(iter, 1) + callsign = self.parent.toolbar.filter_source.get_text() + + if(callsign is None or callsign == ""): + # If there is nothing to filter with, then show all the records! + return True + else: + # This should be case insensitive. + # Also, we could use value[:][0:len(callsign))] if we wanted to match from the very start of each callsign. + return callsign.upper() in value or callsign.lower() in value + + def _render_log(self, index): + """ Render a Log in the Gtk.Notebook. + + :arg int index: The index of the Log (in the list of Logs) to render. + """ + self.filter.append(self.logs[index].filter_new(root=None)) + # Set the callsign column as the column we want to filter by + self.filter[index].set_visible_func(self._filter_by_callsign, data=None) + self.sorter.append(Gtk.TreeModelSort(model=self.filter[index])) + self.sorter[index].set_sort_column_id(0, Gtk.SortType.ASCENDING) + + self.treeview.append(Gtk.TreeView(self.sorter[index])) + self.treeview[index].set_grid_lines(Gtk.TreeViewGridLines.BOTH) + self.treeview[index].connect("row-activated", self.edit_record_callback) + self.treeselection.append(self.treeview[index].get_selection()) + self.treeselection[index].set_mode(Gtk.SelectionMode.SINGLE) + # Allow the Log to be scrolled up/down + sw = Gtk.ScrolledWindow() + sw.set_shadow_type(Gtk.ShadowType.ETCHED_IN) + sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + sw.add(self.treeview[index]) + vbox = Gtk.VBox() + vbox.set_name(self.logs[index].name) # Set a name for the tab itself so we can match it up with the associated Log object later. + vbox.pack_start(sw, True, True, 0) + + # Add a close button to the tab + hbox = Gtk.HBox(False, 0) + label = Gtk.Label(self.logs[index].name) + hbox.pack_start(label, False, False, 0) + hbox.show_all() + + self.insert_page(vbox, hbox, index+1) # Append the new log as a new tab + + # The first column of the logbook will always be the unique record index. + # Let's append this separately to the field names. + renderer = Gtk.CellRendererText() + column = Gtk.TreeViewColumn("Index", renderer, text=0) + column.set_resizable(True) + column.set_min_width(50) + column.set_clickable(True) + column.set_sort_order(Gtk.SortType.ASCENDING) + column.set_sort_indicator(True) + column.connect("clicked", self.sort_log, 0) + self.treeview[index].append_column(column) + + # Set up column names for each selected field + field_names = AVAILABLE_FIELD_NAMES_ORDERED + for i in range(0, len(field_names)): + renderer = Gtk.CellRendererText() + column = Gtk.TreeViewColumn(AVAILABLE_FIELD_NAMES_FRIENDLY[field_names[i]], renderer, text=i+1) + column.set_resizable(True) + column.set_min_width(50) + column.set_clickable(True) + + # Special cases + if(field_names[i] == "NOTES"): + # Give the 'Notes' column some extra space, since this is likely to contain some long sentences... + column.set_min_width(300) + # ... but don't let it automatically re-size itself. + column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) + + column.connect("clicked", self.sort_log, i+1) + + config = configparser.ConfigParser() + have_config = (config.read(expanduser('~/.config/pyqso/preferences.ini')) != []) + (section, option) = ("view", AVAILABLE_FIELD_NAMES_ORDERED[i].lower()) + if(have_config and config.has_option(section, option)): + column.set_visible(config.get(section, option) == "True") + self.treeview[index].append_column(column) + + self.show_all() + return + + def _compare_date_and_time(self, model, row1, row2, user_data): + """ Compare two rows (let's call them A and B) in a Gtk.ListStore, and sort by both date and time. + + :arg Gtk.TreeModel model: The model used to sort the log data. + :arg Gtk.TreeIter row1: The pointer to row A. + :arg Gtk.TreeIter row2: The pointer to row B. + :arg user_data: The specific column from which to retrieve data for rows A and B. + :returns: 1 if Row B's date/time is more recent than Row A's; 0 if both dates and times are the same; -1 if Row A's date/time is more recent than Row B's. + :rtype: int + """ + date1 = model.get_value(row1, user_data[0]) + date2 = model.get_value(row2, user_data[0]) + time1 = model.get_value(row1, user_data[1]) + time2 = model.get_value(row2, user_data[1]) + if(date1 < date2): return 1 - else: - return -1 + elif(date1 == date2): + # If the dates are the same, then let's also sort by time. + if(time1 > time2): + return -1 + elif(time1 == time2): + return 0 + else: + return 1 + else: + return -1 - def _compare_default(self, model, row1, row2, user_data): - """ The default sorting function for all Gtk.ListStore objects. - - :arg Gtk.TreeModel model: The model used to sort the log data. - :arg Gtk.TreeIter row1: The pointer to row A. - :arg Gtk.TreeIter row2: The pointer to row B. - :arg user_data: The specific column from which to retrieve data for rows A and B. - :returns: 1 if the value of Row A's column value is less than Row B's column value; 0 if both values are the same; -1 if Row A's column value is greater than Row B's column value. - :rtype: int - """ - value1 = model.get_value(row1, user_data) - value2 = model.get_value(row2, user_data) - if(value1 < value2): - return 1 - elif(value1 == value2): - return 0 - else: - return -1 + def _compare_default(self, model, row1, row2, user_data): + """ The default sorting function for all Gtk.ListStore objects. - def sort_log(self, widget, column_index): - """ Sort the log (that is currently selected) with respect to a given field. - - :arg int column_index: The index of the column to sort by. - """ + :arg Gtk.TreeModel model: The model used to sort the log data. + :arg Gtk.TreeIter row1: The pointer to row A. + :arg Gtk.TreeIter row2: The pointer to row B. + :arg user_data: The specific column from which to retrieve data for rows A and B. + :returns: 1 if the value of Row A's column value is less than Row B's column value; 0 if both values are the same; -1 if Row A's column value is greater than Row B's column value. + :rtype: int + """ + value1 = model.get_value(row1, user_data) + value2 = model.get_value(row2, user_data) + if(value1 < value2): + return 1 + elif(value1 == value2): + return 0 + else: + return -1 - log_index = self._get_log_index() - column = self.treeview[log_index].get_column(column_index) + def sort_log(self, widget, column_index): + """ Sort the log (that is currently selected) with respect to a given field. - if(AVAILABLE_FIELD_NAMES_ORDERED[column_index-1] == "QSO_DATE"): - # If the field being sorted is the QSO_DATE, then also sort by the TIME_ON field so we get the - # correct chronological order. - # Note: This assumes that the TIME_ON field is always immediately to the right of the QSO_DATE field. - self.sorter[log_index].set_sort_func(column_index, self._compare_date_and_time, user_data=[column_index, column_index+1]) - else: - self.sorter[log_index].set_sort_func(column_index, self._compare_default, user_data=column_index) + :arg int column_index: The index of the column to sort by. + """ - # If we are operating on the currently-sorted column... - if(self.sorter[log_index].get_sort_column_id()[0] == column_index): - order = column.get_sort_order() - # ...then check if we need to reverse the order of searching. - if(order == Gtk.SortType.ASCENDING): - self.sorter[log_index].set_sort_column_id(column_index, Gtk.SortType.DESCENDING) - column.set_sort_order(Gtk.SortType.DESCENDING) - else: + log_index = self._get_log_index() + column = self.treeview[log_index].get_column(column_index) + + if(AVAILABLE_FIELD_NAMES_ORDERED[column_index-1] == "QSO_DATE"): + # If the field being sorted is the QSO_DATE, then also sort by the TIME_ON field so we get the + # correct chronological order. + # Note: This assumes that the TIME_ON field is always immediately to the right of the QSO_DATE field. + self.sorter[log_index].set_sort_func(column_index, self._compare_date_and_time, user_data=[column_index, column_index+1]) + else: + self.sorter[log_index].set_sort_func(column_index, self._compare_default, user_data=column_index) + + # If we are operating on the currently-sorted column... + if(self.sorter[log_index].get_sort_column_id()[0] == column_index): + order = column.get_sort_order() + # ...then check if we need to reverse the order of searching. + if(order == Gtk.SortType.ASCENDING): + self.sorter[log_index].set_sort_column_id(column_index, Gtk.SortType.DESCENDING) + column.set_sort_order(Gtk.SortType.DESCENDING) + else: + self.sorter[log_index].set_sort_column_id(column_index, Gtk.SortType.ASCENDING) + column.set_sort_order(Gtk.SortType.ASCENDING) + else: + # Otherwise, change to the new sorted column. Default to ASCENDING order. self.sorter[log_index].set_sort_column_id(column_index, Gtk.SortType.ASCENDING) column.set_sort_order(Gtk.SortType.ASCENDING) - else: - # Otherwise, change to the new sorted column. Default to ASCENDING order. - self.sorter[log_index].set_sort_column_id(column_index, Gtk.SortType.ASCENDING) - column.set_sort_order(Gtk.SortType.ASCENDING) - # Show an arrow pointing in the direction of the sorting. - # (First we need to remove the arrow from the previously-sorted column. - # Since we don't know which one that was, just remove the arrow from all columns - # and start again. This only loops over a few dozen columns at most, so - # hopefully it won't take too much time.) - for i in range(0, len(AVAILABLE_FIELD_NAMES_ORDERED)): - column = self.treeview[log_index].get_column(i) - column.set_sort_indicator(False) - column = self.treeview[log_index].get_column(column_index) - column.set_sort_indicator(True) - return - - def rename_log(self, widget=None): - """ Rename the log that is currently selected. """ - if(self.connection is None): - return - page_index = self.get_current_page() - if(page_index == 0): # If we are on the Summary page... - logging.debug("No log currently selected!") - return - page = self.get_nth_page(page_index) # Gets the Gtk.VBox of the selected tab in the logbook - old_log_name = page.get_name() - - log_index = self._get_log_index(name=old_log_name) - - exists = True - dialog = LogNameDialog(self.parent, title="Rename Log", name=old_log_name) - while(exists): - response = dialog.run() - if(response == Gtk.ResponseType.OK): - new_log_name = dialog.get_log_name() - try: - with self.connection: - c = self.connection.cursor() - query = "ALTER TABLE %s RENAME TO %s" % (old_log_name, new_log_name) - c.execute(query) - exists = False - except sqlite.Error as e: - logging.exception(e) - # Data is not valid - inform the user. - error(parent=self.parent, message="Database error. Try another log name.") - exists = True - else: - dialog.destroy() + # Show an arrow pointing in the direction of the sorting. + # (First we need to remove the arrow from the previously-sorted column. + # Since we don't know which one that was, just remove the arrow from all columns + # and start again. This only loops over a few dozen columns at most, so + # hopefully it won't take too much time.) + for i in range(0, len(AVAILABLE_FIELD_NAMES_ORDERED)): + column = self.treeview[log_index].get_column(i) + column.set_sort_indicator(False) + column = self.treeview[log_index].get_column(column_index) + column.set_sort_indicator(True) + return + + def rename_log(self, widget=None): + """ Rename the log that is currently selected. """ + if(self.connection is None): return + page_index = self.get_current_page() + if(page_index == 0): # If we are on the Summary page... + logging.debug("No log currently selected!") + return + page = self.get_nth_page(page_index) # Gets the Gtk.VBox of the selected tab in the logbook + old_log_name = page.get_name() - dialog.destroy() - - # Remember to change the Log object's name... - self.logs[log_index].name = new_log_name - - # ...and the page's name - page.set_name(self.logs[log_index].name) + log_index = self._get_log_index(name=old_log_name) - # ...and update the tab's label - hbox = Gtk.HBox(False, 0) - label = Gtk.Label(new_log_name) - hbox.pack_start(label, False, False, 0) - hbox.show_all() - self.set_tab_label(page, hbox) - - # The number of logs will obviously stay the same, but - # we want to update the logbook's modification date. - self.update_summary() - - return - - def import_log(self, widget=None): - """ Import a log from an ADIF file. """ - dialog = Gtk.FileChooserDialog("Import ADIF Log File", - None, - Gtk.FileChooserAction.OPEN, - (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, - Gtk.STOCK_OPEN, Gtk.ResponseType.OK)) - filter = Gtk.FileFilter() - filter.set_name("All ADIF files (*.adi, *.ADI)") - filter.add_pattern("*.adi"); filter.add_pattern("*.ADI") - dialog.add_filter(filter) - - filter = Gtk.FileFilter() - filter.set_name("All files") - filter.add_pattern("*") - dialog.add_filter(filter) - - response = dialog.run() - if(response == Gtk.ResponseType.OK): - path = dialog.get_filename() - else: - path = None - dialog.destroy() - - if(path is None): - logging.debug("No file path specified.") - return - - dialog = LogNameDialog(self.parent, title="Import Log") - while(True): - response = dialog.run() - if(response == Gtk.ResponseType.OK): - log_name = dialog.get_log_name() - if(self.log_name_exists(log_name)): - # Import into existing log - exists = True - l = self.logs[self._get_log_index(name=log_name)] - response = question(parent=self.parent, message="Are you sure you want to import into an existing log?") - if(response == Gtk.ResponseType.YES): - break - elif(self.log_name_exists(log_name) is None): - # Could not determine if the log name exists. It's safer to stop here than to try to add a new log. - error(parent=self.parent, message="Database error. Could not check if the log name exists.") - dialog.destroy() - return + exists = True + dialog = LogNameDialog(self.parent, title="Rename Log", name=old_log_name) + while(exists): + response = dialog.run() + if(response == Gtk.ResponseType.OK): + new_log_name = dialog.get_log_name() + try: + with self.connection: + c = self.connection.cursor() + query = "ALTER TABLE %s RENAME TO %s" % (old_log_name, new_log_name) + c.execute(query) + exists = False + except sqlite.Error as e: + logging.exception(e) + # Data is not valid - inform the user. + error(parent=self.parent, message="Database error. Try another log name.") + exists = True else: - # Create a new log with the name the user supplies - exists = False - try: - with self.connection: - c = self.connection.cursor() - query = "CREATE TABLE %s (id INTEGER PRIMARY KEY AUTOINCREMENT" % log_name - for field_name in AVAILABLE_FIELD_NAMES_ORDERED: - s = ", %s TEXT" % field_name.lower() - query = query + s - query = query + ")" - c.execute(query) - l = Log(self.connection, log_name) - break - except sqlite.Error as e: - logging.exception(e) - # Data is not valid - inform the user. - error(parent=self.parent, message="Database error. Try another log name.") - else: - dialog.destroy() + dialog.destroy() + return + + dialog.destroy() + + # Remember to change the Log object's name... + self.logs[log_index].name = new_log_name + + # ...and the page's name + page.set_name(self.logs[log_index].name) + + # ...and update the tab's label + hbox = Gtk.HBox(False, 0) + label = Gtk.Label(new_log_name) + hbox.pack_start(label, False, False, 0) + hbox.show_all() + self.set_tab_label(page, hbox) + + # The number of logs will obviously stay the same, but + # we want to update the logbook's modification date. + self.update_summary() + + return + + def import_log(self, widget=None): + """ Import a log from an ADIF file. """ + dialog = Gtk.FileChooserDialog("Import ADIF Log File", + None, + Gtk.FileChooserAction.OPEN, + (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_OPEN, Gtk.ResponseType.OK)) + filter = Gtk.FileFilter() + filter.set_name("All ADIF files (*.adi, *.ADI)") + filter.add_pattern("*.adi") + filter.add_pattern("*.ADI") + dialog.add_filter(filter) + + filter = Gtk.FileFilter() + filter.set_name("All files") + filter.add_pattern("*") + dialog.add_filter(filter) + + response = dialog.run() + if(response == Gtk.ResponseType.OK): + path = dialog.get_filename() + else: + path = None + dialog.destroy() + + if(path is None): + logging.debug("No file path specified.") return - - dialog.destroy() - adif = ADIF() - logging.debug("Importing records from the ADIF file with path: %s" % path) - records = adif.read(path) - l.add_record(records) - l.populate() + dialog = LogNameDialog(self.parent, title="Import Log") + while(True): + response = dialog.run() + if(response == Gtk.ResponseType.OK): + log_name = dialog.get_log_name() + if(self.log_name_exists(log_name)): + # Import into existing log + exists = True + l = self.logs[self._get_log_index(name=log_name)] + response = question(parent=self.parent, message="Are you sure you want to import into an existing log?") + if(response == Gtk.ResponseType.YES): + break + elif(self.log_name_exists(log_name) is None): + # Could not determine if the log name exists. It's safer to stop here than to try to add a new log. + error(parent=self.parent, message="Database error. Could not check if the log name exists.") + dialog.destroy() + return + else: + # Create a new log with the name the user supplies + exists = False + try: + with self.connection: + c = self.connection.cursor() + query = "CREATE TABLE %s (id INTEGER PRIMARY KEY AUTOINCREMENT" % log_name + for field_name in AVAILABLE_FIELD_NAMES_ORDERED: + s = ", %s TEXT" % field_name.lower() + query = query + s + query = query + ")" + c.execute(query) + l = Log(self.connection, log_name) + break + except sqlite.Error as e: + logging.exception(e) + # Data is not valid - inform the user. + error(parent=self.parent, message="Database error. Try another log name.") + else: + dialog.destroy() + return - if(not exists): - self.logs.append(l) - self._render_log(self.get_number_of_logs()-1) - self.update_summary() - self.parent.toolbox.awards.count() - - return - - def export_log(self, widget=None): - """ Export the log (that is currently selected) to an ADIF file. """ - page_index = self.get_current_page() # Gets the index of the selected tab in the logbook - if(page_index == 0): # If we are on the Summary page... - logging.debug("No log currently selected!") - return + dialog.destroy() - log_index = self._get_log_index() - log = self.logs[log_index] + adif = ADIF() + logging.debug("Importing records from the ADIF file with path: %s" % path) + records = adif.read(path) + l.add_record(records) + l.populate() - dialog = Gtk.FileChooserDialog("Export Log to File", - None, - Gtk.FileChooserAction.SAVE, - (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, - Gtk.STOCK_SAVE, Gtk.ResponseType.OK)) - dialog.set_do_overwrite_confirmation(True) + if(not exists): + self.logs.append(l) + self._render_log(self.get_number_of_logs()-1) + self.update_summary() + self.parent.toolbox.awards.count() - filter = Gtk.FileFilter() - filter.set_name("All ADIF files (*.adi, *.ADI)") - filter.add_pattern("*.adi"); filter.add_pattern("*.ADI") - dialog.add_filter(filter) + return - filter = Gtk.FileFilter() - filter.set_name("All files") - filter.add_pattern("*") - dialog.add_filter(filter) + def export_log(self, widget=None): + """ Export the log (that is currently selected) to an ADIF file. """ + page_index = self.get_current_page() # Gets the index of the selected tab in the logbook + if(page_index == 0): # If we are on the Summary page... + logging.debug("No log currently selected!") + return - response = dialog.run() - if(response == Gtk.ResponseType.OK): - path = dialog.get_filename() - else: - path = None - dialog.destroy() - - if(path is None): - logging.debug("No file path specified.") - else: - adif = ADIF() - records = log.get_all_records() - if(records is not None): - adif.write(records, path) - else: - error(self.parent, "Could not retrieve the records from the SQL database. No records have been exported.") - return + log_index = self._get_log_index() + log = self.logs[log_index] - def print_log(self, widget=None): - """ Print all the records in the log (that is currently selected). - Note that only a few important fields are printed because of the restricted width of the page. """ - page_index = self.get_current_page() # Gets the index of the selected tab in the logbook - if(page_index == 0): # If we are on the Summary page... - logging.debug("No log currently selected!") - return - log_index = self._get_log_index() - log = self.logs[log_index] + dialog = Gtk.FileChooserDialog("Export Log to File", + None, + Gtk.FileChooserAction.SAVE, + (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_SAVE, Gtk.ResponseType.OK)) + dialog.set_do_overwrite_confirmation(True) - self.text_to_print = "Callsign\t---\tDate\t---\tTime\t---\tFrequency\t---\tMode\n" - records = log.get_all_records() - if(records is not None): - for r in records: - self.text_to_print += str(r["CALL"]) + "\t---\t" + str(r["QSO_DATE"]) + "\t---\t" + str(r["TIME_ON"]) + "\t---\t" + str(r["FREQ"]) + "\t---\t" + str(r["MODE"]) + "\n" + filter = Gtk.FileFilter() + filter.set_name("All ADIF files (*.adi, *.ADI)") + filter.add_pattern("*.adi") + filter.add_pattern("*.ADI") + dialog.add_filter(filter) - action = Gtk.PrintOperationAction.PRINT_DIALOG - operation = Gtk.PrintOperation() - operation.set_default_page_setup(Gtk.PageSetup()) - operation.set_unit(Gtk.Unit.MM) + filter = Gtk.FileFilter() + filter.set_name("All files") + filter.add_pattern("*") + dialog.add_filter(filter) - operation.connect("begin_print", self._begin_print) - operation.connect("draw_page", self._draw_page) - operation.run(action, parent=self.parent) - else: - error(self.parent, "Could not retrieve the records from the SQL database. No records have been printed.") - return - - def _begin_print(self, operation, context): - """ Specify the layout/position/font of the text on the pages to be printed. - - :arg Gtk.PrintOperation operation: The printing API. - :arg Gtk.PrintContext context: Used to draw/render the pages to print. - """ - width = context.get_width() - height = context.get_height() - layout = context.create_pango_layout() - layout.set_font_description(Pango.FontDescription("normal 10")) - layout.set_width(int(width*Pango.SCALE)) - layout.set_text(self.text_to_print, -1) + response = dialog.run() + if(response == Gtk.ResponseType.OK): + path = dialog.get_filename() + else: + path = None + dialog.destroy() - number_of_pages = 0 - page_height = 0 - for line in range(0, layout.get_line_count()): - layout_line = layout.get_line(line) - ink_rectangle, logical_rectangle = layout_line.get_extents() - x_bearing, y_bearing, logical_rectangle_width, logical_rectangle_height = logical_rectangle.x, logical_rectangle.y, logical_rectangle.width, logical_rectangle.height - self.line_height = logical_rectangle.height/1024.0 + 3 - page_height += self.line_height - if(page_height + self.line_height > height): - number_of_pages += 1 - page_height = self.line_height - operation.set_n_pages(number_of_pages + 1) - self.text_to_print = self.text_to_print.split("\n") - return + if(path is None): + logging.debug("No file path specified.") + else: + adif = ADIF() + records = log.get_all_records() + if(records is not None): + adif.write(records, path) + else: + error(self.parent, "Could not retrieve the records from the SQL database. No records have been exported.") + return - def _draw_page(self, operation, context, page_number): - """ Render the QSO details on the page. - - :arg Gtk.PrintOperation operation: The printing API. - :arg Gtk.PrintContext context: Used to draw/render the pages to print. - :arg int page_number: The current page number. - """ - cr = context.get_cairo_context() - cr.set_source_rgb(0, 0, 0) - layout = context.create_pango_layout() - - current_line_number = 0 - for line in self.text_to_print: - layout.set_text(line, -1) - cr.move_to(5, current_line_number*self.line_height) - PangoCairo.update_layout(cr, layout) - PangoCairo.show_layout(cr, layout) - current_line_number += 1 - if(current_line_number*self.line_height > context.get_height()): - for j in range(0, current_line_number): - self.text_to_print.pop(0) # Remove what has been printed already before draw_page is called again - break - return + def print_log(self, widget=None): + """ Print all the records in the log (that is currently selected). + Note that only a few important fields are printed because of the restricted width of the page. """ + page_index = self.get_current_page() # Gets the index of the selected tab in the logbook + if(page_index == 0): # If we are on the Summary page... + logging.debug("No log currently selected!") + return + log_index = self._get_log_index() + log = self.logs[log_index] - def add_record_callback(self, widget): - """ A callback function used to add a particular record/QSO. """ - # Get the log index - try: - log_index = self._get_log_index() - if(log_index is None): - raise ValueError("The log index could not be determined. Perhaps you tried adding a record when the Summary page was selected?") - except ValueError as e: - error(self.parent, e) - return - log = self.logs[log_index] - - # Keep the dialog open after adding a record? - config = configparser.ConfigParser() - have_config = (config.read(expanduser('~/.config/pyqso/preferences.ini')) != []) - (section, option) = ("general", "keep_open") - if(have_config and config.has_option(section, option)): - keep_open = config.get("general", "keep_open") == "True" - else: - keep_open = False - adif = ADIF() + self.text_to_print = "Callsign\t---\tDate\t---\tTime\t---\tFrequency\t---\tMode\n" + records = log.get_all_records() + if(records is not None): + for r in records: + self.text_to_print += str(r["CALL"]) + "\t---\t" + str(r["QSO_DATE"]) + "\t---\t" + str(r["TIME_ON"]) + "\t---\t" + str(r["FREQ"]) + "\t---\t" + str(r["MODE"]) + "\n" - exit = False - while not exit: - dialog = RecordDialog(parent=self.parent, log=log, index=None) - - all_valid = False # Are all the field entries valid? - - # Shall we exit the while loop (and therefore close the Add Record dialog)? - if keep_open: - exit = False - else: - exit = True - - while not all_valid: + action = Gtk.PrintOperationAction.PRINT_DIALOG + operation = Gtk.PrintOperation() + operation.set_default_page_setup(Gtk.PageSetup()) + operation.set_unit(Gtk.Unit.MM) + + operation.connect("begin_print", self._begin_print) + operation.connect("draw_page", self._draw_page) + operation.run(action, parent=self.parent) + else: + error(self.parent, "Could not retrieve the records from the SQL database. No records have been printed.") + return + + def _begin_print(self, operation, context): + """ Specify the layout/position/font of the text on the pages to be printed. + + :arg Gtk.PrintOperation operation: The printing API. + :arg Gtk.PrintContext context: Used to draw/render the pages to print. + """ + width = context.get_width() + height = context.get_height() + layout = context.create_pango_layout() + layout.set_font_description(Pango.FontDescription("normal 10")) + layout.set_width(int(width*Pango.SCALE)) + layout.set_text(self.text_to_print, -1) + + number_of_pages = 0 + page_height = 0 + for line in range(0, layout.get_line_count()): + layout_line = layout.get_line(line) + ink_rectangle, logical_rectangle = layout_line.get_extents() + x_bearing, y_bearing, logical_rectangle_width, logical_rectangle_height = logical_rectangle.x, logical_rectangle.y, logical_rectangle.width, logical_rectangle.height + self.line_height = logical_rectangle.height/1024.0 + 3 + page_height += self.line_height + if(page_height + self.line_height > height): + number_of_pages += 1 + page_height = self.line_height + operation.set_n_pages(number_of_pages + 1) + self.text_to_print = self.text_to_print.split("\n") + return + + def _draw_page(self, operation, context, page_number): + """ Render the QSO details on the page. + + :arg Gtk.PrintOperation operation: The printing API. + :arg Gtk.PrintContext context: Used to draw/render the pages to print. + :arg int page_number: The current page number. + """ + cr = context.get_cairo_context() + cr.set_source_rgb(0, 0, 0) + layout = context.create_pango_layout() + + current_line_number = 0 + for line in self.text_to_print: + layout.set_text(line, -1) + cr.move_to(5, current_line_number*self.line_height) + PangoCairo.update_layout(cr, layout) + PangoCairo.show_layout(cr, layout) + current_line_number += 1 + if(current_line_number*self.line_height > context.get_height()): + for j in range(0, current_line_number): + self.text_to_print.pop(0) # Remove what has been printed already before draw_page is called again + break + return + + def add_record_callback(self, widget): + """ A callback function used to add a particular record/QSO. """ + # Get the log index + try: + log_index = self._get_log_index() + if(log_index is None): + raise ValueError("The log index could not be determined. Perhaps you tried adding a record when the Summary page was selected?") + except ValueError as e: + error(self.parent, e) + return + log = self.logs[log_index] + + # Keep the dialog open after adding a record? + config = configparser.ConfigParser() + have_config = (config.read(expanduser('~/.config/pyqso/preferences.ini')) != []) + (section, option) = ("general", "keep_open") + if(have_config and config.has_option(section, option)): + keep_open = config.get("general", "keep_open") == "True" + else: + keep_open = False + adif = ADIF() + + exit = False + while not exit: + dialog = RecordDialog(parent=self.parent, log=log, index=None) + + all_valid = False # Are all the field entries valid? + + # Shall we exit the while loop (and therefore close the Add Record dialog)? + if keep_open: + exit = False + else: + exit = True + + while not all_valid: + # This while loop gives the user infinite attempts at giving valid data. + # The add/edit record window will stay open until the user gives valid data, + # or until the Cancel button is clicked. + all_valid = True + response = dialog.run() + if(response == Gtk.ResponseType.OK): + fields_and_data = {} + field_names = AVAILABLE_FIELD_NAMES_ORDERED + for i in range(0, len(field_names)): + # Validate user input. + fields_and_data[field_names[i]] = dialog.get_data(field_names[i]) + if(not(adif.is_valid(field_names[i], fields_and_data[field_names[i]], AVAILABLE_FIELD_NAMES_TYPES[field_names[i]]))): + # Data is not valid - inform the user. + error(parent=self.parent, message="The data in field \"%s\" is not valid!" % field_names[i]) + all_valid = False + break # Don't check the other data until the user has fixed the current one. + + if(all_valid): + # All data has been validated, so we can go ahead and add the new record. + log.add_record(fields_and_data) + self.update_summary() + self.parent.toolbox.awards.count() + # Select the new Record's row in the treeview. + number_of_records = log.get_number_of_records() + if(number_of_records is not None): + self.treeselection[log_index].select_path(number_of_records) + else: + exit = True + break + dialog.destroy() + return + + def delete_record_callback(self, widget): + """ A callback function used to delete a particular record/QSO. """ + + # Get the log index + try: + log_index = self._get_log_index() + if(log_index is None): + raise ValueError("The log index could not be determined. Perhaps you tried deleting a record when the Summary page was selected?") + except ValueError as e: + error(self.parent, e) + return + log = self.logs[log_index] + + (sort_model, path) = self.treeselection[log_index].get_selected_rows() # Get the selected row in the log + try: + sort_iter = sort_model.get_iter(path[0]) + filter_iter = self.sorter[log_index].convert_iter_to_child_iter(sort_iter) + # ...and the ListStore model (i.e. the log) is a child of the filter model. + child_iter = self.filter[log_index].convert_iter_to_child_iter(filter_iter) + row_index = log.get_value(child_iter, 0) + except IndexError: + logging.debug("Trying to delete a record, but there are no records in the log!") + return + + response = question(parent=self.parent, message="Are you sure you want to delete record %d?" % row_index) + if(response == Gtk.ResponseType.YES): + # Deletes the record with index 'row_index' from the Records list. + # 'iter' is needed to remove the record from the ListStore itself. + log.delete_record(row_index, iter=child_iter) + self.update_summary() + self.parent.toolbox.awards.count() + return + + def edit_record_callback(self, widget, path, view_column): + """ A callback function used to edit a particular record/QSO. + Note that the widget, path and view_column arguments are not used, + but need to be passed in since they associated with the row-activated signal + which is generated when the user double-clicks on a record. """ + + # Get the log index + try: + log_index = self._get_log_index() + if(log_index is None): + raise ValueError("The log index could not be determined. Perhaps you tried editing a record when the Summary page was selected?") + except ValueError as e: + error(self.parent, e) + return + log = self.logs[log_index] + + (sort_model, path) = self.treeselection[log_index].get_selected_rows() # Get the selected row in the log + try: + sort_iter = sort_model.get_iter(path[0]) + filter_iter = self.sorter[log_index].convert_iter_to_child_iter(sort_iter) + # ...and the ListStore model (i.e. the log) is a child of the filter model. + child_iter = self.filter[log_index].convert_iter_to_child_iter(filter_iter) + row_index = log.get_value(child_iter, 0) + except IndexError: + logging.debug("Could not find the selected row's index!") + return + + dialog = RecordDialog(parent=self.parent, log=self.logs[log_index], index=row_index) + all_valid = False # Are all the field entries valid? + + adif = ADIF() + while(not all_valid): # This while loop gives the user infinite attempts at giving valid data. # The add/edit record window will stay open until the user gives valid data, # or until the Cancel button is clicked. all_valid = True response = dialog.run() if(response == Gtk.ResponseType.OK): - fields_and_data = {} - field_names = AVAILABLE_FIELD_NAMES_ORDERED - for i in range(0, len(field_names)): - # Validate user input. - fields_and_data[field_names[i]] = dialog.get_data(field_names[i]) - if(not(adif.is_valid(field_names[i], fields_and_data[field_names[i]], AVAILABLE_FIELD_NAMES_TYPES[field_names[i]]))): - # Data is not valid - inform the user. - error(parent=self.parent, message="The data in field \"%s\" is not valid!" % field_names[i]) - all_valid = False - break # Don't check the other data until the user has fixed the current one. + fields_and_data = {} + field_names = AVAILABLE_FIELD_NAMES_ORDERED + for i in range(0, len(field_names)): + # Validate user input. + fields_and_data[field_names[i]] = dialog.get_data(field_names[i]) + if(not(adif.is_valid(field_names[i], fields_and_data[field_names[i]], AVAILABLE_FIELD_NAMES_TYPES[field_names[i]]))): + # Data is not valid - inform the user. + error(parent=self.parent, message="The data in field \"%s\" is not valid!" % field_names[i]) + all_valid = False + break # Don't check the other fields until the user has fixed the current field's data. - if(all_valid): - # All data has been validated, so we can go ahead and add the new record. - log.add_record(fields_and_data) - self.update_summary() - self.parent.toolbox.awards.count() - # Select the new Record's row in the treeview. - number_of_records = log.get_number_of_records() - if(number_of_records is not None): - self.treeselection[log_index].select_path(number_of_records) + if(all_valid): + # All data has been validated, so we can go ahead and update the record. + record = log.get_record_by_index(row_index) + if(record is None): + message = "Could not retrieve record with row_index %d from the SQL database. The record has not been edited." % row_index + logging.error(message) + error(parent=self.parent, message=message) + else: + for i in range(0, len(field_names)): + # Check whether the data has actually changed. Database updates can be expensive. + if(record[field_names[i].lower()] != fields_and_data[field_names[i]]): + # Update the record in the database and then in the ListStore. + # We add 1 onto the column_index here because we don't want to consider the index column. + log.edit_record(row_index, field_names[i], fields_and_data[field_names[i]], iter=child_iter, column_index=i+1) + self.update_summary() + self.parent.toolbox.awards.count() + + dialog.destroy() + return + + def remove_duplicates_callback(self, widget=None): + """ Remove duplicate records in a log. + Detecting duplicate records is done based on the CALL, QSO_DATE, TIME_ON, FREQ, and MODE fields. """ + logging.debug("Removing duplicate records...") + + log_index = self._get_log_index() + log = self.logs[log_index] + + (number_of_duplicates, number_of_duplicates_removed) = log.remove_duplicates() + info(self.parent, "Found %d duplicate(s). Successfully removed %d duplicate(s)." % (number_of_duplicates, number_of_duplicates_removed)) + return + + def get_number_of_logs(self): + """ Return the total number of logs in the logbook. + + :returns: The total number of logs in the logbook. + :rtype: int + """ + return len(self.logs) + + def get_number_of_qsos(self): + """ Return the total number of QSOs/records in the whole logbook. + + :returns: The total number of QSOs/records in the whole logbook. + :rtype: int + """ + total = 0 + for log in self.logs: + total += log.get_number_of_records() + return total + + def log_name_exists(self, table_name): + """ Determine whether a Log object with a given name exists in the SQL database. + + :arg str table_name: The name of the log (i.e. the name of the table in the SQL database). + :returns: True if the log name already exists in the logbook; False if it does not already exist; None if there is a database error. + :rtype: bool or None + """ + try: + with self.connection: + c = self.connection.cursor() + c.execute("SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name=?)", [table_name]) + exists = c.fetchone() + if(exists[0] == 1): + return True else: - exit = True - break - dialog.destroy() - return - - def delete_record_callback(self, widget): - """ A callback function used to delete a particular record/QSO. """ - - # Get the log index - try: - log_index = self._get_log_index() - if(log_index is None): - raise ValueError("The log index could not be determined. Perhaps you tried deleting a record when the Summary page was selected?") - except ValueError as e: - error(self.parent, e) - return - log = self.logs[log_index] - - (sort_model, path) = self.treeselection[log_index].get_selected_rows() # Get the selected row in the log - try: - sort_iter = sort_model.get_iter(path[0]) - filter_iter = self.sorter[log_index].convert_iter_to_child_iter(sort_iter) - # ...and the ListStore model (i.e. the log) is a child of the filter model. - child_iter = self.filter[log_index].convert_iter_to_child_iter(filter_iter) - row_index = log.get_value(child_iter,0) - except IndexError: - logging.debug("Trying to delete a record, but there are no records in the log!") - return - - response = question(parent=self.parent, message = "Are you sure you want to delete record %d?" % row_index) - if(response == Gtk.ResponseType.YES): - # Deletes the record with index 'row_index' from the Records list. - # 'iter' is needed to remove the record from the ListStore itself. - log.delete_record(row_index, iter=child_iter) - self.update_summary() - self.parent.toolbox.awards.count() - return - - def edit_record_callback(self, widget, path, view_column): - """ A callback function used to edit a particular record/QSO. - Note that the widget, path and view_column arguments are not used, - but need to be passed in since they associated with the row-activated signal - which is generated when the user double-clicks on a record. """ - - # Get the log index - try: - log_index = self._get_log_index() - if(log_index is None): - raise ValueError("The log index could not be determined. Perhaps you tried editing a record when the Summary page was selected?") - except ValueError as e: - error(self.parent, e) - return - log = self.logs[log_index] - - (sort_model, path) = self.treeselection[log_index].get_selected_rows() # Get the selected row in the log - try: - sort_iter = sort_model.get_iter(path[0]) - filter_iter = self.sorter[log_index].convert_iter_to_child_iter(sort_iter) - # ...and the ListStore model (i.e. the log) is a child of the filter model. - child_iter = self.filter[log_index].convert_iter_to_child_iter(filter_iter) - row_index = log.get_value(child_iter,0) - except IndexError: - logging.debug("Could not find the selected row's index!") - return - - dialog = RecordDialog(parent=self.parent, log=self.logs[log_index], index=row_index) - all_valid = False # Are all the field entries valid? - - adif = ADIF() - while(not all_valid): - # This while loop gives the user infinite attempts at giving valid data. - # The add/edit record window will stay open until the user gives valid data, - # or until the Cancel button is clicked. - all_valid = True - response = dialog.run() - if(response == Gtk.ResponseType.OK): - fields_and_data = {} - field_names = AVAILABLE_FIELD_NAMES_ORDERED - for i in range(0, len(field_names)): - # Validate user input. - fields_and_data[field_names[i]] = dialog.get_data(field_names[i]) - if(not(adif.is_valid(field_names[i], fields_and_data[field_names[i]], AVAILABLE_FIELD_NAMES_TYPES[field_names[i]]))): - # Data is not valid - inform the user. - error(parent=self.parent, message="The data in field \"%s\" is not valid!" % field_names[i]) - all_valid = False - break # Don't check the other fields until the user has fixed the current field's data. - - if(all_valid): - # All data has been validated, so we can go ahead and update the record. - record = log.get_record_by_index(row_index) - if(record is None): - message = "Could not retrieve record with row_index %d from the SQL database. The record has not been edited." % row_index - logging.error(message) - error(parent=self.parent, message=message) - else: - for i in range(0, len(field_names)): - # Check whether the data has actually changed. Database updates can be expensive. - if(record[field_names[i].lower()] != fields_and_data[field_names[i]]): - # Update the record in the database and then in the ListStore. - # We add 1 onto the column_index here because we don't want to consider the index column. - log.edit_record(row_index, field_names[i], fields_and_data[field_names[i]], iter=child_iter, column_index=i+1) - self.update_summary() - self.parent.toolbox.awards.count() - - dialog.destroy() - return - - def remove_duplicates_callback(self, widget=None): - """ Remove duplicate records in a log. - Detecting duplicate records is done based on the CALL, QSO_DATE, TIME_ON, FREQ, and MODE fields. """ - logging.debug("Removing duplicate records...") - - log_index = self._get_log_index() - log = self.logs[log_index] - - (number_of_duplicates, number_of_duplicates_removed) = log.remove_duplicates() - info(self.parent, "Found %d duplicate(s). Successfully removed %d duplicate(s)." % (number_of_duplicates, number_of_duplicates_removed)) - return - - def get_number_of_logs(self): - """ Return the total number of logs in the logbook. - - :returns: The total number of logs in the logbook. - :rtype: int - """ - return len(self.logs) - - def get_number_of_qsos(self): - """ Return the total number of QSOs/records in the whole logbook. - - :returns: The total number of QSOs/records in the whole logbook. - :rtype: int - """ - total = 0 - for log in self.logs: - total += log.get_number_of_records() - return total - - def log_name_exists(self, table_name): - """ Determine whether a Log object with a given name exists in the SQL database. - - :arg str table_name: The name of the log (i.e. the name of the table in the SQL database). - :returns: True if the log name already exists in the logbook; False if it does not already exist; None if there is a database error. - :rtype: bool or None - """ - try: - with self.connection: - c = self.connection.cursor() - c.execute("SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name=?)", [table_name]) - exists = c.fetchone() - if(exists[0] == 1): - return True - else: - return False - except (sqlite.Error, IndexError) as e: - logging.exception(e) # Database error. PyQSO could not check if the log name exists. - return None - - def _get_log_index(self, name=None): - """ Given the name of a log, return its index in the list of Log objects. - - :arg str name: The name of the log. If None, use the name of the currently-selected log. - :returns: The index of the named log in the list of Log objects. - :rtype: int - """ - if(name is None): - # If no page name is supplied, then just use the currently selected page - page_index = self.get_current_page() # Gets the index of the selected tab in the logbook - if(page_index == 0 or page_index == self.get_n_pages()-1): - # We either have the Summary page, or the "+" (add log) dummy page. - logging.debug("No log currently selected!") + return False + except (sqlite.Error, IndexError) as e: + logging.exception(e) # Database error. PyQSO could not check if the log name exists. return None - name = self.get_nth_page(page_index).get_name() - # If a page of the logbook (and therefore a Log object) gets deleted, - # then the page_index may not correspond to the index of the log in the self.logs list. - # Therefore, we have to search for the tab with the same name as the log. - for i in range(0, len(self.logs)): - if(self.logs[i].name == name): - log_index = i - break - return log_index + + def _get_log_index(self, name=None): + """ Given the name of a log, return its index in the list of Log objects. + + :arg str name: The name of the log. If None, use the name of the currently-selected log. + :returns: The index of the named log in the list of Log objects. + :rtype: int + """ + if(name is None): + # If no page name is supplied, then just use the currently selected page + page_index = self.get_current_page() # Gets the index of the selected tab in the logbook + if(page_index == 0 or page_index == self.get_n_pages()-1): + # We either have the Summary page, or the "+" (add log) dummy page. + logging.debug("No log currently selected!") + return None + name = self.get_nth_page(page_index).get_name() + # If a page of the logbook (and therefore a Log object) gets deleted, + # then the page_index may not correspond to the index of the log in the self.logs list. + # Therefore, we have to search for the tab with the same name as the log. + for i in range(0, len(self.logs)): + if(self.logs[i].name == name): + log_index = i + break + return log_index + class TestLogbook(unittest.TestCase): - """ The unit tests for the Logbook class. """ - def setUp(self): - """ Set up the Logbook object and connection to the test database needed for the unit tests. """ - import os - self.logbook = Logbook(parent=None) - success = self.logbook.db_connect(os.path.dirname(os.path.realpath(__file__))+"/unittest_resources/test.db") - assert success - - def tearDown(self): - """ Disconnect from the test database. """ - success = self.logbook.db_disconnect() - assert success + """ The unit tests for the Logbook class. """ + + def setUp(self): + """ Set up the Logbook object and connection to the test database needed for the unit tests. """ + import os + self.logbook = Logbook(parent=None) + success = self.logbook.db_connect(os.path.dirname(os.path.realpath(__file__))+"/unittest_resources/test.db") + assert success + + def tearDown(self): + """ Disconnect from the test database. """ + success = self.logbook.db_disconnect() + assert success + + def test_log_name_exists(self): + """ Check that only the log called 'test' exists. """ + assert self.logbook.log_name_exists("test") # Log 'test' exists. + assert not self.logbook.log_name_exists("hello") # Log 'hello' should not exist. - def test_log_name_exists(self): - """ Check that only the log called 'test' exists. """ - assert self.logbook.log_name_exists("test") # Log 'test' exists. - assert not self.logbook.log_name_exists("hello") # Log 'hello' should not exist. - if(__name__ == '__main__'): - unittest.main() + unittest.main() diff --git a/pyqso/menu.py b/pyqso/menu.py index 39d5eb3..3aa71d8 100644 --- a/pyqso/menu.py +++ b/pyqso/menu.py @@ -22,266 +22,264 @@ import logging import configparser import os.path + class Menu(Gtk.MenuBar): - """ The PyQSO menu bar along the top of the main window. """ - def __init__(self, parent): - """ Set up all menu items and connect to the various functions. - - :arg parent: The parent Gtk window. - """ + """ The PyQSO menu bar along the top of the main window. """ - logging.debug("New Menu instance created!") - - # First let's call the constructor of the super class (Gtk.MenuBar) - Gtk.MenuBar.__init__(self) + def __init__(self, parent): + """ Set up all menu items and connect to the various functions. - logging.debug("Setting up the menu bar...") - agrp = Gtk.AccelGroup() - parent.add_accel_group(agrp) + :arg parent: The parent Gtk window. + """ - self.items = {} - - ###### LOGBOOK ###### - mitem_logbook = Gtk.MenuItem("Logbook") - self.append(mitem_logbook) - subm_logbook = Gtk.Menu() - mitem_logbook.set_submenu(subm_logbook) + logging.debug("New Menu instance created!") - # Create logbook - mitem_connect = Gtk.ImageMenuItem("Create a New Logbook...") - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_NEW, Gtk.IconSize.MENU) - mitem_connect.set_image(icon) - mitem_connect.connect("activate", parent.logbook.new) - subm_logbook.append(mitem_connect) - self.items["NEW_LOGBOOK"] = mitem_connect - - # Open logbook - mitem_connect = Gtk.ImageMenuItem("Open an Existing Logbook...") - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_OPEN, Gtk.IconSize.MENU) - mitem_connect.set_image(icon) - mitem_connect.connect("activate", parent.logbook.open) - key, mod = Gtk.accelerator_parse("O") - mitem_connect.add_accelerator("activate", agrp, key, mod, Gtk.AccelFlags.VISIBLE) - subm_logbook.append(mitem_connect) - self.items["OPEN_LOGBOOK"] = mitem_connect + # First let's call the constructor of the super class (Gtk.MenuBar) + Gtk.MenuBar.__init__(self) - # Close logbook - mitem_disconnect = Gtk.ImageMenuItem("Close Logbook") - mitem_disconnect.connect("activate", parent.logbook.close) - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_CLOSE, Gtk.IconSize.MENU) - mitem_disconnect.set_image(icon) - key, mod = Gtk.accelerator_parse("W") - mitem_disconnect.add_accelerator("activate", agrp, key, mod, Gtk.AccelFlags.VISIBLE) - subm_logbook.append(mitem_disconnect) - self.items["CLOSE_LOGBOOK"] = mitem_disconnect + logging.debug("Setting up the menu bar...") + agrp = Gtk.AccelGroup() + parent.add_accel_group(agrp) - subm_logbook.append(Gtk.SeparatorMenuItem()) + self.items = {} - # New log - mitem_new = Gtk.ImageMenuItem("New Log") - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_ADD, Gtk.IconSize.MENU) - mitem_new.set_image(icon) - mitem_new.connect("activate", parent.logbook.new_log) - key, mod = Gtk.accelerator_parse("N") - mitem_new.add_accelerator("activate", agrp, key, mod, Gtk.AccelFlags.VISIBLE) - subm_logbook.append(mitem_new) - self.items["NEW_LOG"] = mitem_new + # LOGBOOK ###### + mitem_logbook = Gtk.MenuItem("Logbook") + self.append(mitem_logbook) + subm_logbook = Gtk.Menu() + mitem_logbook.set_submenu(subm_logbook) - # Delete the current log - mitem_delete = Gtk.ImageMenuItem("Delete Selected Log") - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_DELETE, Gtk.IconSize.MENU) - mitem_delete.set_image(icon) - mitem_delete.connect("activate", parent.logbook.delete_log) - subm_logbook.append(mitem_delete) - self.items["DELETE_LOG"] = mitem_delete - - # Rename the current log - mitem_rename = Gtk.ImageMenuItem("Rename Selected Log") - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_EDIT, Gtk.IconSize.MENU) - mitem_rename.set_image(icon) - mitem_rename.connect("activate", parent.logbook.rename_log) - subm_logbook.append(mitem_rename) - self.items["RENAME_LOG"] = mitem_rename + # Create logbook + mitem_connect = Gtk.ImageMenuItem("Create a New Logbook...") + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_NEW, Gtk.IconSize.MENU) + mitem_connect.set_image(icon) + mitem_connect.connect("activate", parent.logbook.new) + subm_logbook.append(mitem_connect) + self.items["NEW_LOGBOOK"] = mitem_connect - subm_logbook.append(Gtk.SeparatorMenuItem()) + # Open logbook + mitem_connect = Gtk.ImageMenuItem("Open an Existing Logbook...") + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_OPEN, Gtk.IconSize.MENU) + mitem_connect.set_image(icon) + mitem_connect.connect("activate", parent.logbook.open) + key, mod = Gtk.accelerator_parse("O") + mitem_connect.add_accelerator("activate", agrp, key, mod, Gtk.AccelFlags.VISIBLE) + subm_logbook.append(mitem_connect) + self.items["OPEN_LOGBOOK"] = mitem_connect - # Import log - mitem_import = Gtk.ImageMenuItem("Import Log") - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_GO_FORWARD, Gtk.IconSize.MENU) - mitem_import.set_image(icon) - mitem_import.connect("activate", parent.logbook.import_log) - subm_logbook.append(mitem_import) - self.items["IMPORT_LOG"] = mitem_import + # Close logbook + mitem_disconnect = Gtk.ImageMenuItem("Close Logbook") + mitem_disconnect.connect("activate", parent.logbook.close) + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_CLOSE, Gtk.IconSize.MENU) + mitem_disconnect.set_image(icon) + key, mod = Gtk.accelerator_parse("W") + mitem_disconnect.add_accelerator("activate", agrp, key, mod, Gtk.AccelFlags.VISIBLE) + subm_logbook.append(mitem_disconnect) + self.items["CLOSE_LOGBOOK"] = mitem_disconnect - # Export the current log - mitem_export = Gtk.ImageMenuItem("Export Log") - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_GO_BACK, Gtk.IconSize.MENU) - mitem_export.set_image(icon) - mitem_export.connect("activate", parent.logbook.export_log) - subm_logbook.append(mitem_export) - self.items["EXPORT_LOG"] = mitem_export - - subm_logbook.append(Gtk.SeparatorMenuItem()) + subm_logbook.append(Gtk.SeparatorMenuItem()) - # Print log - mitem_print = Gtk.ImageMenuItem("Print Log") - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_PRINT, Gtk.IconSize.MENU) - mitem_print.set_image(icon) - mitem_print.connect("activate", parent.logbook.print_log) - key, mod = Gtk.accelerator_parse("P") - mitem_print.add_accelerator("activate", agrp, key, mod, Gtk.AccelFlags.VISIBLE) - subm_logbook.append(mitem_print) - self.items["PRINT_LOG"] = mitem_print + # New log + mitem_new = Gtk.ImageMenuItem("New Log") + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_ADD, Gtk.IconSize.MENU) + mitem_new.set_image(icon) + mitem_new.connect("activate", parent.logbook.new_log) + key, mod = Gtk.accelerator_parse("N") + mitem_new.add_accelerator("activate", agrp, key, mod, Gtk.AccelFlags.VISIBLE) + subm_logbook.append(mitem_new) + self.items["NEW_LOG"] = mitem_new - subm_logbook.append(Gtk.SeparatorMenuItem()) + # Delete the current log + mitem_delete = Gtk.ImageMenuItem("Delete Selected Log") + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_DELETE, Gtk.IconSize.MENU) + mitem_delete.set_image(icon) + mitem_delete.connect("activate", parent.logbook.delete_log) + subm_logbook.append(mitem_delete) + self.items["DELETE_LOG"] = mitem_delete - # Quit - mitem_quit = Gtk.ImageMenuItem("Quit") - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_QUIT, Gtk.IconSize.MENU) - mitem_quit.set_image(icon) - mitem_quit.connect("activate", Gtk.main_quit) - key, mod = Gtk.accelerator_parse("Q") - mitem_quit.add_accelerator("activate", agrp, key, mod, Gtk.AccelFlags.VISIBLE) - subm_logbook.append(mitem_quit) - self.items["QUIT"] = mitem_quit - - - ###### RECORDS ###### - mitem_records = Gtk.MenuItem("Records") - self.append(mitem_records) - subm_records = Gtk.Menu() - mitem_records.set_submenu(subm_records) - - mitem_addrecord = Gtk.ImageMenuItem("Add Record...") - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_ADD, Gtk.IconSize.MENU) - mitem_addrecord.set_image(icon) - mitem_addrecord.connect("activate", parent.logbook.add_record_callback) - key, mod = Gtk.accelerator_parse("R") - mitem_addrecord.add_accelerator("activate", agrp, key, mod, Gtk.AccelFlags.VISIBLE) - subm_records.append(mitem_addrecord) - self.items["ADD_RECORD"] = mitem_addrecord - - mitem_editrecord = Gtk.ImageMenuItem("Edit Selected Record...") - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_EDIT, Gtk.IconSize.MENU) - mitem_editrecord.set_image(icon) - mitem_editrecord.connect("activate", parent.logbook.edit_record_callback, None, None) - key, mod = Gtk.accelerator_parse("E") - mitem_editrecord.add_accelerator("activate", agrp, key, mod, Gtk.AccelFlags.VISIBLE) - subm_records.append(mitem_editrecord) - self.items["EDIT_RECORD"] = mitem_editrecord + # Rename the current log + mitem_rename = Gtk.ImageMenuItem("Rename Selected Log") + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_EDIT, Gtk.IconSize.MENU) + mitem_rename.set_image(icon) + mitem_rename.connect("activate", parent.logbook.rename_log) + subm_logbook.append(mitem_rename) + self.items["RENAME_LOG"] = mitem_rename - mitem_deleterecord = Gtk.ImageMenuItem("Delete Selected Record...") - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_DELETE, Gtk.IconSize.MENU) - mitem_deleterecord.set_image(icon) - mitem_deleterecord.connect("activate", parent.logbook.delete_record_callback) - key, mod = Gtk.accelerator_parse("Delete") - mitem_deleterecord.add_accelerator("activate", agrp, key, mod, Gtk.AccelFlags.VISIBLE) - subm_records.append(mitem_deleterecord) - self.items["DELETE_RECORD"] = mitem_deleterecord + subm_logbook.append(Gtk.SeparatorMenuItem()) - mitem_removeduplicates = Gtk.ImageMenuItem("Remove Duplicate Records") - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_FIND_AND_REPLACE, Gtk.IconSize.MENU) - mitem_removeduplicates.set_image(icon) - mitem_removeduplicates.connect("activate", parent.logbook.remove_duplicates_callback) - subm_records.append(mitem_removeduplicates) - self.items["REMOVE_DUPLICATES"] = mitem_removeduplicates - - - ###### VIEW ###### - mitem_view = Gtk.MenuItem("View") - self.append(mitem_view) - subm_view = Gtk.Menu() - mitem_view.set_submenu(subm_view) + # Import log + mitem_import = Gtk.ImageMenuItem("Import Log") + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_GO_FORWARD, Gtk.IconSize.MENU) + mitem_import.set_image(icon) + mitem_import.connect("activate", parent.logbook.import_log) + subm_logbook.append(mitem_import) + self.items["IMPORT_LOG"] = mitem_import - mitem_toolbox = Gtk.CheckMenuItem("Toolbox") - config = configparser.ConfigParser() - have_config = (config.read(os.path.expanduser('~/.config/pyqso/preferences.ini')) != []) - (section, option) = ("general", "show_toolbox") - if(have_config and config.has_option(section, option)): - mitem_toolbox.set_active(config.get(section, option) == "True") - else: - mitem_toolbox.set_active(False) # Don't show the toolbox by default - mitem_toolbox.connect("activate", parent.toolbox.toggle_visible_callback) - subm_view.append(mitem_toolbox) - self.items["TOOLBOX"] = mitem_toolbox + # Export the current log + mitem_export = Gtk.ImageMenuItem("Export Log") + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_GO_BACK, Gtk.IconSize.MENU) + mitem_export.set_image(icon) + mitem_export.connect("activate", parent.logbook.export_log) + subm_logbook.append(mitem_export) + self.items["EXPORT_LOG"] = mitem_export - mitem_preferences = Gtk.ImageMenuItem("Preferences...") - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_PREFERENCES, Gtk.IconSize.MENU) - mitem_preferences.set_image(icon) - mitem_preferences.connect("activate", parent.show_preferences) - subm_view.append(mitem_preferences) - self.items["PREFERENCES"] = mitem_preferences - + subm_logbook.append(Gtk.SeparatorMenuItem()) - ###### HELP ###### - mitem_help = Gtk.MenuItem("Help") - self.append(mitem_help) - subm_help = Gtk.Menu() - mitem_help.set_submenu(subm_help) - - # About - mitem_about = Gtk.ImageMenuItem("About PyQSO") - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_ABOUT, Gtk.IconSize.MENU) - mitem_about.set_image(icon) - mitem_about.connect("activate", parent.show_about) - subm_help.append(mitem_about) + # Print log + mitem_print = Gtk.ImageMenuItem("Print Log") + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_PRINT, Gtk.IconSize.MENU) + mitem_print.set_image(icon) + mitem_print.connect("activate", parent.logbook.print_log) + key, mod = Gtk.accelerator_parse("P") + mitem_print.add_accelerator("activate", agrp, key, mod, Gtk.AccelFlags.VISIBLE) + subm_logbook.append(mitem_print) + self.items["PRINT_LOG"] = mitem_print - self.set_logbook_item_sensitive(True) - self.set_log_items_sensitive(False) - self.set_record_items_sensitive(False) - - logging.debug("Menu bar ready!") + subm_logbook.append(Gtk.SeparatorMenuItem()) - return - - def set_logbook_item_sensitive(self, sensitive): - """ Enable/disable logbook-related menu items. - - :arg bool sensitive: If True, enable the 'new logbook' and 'open logbook' menu items. If False, disable them. - """ - logging.debug("Setting the 'Create/Open Logbook' menu item's sensitivity to: %s..." % sensitive) - self.items["NEW_LOGBOOK"].set_sensitive(sensitive) - self.items["OPEN_LOGBOOK"].set_sensitive(sensitive) - self.items["CLOSE_LOGBOOK"].set_sensitive(not sensitive) - logging.debug("Set the 'Create/Open Logbook' menu item's sensitivity to: %s." % sensitive) - return + # Quit + mitem_quit = Gtk.ImageMenuItem("Quit") + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_QUIT, Gtk.IconSize.MENU) + mitem_quit.set_image(icon) + mitem_quit.connect("activate", Gtk.main_quit) + key, mod = Gtk.accelerator_parse("Q") + mitem_quit.add_accelerator("activate", agrp, key, mod, Gtk.AccelFlags.VISIBLE) + subm_logbook.append(mitem_quit) + self.items["QUIT"] = mitem_quit - def set_log_items_sensitive(self, sensitive): - """ Enable/disable log-related menu items. - - :arg bool sensitive: If True, enable all the log-related menu items. If False, disable them all. - """ - logging.debug("Setting log-related menu item sensitivity to: %s..." % sensitive) - for item_name in ["NEW_LOG", "DELETE_LOG", "RENAME_LOG", "IMPORT_LOG", "EXPORT_LOG", "PRINT_LOG"]: - self.items[item_name].set_sensitive(sensitive) - logging.debug("Set log-related menu item sensitivity to: %s." % sensitive) - return + # RECORDS ###### + mitem_records = Gtk.MenuItem("Records") + self.append(mitem_records) + subm_records = Gtk.Menu() + mitem_records.set_submenu(subm_records) - def set_record_items_sensitive(self, sensitive): - """ Enable/disable record-related menu items. - - :arg bool sensitive: If True, enable all the record-related menu items. If False, disable them all. - """ - logging.debug("Setting record-related menu item sensitivity to: %s..." % sensitive) - for item_name in ["ADD_RECORD", "EDIT_RECORD", "DELETE_RECORD", "REMOVE_DUPLICATES"]: - self.items[item_name].set_sensitive(sensitive) - logging.debug("Set record-related menu item sensitivity to: %s." % sensitive) - return + mitem_addrecord = Gtk.ImageMenuItem("Add Record...") + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_ADD, Gtk.IconSize.MENU) + mitem_addrecord.set_image(icon) + mitem_addrecord.connect("activate", parent.logbook.add_record_callback) + key, mod = Gtk.accelerator_parse("R") + mitem_addrecord.add_accelerator("activate", agrp, key, mod, Gtk.AccelFlags.VISIBLE) + subm_records.append(mitem_addrecord) + self.items["ADD_RECORD"] = mitem_addrecord + mitem_editrecord = Gtk.ImageMenuItem("Edit Selected Record...") + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_EDIT, Gtk.IconSize.MENU) + mitem_editrecord.set_image(icon) + mitem_editrecord.connect("activate", parent.logbook.edit_record_callback, None, None) + key, mod = Gtk.accelerator_parse("E") + mitem_editrecord.add_accelerator("activate", agrp, key, mod, Gtk.AccelFlags.VISIBLE) + subm_records.append(mitem_editrecord) + self.items["EDIT_RECORD"] = mitem_editrecord + + mitem_deleterecord = Gtk.ImageMenuItem("Delete Selected Record...") + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_DELETE, Gtk.IconSize.MENU) + mitem_deleterecord.set_image(icon) + mitem_deleterecord.connect("activate", parent.logbook.delete_record_callback) + key, mod = Gtk.accelerator_parse("Delete") + mitem_deleterecord.add_accelerator("activate", agrp, key, mod, Gtk.AccelFlags.VISIBLE) + subm_records.append(mitem_deleterecord) + self.items["DELETE_RECORD"] = mitem_deleterecord + + mitem_removeduplicates = Gtk.ImageMenuItem("Remove Duplicate Records") + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_FIND_AND_REPLACE, Gtk.IconSize.MENU) + mitem_removeduplicates.set_image(icon) + mitem_removeduplicates.connect("activate", parent.logbook.remove_duplicates_callback) + subm_records.append(mitem_removeduplicates) + self.items["REMOVE_DUPLICATES"] = mitem_removeduplicates + + # VIEW ###### + mitem_view = Gtk.MenuItem("View") + self.append(mitem_view) + subm_view = Gtk.Menu() + mitem_view.set_submenu(subm_view) + + mitem_toolbox = Gtk.CheckMenuItem("Toolbox") + config = configparser.ConfigParser() + have_config = (config.read(os.path.expanduser('~/.config/pyqso/preferences.ini')) != []) + (section, option) = ("general", "show_toolbox") + if(have_config and config.has_option(section, option)): + mitem_toolbox.set_active(config.get(section, option) == "True") + else: + mitem_toolbox.set_active(False) # Don't show the toolbox by default + mitem_toolbox.connect("activate", parent.toolbox.toggle_visible_callback) + subm_view.append(mitem_toolbox) + self.items["TOOLBOX"] = mitem_toolbox + + mitem_preferences = Gtk.ImageMenuItem("Preferences...") + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_PREFERENCES, Gtk.IconSize.MENU) + mitem_preferences.set_image(icon) + mitem_preferences.connect("activate", parent.show_preferences) + subm_view.append(mitem_preferences) + self.items["PREFERENCES"] = mitem_preferences + + # HELP ###### + mitem_help = Gtk.MenuItem("Help") + self.append(mitem_help) + subm_help = Gtk.Menu() + mitem_help.set_submenu(subm_help) + + # About + mitem_about = Gtk.ImageMenuItem("About PyQSO") + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_ABOUT, Gtk.IconSize.MENU) + mitem_about.set_image(icon) + mitem_about.connect("activate", parent.show_about) + subm_help.append(mitem_about) + + self.set_logbook_item_sensitive(True) + self.set_log_items_sensitive(False) + self.set_record_items_sensitive(False) + + logging.debug("Menu bar ready!") + + return + + def set_logbook_item_sensitive(self, sensitive): + """ Enable/disable logbook-related menu items. + + :arg bool sensitive: If True, enable the 'new logbook' and 'open logbook' menu items. If False, disable them. + """ + logging.debug("Setting the 'Create/Open Logbook' menu item's sensitivity to: %s..." % sensitive) + self.items["NEW_LOGBOOK"].set_sensitive(sensitive) + self.items["OPEN_LOGBOOK"].set_sensitive(sensitive) + self.items["CLOSE_LOGBOOK"].set_sensitive(not sensitive) + logging.debug("Set the 'Create/Open Logbook' menu item's sensitivity to: %s." % sensitive) + return + + def set_log_items_sensitive(self, sensitive): + """ Enable/disable log-related menu items. + + :arg bool sensitive: If True, enable all the log-related menu items. If False, disable them all. + """ + logging.debug("Setting log-related menu item sensitivity to: %s..." % sensitive) + for item_name in ["NEW_LOG", "DELETE_LOG", "RENAME_LOG", "IMPORT_LOG", "EXPORT_LOG", "PRINT_LOG"]: + self.items[item_name].set_sensitive(sensitive) + logging.debug("Set log-related menu item sensitivity to: %s." % sensitive) + return + + def set_record_items_sensitive(self, sensitive): + """ Enable/disable record-related menu items. + + :arg bool sensitive: If True, enable all the record-related menu items. If False, disable them all. + """ + logging.debug("Setting record-related menu item sensitivity to: %s..." % sensitive) + for item_name in ["ADD_RECORD", "EDIT_RECORD", "DELETE_RECORD", "REMOVE_DUPLICATES"]: + self.items[item_name].set_sensitive(sensitive) + logging.debug("Set record-related menu item sensitivity to: %s." % sensitive) + return diff --git a/pyqso/preferences_dialog.py b/pyqso/preferences_dialog.py index 4d2bf7e..7e148ae 100644 --- a/pyqso/preferences_dialog.py +++ b/pyqso/preferences_dialog.py @@ -23,542 +23,551 @@ import configparser import os.path import base64 try: - import Hamlib - have_hamlib = True + import Hamlib + have_hamlib = True except ImportError: - logging.warning("Could not import the Hamlib module!") - have_hamlib = False + logging.warning("Could not import the Hamlib module!") + have_hamlib = False from pyqso.adif import * PREFERENCES_FILE = os.path.expanduser("~/.config/pyqso/preferences.ini") + class PreferencesDialog(Gtk.Dialog): - """ A dialog to specify the PyQSO preferences. """ - def __init__(self, parent): - """ Set up the various pages of the preferences dialog. """ + """ A dialog to specify the PyQSO preferences. """ - logging.debug("Setting up the preferences dialog...") + def __init__(self, parent): + """ Set up the various pages of the preferences dialog. """ - Gtk.Dialog.__init__(self, title="Preferences", parent=parent, flags=Gtk.DialogFlags.DESTROY_WITH_PARENT, buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OK, Gtk.ResponseType.OK)) + logging.debug("Setting up the preferences dialog...") - self.preferences = Gtk.Notebook() + Gtk.Dialog.__init__(self, title="Preferences", parent=parent, flags=Gtk.DialogFlags.DESTROY_WITH_PARENT, buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OK, Gtk.ResponseType.OK)) - self.general = GeneralPage() - self.preferences.insert_page(self.general, Gtk.Label("General"), 0) + self.preferences = Gtk.Notebook() - self.view = ViewPage() - self.preferences.insert_page(self.view, Gtk.Label("View"), 1) + self.general = GeneralPage() + self.preferences.insert_page(self.general, Gtk.Label("General"), 0) - self.hamlib = HamlibPage() - self.preferences.insert_page(self.hamlib, Gtk.Label("Hamlib"), 2) + self.view = ViewPage() + self.preferences.insert_page(self.view, Gtk.Label("View"), 1) - self.records = RecordsPage() - self.preferences.insert_page(self.records, Gtk.Label("Records"), 2) + self.hamlib = HamlibPage() + self.preferences.insert_page(self.hamlib, Gtk.Label("Hamlib"), 2) - self.adif = ADIFPage() - self.preferences.insert_page(self.adif, Gtk.Label("ADIF"), 2) - - self.vbox.pack_start(self.preferences, True, True, 2) - self.show_all() + self.records = RecordsPage() + self.preferences.insert_page(self.records, Gtk.Label("Records"), 2) - logging.debug("Preferences dialog ready!") + self.adif = ADIFPage() + self.preferences.insert_page(self.adif, Gtk.Label("ADIF"), 2) - return + self.vbox.pack_start(self.preferences, True, True, 2) + self.show_all() - def commit(self): - """ Commit the user preferences to the configuration file. """ + logging.debug("Preferences dialog ready!") - logging.debug("Committing the user preferences to the configuration file...") - general_data = self.general.get_data() - view_data = self.view.get_data() - hamlib_data = self.hamlib.get_data() - records_data = self.records.get_data() - adif_data = self.adif.get_data() + return - config = configparser.ConfigParser() + def commit(self): + """ Commit the user preferences to the configuration file. """ - # General - config.add_section("general") - for key in list(general_data.keys()): - config.set("general", key.lower(), str(general_data[key])) + logging.debug("Committing the user preferences to the configuration file...") + general_data = self.general.get_data() + view_data = self.view.get_data() + hamlib_data = self.hamlib.get_data() + records_data = self.records.get_data() + adif_data = self.adif.get_data() - # View - config.add_section("view") - for key in list(view_data.keys()): - config.set("view", key.lower(), str(view_data[key])) + config = configparser.ConfigParser() - # ADIF - config.add_section("adif") - for key in list(adif_data.keys()): - config.set("adif", key.lower(), str(adif_data[key])) - - # Hamlib - config.add_section("hamlib") - for key in list(hamlib_data.keys()): - config.set("hamlib", key.lower(), str(hamlib_data[key])) - - # Records - config.add_section("records") - for key in list(records_data.keys()): - config.set("records", key.lower(), str(records_data[key])) + # General + config.add_section("general") + for key in list(general_data.keys()): + config.set("general", key.lower(), str(general_data[key])) - # Write the preferences to file. - with open(os.path.expanduser(PREFERENCES_FILE), 'w') as f: - config.write(f) + # View + config.add_section("view") + for key in list(view_data.keys()): + config.set("view", key.lower(), str(view_data[key])) + + # ADIF + config.add_section("adif") + for key in list(adif_data.keys()): + config.set("adif", key.lower(), str(adif_data[key])) + + # Hamlib + config.add_section("hamlib") + for key in list(hamlib_data.keys()): + config.set("hamlib", key.lower(), str(hamlib_data[key])) + + # Records + config.add_section("records") + for key in list(records_data.keys()): + config.set("records", key.lower(), str(records_data[key])) + + # Write the preferences to file. + with open(os.path.expanduser(PREFERENCES_FILE), 'w') as f: + config.write(f) + + return - return class GeneralPage(Gtk.VBox): - """ The section of the preferences dialog containing general preferences. """ - def __init__(self): - logging.debug("Setting up the General page of the preferences dialog...") + """ The section of the preferences dialog containing general preferences. """ - Gtk.VBox.__init__(self, spacing=2) + def __init__(self): + logging.debug("Setting up the General page of the preferences dialog...") - # Remember that the have_config conditional in the PyQSO class may be out-of-date the next time the user opens up the preferences dialog - # because a configuration file may have been created after launching the application. Let's check to see if one exists again... - config = configparser.ConfigParser() - have_config = (config.read(PREFERENCES_FILE) != []) + Gtk.VBox.__init__(self, spacing=2) - self.sources = {} + # Remember that the have_config conditional in the PyQSO class may be out-of-date the next time the user opens up the preferences dialog + # because a configuration file may have been created after launching the application. Let's check to see if one exists again... + config = configparser.ConfigParser() + have_config = (config.read(PREFERENCES_FILE) != []) - # Startup - frame = Gtk.Frame() - frame.set_label("Startup") - - vbox = Gtk.VBox() - - # Show toolbox - hbox = Gtk.HBox() - self.sources["SHOW_TOOLBOX"] = Gtk.CheckButton("Show toolbox by default") - (section, option) = ("general", "show_toolbox") - if(have_config and config.has_option(section, option)): - self.sources["SHOW_TOOLBOX"].set_active(config.get(section, option) == "True") - else: - self.sources["SHOW_TOOLBOX"].set_active(False) - hbox.pack_start(self.sources["SHOW_TOOLBOX"], False, False, 2) - vbox.pack_start(hbox, False, False, 2) - - # Show statistics - hbox = Gtk.HBox() - self.sources["SHOW_YEARLY_STATISTICS"] = Gtk.CheckButton("Show yearly logbook statistics on the Summary page") - (section, option) = ("general", "show_yearly_statistics") - if(have_config and config.has_option(section, option)): - self.sources["SHOW_YEARLY_STATISTICS"].set_active(config.get(section, option) == "True") - else: - self.sources["SHOW_YEARLY_STATISTICS"].set_active(False) - hbox.pack_start(self.sources["SHOW_YEARLY_STATISTICS"], False, False, 2) - vbox.pack_start(hbox, False, False, 2) - - frame.add(vbox) - self.pack_start(frame, False, False, 2) - - # Dialogs - frame = Gtk.Frame() - frame.set_label("Dialogs") - - vbox = Gtk.VBox() - - # Keep 'Add Record' dialog open - hbox = Gtk.HBox() - self.sources["KEEP_OPEN"] = Gtk.CheckButton("Keep the Add Record dialog open") - (section, option) = ("general", "keep_open") - if(have_config and config.has_option(section, option)): - self.sources["KEEP_OPEN"].set_active(config.get(section, option) == "True") - else: - self.sources["KEEP_OPEN"].set_active(False) - hbox.pack_start(self.sources["KEEP_OPEN"], False, False, 2) - vbox.pack_start(hbox, False, False, 2) - - frame.add(vbox) - self.pack_start(frame, False, False, 2) + self.sources = {} - logging.debug("General page of the preferences dialog ready!") - return + # Startup + frame = Gtk.Frame() + frame.set_label("Startup") + + vbox = Gtk.VBox() + + # Show toolbox + hbox = Gtk.HBox() + self.sources["SHOW_TOOLBOX"] = Gtk.CheckButton("Show toolbox by default") + (section, option) = ("general", "show_toolbox") + if(have_config and config.has_option(section, option)): + self.sources["SHOW_TOOLBOX"].set_active(config.get(section, option) == "True") + else: + self.sources["SHOW_TOOLBOX"].set_active(False) + hbox.pack_start(self.sources["SHOW_TOOLBOX"], False, False, 2) + vbox.pack_start(hbox, False, False, 2) + + # Show statistics + hbox = Gtk.HBox() + self.sources["SHOW_YEARLY_STATISTICS"] = Gtk.CheckButton("Show yearly logbook statistics on the Summary page") + (section, option) = ("general", "show_yearly_statistics") + if(have_config and config.has_option(section, option)): + self.sources["SHOW_YEARLY_STATISTICS"].set_active(config.get(section, option) == "True") + else: + self.sources["SHOW_YEARLY_STATISTICS"].set_active(False) + hbox.pack_start(self.sources["SHOW_YEARLY_STATISTICS"], False, False, 2) + vbox.pack_start(hbox, False, False, 2) + + frame.add(vbox) + self.pack_start(frame, False, False, 2) + + # Dialogs + frame = Gtk.Frame() + frame.set_label("Dialogs") + + vbox = Gtk.VBox() + + # Keep 'Add Record' dialog open + hbox = Gtk.HBox() + self.sources["KEEP_OPEN"] = Gtk.CheckButton("Keep the Add Record dialog open") + (section, option) = ("general", "keep_open") + if(have_config and config.has_option(section, option)): + self.sources["KEEP_OPEN"].set_active(config.get(section, option) == "True") + else: + self.sources["KEEP_OPEN"].set_active(False) + hbox.pack_start(self.sources["KEEP_OPEN"], False, False, 2) + vbox.pack_start(hbox, False, False, 2) + + frame.add(vbox) + self.pack_start(frame, False, False, 2) + + logging.debug("General page of the preferences dialog ready!") + return + + def get_data(self): + logging.debug("Retrieving data from the General page of the preferences dialog...") + data = {} + data["SHOW_TOOLBOX"] = self.sources["SHOW_TOOLBOX"].get_active() + data["SHOW_YEARLY_STATISTICS"] = self.sources["SHOW_YEARLY_STATISTICS"].get_active() + data["KEEP_OPEN"] = self.sources["KEEP_OPEN"].get_active() + return data - def get_data(self): - logging.debug("Retrieving data from the General page of the preferences dialog...") - data = {} - data["SHOW_TOOLBOX"] = self.sources["SHOW_TOOLBOX"].get_active() - data["SHOW_YEARLY_STATISTICS"] = self.sources["SHOW_YEARLY_STATISTICS"].get_active() - data["KEEP_OPEN"] = self.sources["KEEP_OPEN"].get_active() - return data class ViewPage(Gtk.VBox): - """ The section of the preferences dialog containing view-related preferences. """ - def __init__(self): - logging.debug("Setting up the View page of the preferences dialog...") + """ The section of the preferences dialog containing view-related preferences. """ - Gtk.VBox.__init__(self, spacing=2) + def __init__(self): + logging.debug("Setting up the View page of the preferences dialog...") - config = configparser.ConfigParser() - have_config = (config.read(PREFERENCES_FILE) != []) + Gtk.VBox.__init__(self, spacing=2) - self.sources = {} + config = configparser.ConfigParser() + have_config = (config.read(PREFERENCES_FILE) != []) - # Visible fields frame - frame = Gtk.Frame() - frame.set_label("Visible fields") + self.sources = {} - # Divide the list of available field names up into multiple columns (of maximum length 'max_buttons_per_column') - # so we don't make the Preferences dialog too long. - hbox = Gtk.HBox(spacing=2) - max_buttons_per_column = 6 - number_of_columns = int( len(AVAILABLE_FIELD_NAMES_ORDERED)/max_buttons_per_column ) + 1 # Number of check buttons per column - for i in range(0, number_of_columns): - vbox = Gtk.VBox(spacing=2) - for j in range(0, max_buttons_per_column): - if(i*max_buttons_per_column + j >= len(AVAILABLE_FIELD_NAMES_ORDERED)): - break - field_name = AVAILABLE_FIELD_NAMES_ORDERED[i*max_buttons_per_column + j] - button = Gtk.CheckButton(AVAILABLE_FIELD_NAMES_FRIENDLY[field_name ]) - if(have_config and config.has_option("view", field_name.lower())): - button.set_active(config.get("view", field_name.lower()) == "True") - else: - button.set_active(True) - self.sources[field_name] = button - vbox.pack_start(button, False, False, 2) - hbox.pack_start(vbox, False, False, 2) - frame.add(hbox) - self.pack_start(frame, False, False, 2) + # Visible fields frame + frame = Gtk.Frame() + frame.set_label("Visible fields") - self.label = Gtk.Label("Note: View-related changes will not take effect\nuntil PyQSO is restarted.") - self.pack_start(self.label, False, False, 2) + # Divide the list of available field names up into multiple columns (of maximum length 'max_buttons_per_column') + # so we don't make the Preferences dialog too long. + hbox = Gtk.HBox(spacing=2) + max_buttons_per_column = 6 + number_of_columns = int(len(AVAILABLE_FIELD_NAMES_ORDERED)/max_buttons_per_column) + 1 # Number of check buttons per column + for i in range(0, number_of_columns): + vbox = Gtk.VBox(spacing=2) + for j in range(0, max_buttons_per_column): + if(i*max_buttons_per_column + j >= len(AVAILABLE_FIELD_NAMES_ORDERED)): + break + field_name = AVAILABLE_FIELD_NAMES_ORDERED[i*max_buttons_per_column + j] + button = Gtk.CheckButton(AVAILABLE_FIELD_NAMES_FRIENDLY[field_name]) + if(have_config and config.has_option("view", field_name.lower())): + button.set_active(config.get("view", field_name.lower()) == "True") + else: + button.set_active(True) + self.sources[field_name] = button + vbox.pack_start(button, False, False, 2) + hbox.pack_start(vbox, False, False, 2) + frame.add(hbox) + self.pack_start(frame, False, False, 2) - logging.debug("View page of the preferences dialog ready!") - return + self.label = Gtk.Label("Note: View-related changes will not take effect\nuntil PyQSO is restarted.") + self.pack_start(self.label, False, False, 2) + + logging.debug("View page of the preferences dialog ready!") + return + + def get_data(self): + logging.debug("Retrieving data from the View page of the preferences dialog...") + data = {} + for field_name in AVAILABLE_FIELD_NAMES_ORDERED: + data[field_name] = self.sources[field_name].get_active() + return data - def get_data(self): - logging.debug("Retrieving data from the View page of the preferences dialog...") - data = {} - for field_name in AVAILABLE_FIELD_NAMES_ORDERED: - data[field_name] = self.sources[field_name].get_active() - return data class HamlibPage(Gtk.VBox): - """ The section of the preferences dialog containing Hamlib-related preferences. """ - def __init__(self): - logging.debug("Setting up the Hamlib page of the preferences dialog...") + """ The section of the preferences dialog containing Hamlib-related preferences. """ - Gtk.VBox.__init__(self, spacing=2) + def __init__(self): + logging.debug("Setting up the Hamlib page of the preferences dialog...") - config = configparser.ConfigParser() - have_config = (config.read(PREFERENCES_FILE) != []) + Gtk.VBox.__init__(self, spacing=2) - self.sources = {} + config = configparser.ConfigParser() + have_config = (config.read(PREFERENCES_FILE) != []) - frame = Gtk.Frame() - frame.set_label("Hamlib support") + self.sources = {} - vbox_inner = Gtk.VBox(spacing=2) + frame = Gtk.Frame() + frame.set_label("Hamlib support") - self.sources["AUTOFILL"] = Gtk.CheckButton("Auto-fill Frequency field") - (section, option) = ("hamlib", "autofill") - if(have_config and config.has_option(section, option)): - self.sources["AUTOFILL"].set_active(config.get(section, option) == "True") - else: - self.sources["AUTOFILL"].set_active(False) - vbox_inner.pack_start(self.sources["AUTOFILL"], False, False, 2) + vbox_inner = Gtk.VBox(spacing=2) - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label("Model: ") - label.set_alignment(0, 0.5) - label.set_width_chars(17) - hbox_temp.pack_start(label, False, False, 2) + self.sources["AUTOFILL"] = Gtk.CheckButton("Auto-fill Frequency field") + (section, option) = ("hamlib", "autofill") + if(have_config and config.has_option(section, option)): + self.sources["AUTOFILL"].set_active(config.get(section, option) == "True") + else: + self.sources["AUTOFILL"].set_active(False) + vbox_inner.pack_start(self.sources["AUTOFILL"], False, False, 2) - # Get the list of rig models - models = ["RIG_MODEL_NONE"] - if(have_hamlib): - try: - for item in dir(Hamlib): - if(item.startswith("RIG_MODEL_")): - models.append(item) - except: - logging.error("Could not obtain rig models list via Hamlib!") - else: - logging.debug("Hamlib module not present. Could not obtain a list of rig models.") + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label("Model: ") + label.set_alignment(0, 0.5) + label.set_width_chars(17) + hbox_temp.pack_start(label, False, False, 2) - self.sources["RIG_MODEL"] = Gtk.ComboBoxText() - for model in models: - self.sources["RIG_MODEL"].append_text(model) - (section, option) = ("hamlib", "rig_model") - if(have_config and config.has_option("hamlib", "rig_model")): - self.sources["RIG_MODEL"].set_active(models.index(config.get("hamlib", "rig_model"))) - else: - self.sources["RIG_MODEL"].set_active(models.index("RIG_MODEL_NONE")) # Set to RIG_MODEL_NONE as the default option. - hbox_temp.pack_start(self.sources["RIG_MODEL"], True, True, 2) - vbox_inner.pack_start(hbox_temp, False, False, 2) + # Get the list of rig models + models = ["RIG_MODEL_NONE"] + if(have_hamlib): + try: + for item in dir(Hamlib): + if(item.startswith("RIG_MODEL_")): + models.append(item) + except: + logging.error("Could not obtain rig models list via Hamlib!") + else: + logging.debug("Hamlib module not present. Could not obtain a list of rig models.") - # Path to rig - hbox_temp = Gtk.HBox() - label = Gtk.Label("Path to radio device: ") - label.set_width_chars(17) - label.set_alignment(0, 0.5) - hbox_temp.pack_start(label, False, False, 2) - self.sources["RIG_PATHNAME"] = Gtk.Entry() - (section, option) = ("hamlib", "rig_pathname") - if(have_config and config.has_option(section, option)): - self.sources["RIG_PATHNAME"].set_text(config.get(section, option)) - hbox_temp.pack_start(self.sources["RIG_PATHNAME"], True, True, 2) - vbox_inner.pack_start(hbox_temp, False, False, 2) + self.sources["RIG_MODEL"] = Gtk.ComboBoxText() + for model in models: + self.sources["RIG_MODEL"].append_text(model) + (section, option) = ("hamlib", "rig_model") + if(have_config and config.has_option("hamlib", "rig_model")): + self.sources["RIG_MODEL"].set_active(models.index(config.get("hamlib", "rig_model"))) + else: + self.sources["RIG_MODEL"].set_active(models.index("RIG_MODEL_NONE")) # Set to RIG_MODEL_NONE as the default option. + hbox_temp.pack_start(self.sources["RIG_MODEL"], True, True, 2) + vbox_inner.pack_start(hbox_temp, False, False, 2) - frame.add(vbox_inner) - self.pack_start(frame, True, True, 2) + # Path to rig + hbox_temp = Gtk.HBox() + label = Gtk.Label("Path to radio device: ") + label.set_width_chars(17) + label.set_alignment(0, 0.5) + hbox_temp.pack_start(label, False, False, 2) + self.sources["RIG_PATHNAME"] = Gtk.Entry() + (section, option) = ("hamlib", "rig_pathname") + if(have_config and config.has_option(section, option)): + self.sources["RIG_PATHNAME"].set_text(config.get(section, option)) + hbox_temp.pack_start(self.sources["RIG_PATHNAME"], True, True, 2) + vbox_inner.pack_start(hbox_temp, False, False, 2) - logging.debug("Hamlib page of the preferences dialog ready!") - return + frame.add(vbox_inner) + self.pack_start(frame, True, True, 2) + + logging.debug("Hamlib page of the preferences dialog ready!") + return + + def get_data(self): + logging.debug("Retrieving data from the Hamlib page of the preferences dialog...") + data = {} + data["AUTOFILL"] = self.sources["AUTOFILL"].get_active() + data["RIG_PATHNAME"] = self.sources["RIG_PATHNAME"].get_text() + data["RIG_MODEL"] = self.sources["RIG_MODEL"].get_active_text() + return data - def get_data(self): - logging.debug("Retrieving data from the Hamlib page of the preferences dialog...") - data = {} - data["AUTOFILL"] = self.sources["AUTOFILL"].get_active() - data["RIG_PATHNAME"] = self.sources["RIG_PATHNAME"].get_text() - data["RIG_MODEL"] = self.sources["RIG_MODEL"].get_active_text() - return data class RecordsPage(Gtk.VBox): - """ The section of the preferences dialog containing record-related preferences. """ - def __init__(self): - logging.debug("Setting up the Records page of the preferences dialog...") + """ The section of the preferences dialog containing record-related preferences. """ - Gtk.VBox.__init__(self, spacing=2) + def __init__(self): + logging.debug("Setting up the Records page of the preferences dialog...") - # Remember that the have_config conditional in the PyQSO class may be out-of-date the next time the user opens up the preferences dialog - # because a configuration file may have been created after launching the application. Let's check to see if one exists again... - config = configparser.ConfigParser() - have_config = (config.read(PREFERENCES_FILE) != []) + Gtk.VBox.__init__(self, spacing=2) - self.sources = {} + # Remember that the have_config conditional in the PyQSO class may be out-of-date the next time the user opens up the preferences dialog + # because a configuration file may have been created after launching the application. Let's check to see if one exists again... + config = configparser.ConfigParser() + have_config = (config.read(PREFERENCES_FILE) != []) - # Autocomplete frame - frame = Gtk.Frame() - frame.set_label("Autocomplete") - vbox = Gtk.VBox() - self.sources["AUTOCOMPLETE_BAND"] = Gtk.CheckButton("Autocomplete the Band field") - (section, option) = ("records", "autocomplete_band") - if(have_config and config.has_option(section, option)): - self.sources["AUTOCOMPLETE_BAND"].set_active(config.get(section, option) == "True") - else: - self.sources["AUTOCOMPLETE_BAND"].set_active(True) - vbox.pack_start(self.sources["AUTOCOMPLETE_BAND"], False, False, 2) + self.sources = {} - self.sources["USE_UTC"] = Gtk.CheckButton("Use UTC when autocompleting the Date and Time") - (section, option) = ("records", "use_utc") - if(have_config and config.has_option(section, option)): - self.sources["USE_UTC"].set_active(config.get(section, option) == "True") - else: - self.sources["USE_UTC"].set_active(True) - vbox.pack_start(self.sources["USE_UTC"], False, False, 2) + # Autocomplete frame + frame = Gtk.Frame() + frame.set_label("Autocomplete") + vbox = Gtk.VBox() + self.sources["AUTOCOMPLETE_BAND"] = Gtk.CheckButton("Autocomplete the Band field") + (section, option) = ("records", "autocomplete_band") + if(have_config and config.has_option(section, option)): + self.sources["AUTOCOMPLETE_BAND"].set_active(config.get(section, option) == "True") + else: + self.sources["AUTOCOMPLETE_BAND"].set_active(True) + vbox.pack_start(self.sources["AUTOCOMPLETE_BAND"], False, False, 2) - frame.add(vbox) - self.pack_start(frame, False, False, 2) + self.sources["USE_UTC"] = Gtk.CheckButton("Use UTC when autocompleting the Date and Time") + (section, option) = ("records", "use_utc") + if(have_config and config.has_option(section, option)): + self.sources["USE_UTC"].set_active(config.get(section, option) == "True") + else: + self.sources["USE_UTC"].set_active(True) + vbox.pack_start(self.sources["USE_UTC"], False, False, 2) + + frame.add(vbox) + self.pack_start(frame, False, False, 2) + + # Default values frame + frame = Gtk.Frame() + frame.set_label("Default values") + vbox = Gtk.VBox() + + # Mode + hbox_temp = Gtk.HBox() + label = Gtk.Label("Mode: ") + label.set_width_chars(17) + label.set_alignment(0, 0.5) + hbox_temp.pack_start(label, False, False, 2) + + self.sources["DEFAULT_MODE"] = Gtk.ComboBoxText() + for mode in sorted(MODES.keys()): + self.sources["DEFAULT_MODE"].append_text(mode) + (section, option) = ("records", "default_mode") + if(have_config and config.has_option(section, option)): + mode = config.get(section, option) + else: + mode = "" + self.sources["DEFAULT_MODE"].set_active(sorted(MODES.keys()).index(mode)) + self.sources["DEFAULT_MODE"].connect("changed", self._on_mode_changed) + hbox_temp.pack_start(self.sources["DEFAULT_MODE"], False, False, 2) + vbox.pack_start(hbox_temp, False, False, 2) + + # Submode + hbox_temp = Gtk.HBox() + label = Gtk.Label("Submode: ") + label.set_width_chars(17) + label.set_alignment(0, 0.5) + hbox_temp.pack_start(label, False, False, 2) + + self.sources["DEFAULT_SUBMODE"] = Gtk.ComboBoxText() + for submode in MODES[mode]: + self.sources["DEFAULT_SUBMODE"].append_text(submode) + (section, option) = ("records", "default_submode") + if(have_config and config.has_option(section, option)): + submode = config.get(section, option) + else: + submode = "" + self.sources["DEFAULT_SUBMODE"].set_active(MODES[mode].index(submode)) + hbox_temp.pack_start(self.sources["DEFAULT_SUBMODE"], False, False, 2) + vbox.pack_start(hbox_temp, False, False, 2) + + # Power + hbox_temp = Gtk.HBox() + label = Gtk.Label("TX Power (W): ") + label.set_width_chars(17) + label.set_alignment(0, 0.5) + hbox_temp.pack_start(label, False, False, 2) + + self.sources["DEFAULT_POWER"] = Gtk.Entry() + (section, option) = ("records", "default_power") + if(have_config and config.has_option(section, option)): + self.sources["DEFAULT_POWER"].set_text(config.get(section, option)) + else: + self.sources["DEFAULT_POWER"].set_text("") + hbox_temp.pack_start(self.sources["DEFAULT_POWER"], False, False, 2) + vbox.pack_start(hbox_temp, False, False, 2) + + frame.add(vbox) + self.pack_start(frame, False, False, 2) + + # Callsign lookup frame + frame = Gtk.Frame() + frame.set_label("Callsign lookup") + vbox = Gtk.VBox() + + # Callsign database + hbox_temp = Gtk.HBox() + label = Gtk.Label("Database: ") + label.set_width_chars(17) + label.set_alignment(0, 0.5) + hbox_temp.pack_start(label, False, False, 2) + + self.sources["CALLSIGN_DATABASE"] = Gtk.ComboBoxText() + callsign_database = ["", "qrz.com", "hamqth.com"] + for database in callsign_database: + self.sources["CALLSIGN_DATABASE"].append_text(database) + (section, option) = ("records", "callsign_database") + if(have_config and config.has_option(section, option)): + self.sources["CALLSIGN_DATABASE"].set_active(callsign_database.index(config.get(section, option))) + else: + self.sources["CALLSIGN_DATABASE"].set_active(callsign_database.index("")) + hbox_temp.pack_start(self.sources["CALLSIGN_DATABASE"], False, False, 2) + vbox.pack_start(hbox_temp, False, False, 2) + + # Login details + subframe = Gtk.Frame() + subframe.set_label("Login details") + inner_vbox = Gtk.VBox() + + hbox = Gtk.HBox() + label = Gtk.Label("Username: ") + label.set_width_chars(9) + label.set_alignment(0, 0.5) + hbox.pack_start(label, False, False, 2) + self.sources["CALLSIGN_DATABASE_USERNAME"] = Gtk.Entry() + (section, option) = ("records", "callsign_database_username") + if(have_config and config.has_option(section, option)): + self.sources["CALLSIGN_DATABASE_USERNAME"].set_text(config.get(section, option)) + hbox.pack_start(self.sources["CALLSIGN_DATABASE_USERNAME"], False, False, 2) + inner_vbox.pack_start(hbox, False, False, 2) + + hbox = Gtk.HBox() + label = Gtk.Label("Password: ") + label.set_width_chars(9) + label.set_alignment(0, 0.5) + hbox.pack_start(label, False, False, 2) + self.sources["CALLSIGN_DATABASE_PASSWORD"] = Gtk.Entry() + self.sources["CALLSIGN_DATABASE_PASSWORD"].set_visibility(False) # Mask the password with the "*" character. + (section, option) = ("records", "callsign_database_password") + if(have_config and config.has_option(section, option)): + password = base64.b64decode(config.get(section, option)).decode("utf-8") + self.sources["CALLSIGN_DATABASE_PASSWORD"].set_text(password) + hbox.pack_start(self.sources["CALLSIGN_DATABASE_PASSWORD"], False, False, 2) + inner_vbox.pack_start(hbox, False, False, 2) + + label = Gtk.Label("Warning: Login details are currently stored as\nBase64-encoded plain text in the configuration file.") + inner_vbox.pack_start(label, False, False, 2) + + subframe.add(inner_vbox) + vbox.pack_start(subframe, False, False, 2) + + self.sources["IGNORE_PREFIX_SUFFIX"] = Gtk.CheckButton("Ignore callsign prefixes and/or suffixes") + (section, option) = ("records", "ignore_prefix_suffix") + if(have_config and config.has_option(section, option)): + self.sources["IGNORE_PREFIX_SUFFIX"].set_active(config.get(section, option) == "True") + else: + self.sources["IGNORE_PREFIX_SUFFIX"].set_active(True) + vbox.pack_start(self.sources["IGNORE_PREFIX_SUFFIX"], False, False, 2) + + frame.add(vbox) + self.pack_start(frame, False, False, 2) + + logging.debug("Records page of the preferences dialog ready!") + return + + def get_data(self): + logging.debug("Retrieving data from the Records page of the preferences dialog...") + data = {} + data["AUTOCOMPLETE_BAND"] = self.sources["AUTOCOMPLETE_BAND"].get_active() + data["USE_UTC"] = self.sources["USE_UTC"].get_active() + + data["DEFAULT_MODE"] = self.sources["DEFAULT_MODE"].get_active_text() + data["DEFAULT_SUBMODE"] = self.sources["DEFAULT_SUBMODE"].get_active_text() + data["DEFAULT_POWER"] = self.sources["DEFAULT_POWER"].get_text() + + data["CALLSIGN_DATABASE"] = self.sources["CALLSIGN_DATABASE"].get_active_text() + data["CALLSIGN_DATABASE_USERNAME"] = self.sources["CALLSIGN_DATABASE_USERNAME"].get_text() + data["CALLSIGN_DATABASE_PASSWORD"] = base64.b64encode(self.sources["CALLSIGN_DATABASE_PASSWORD"].get_text().encode("utf-8")).decode('utf-8') # Need to convert from bytes to str here. + data["IGNORE_PREFIX_SUFFIX"] = self.sources["IGNORE_PREFIX_SUFFIX"].get_active() + return data + + def _on_mode_changed(self, combo): + """ If the MODE field has changed its value, then fill the SUBMODE field with all the available SUBMODE options for that new MODE. """ + self.sources["DEFAULT_SUBMODE"].get_model().clear() + mode = combo.get_active_text() + for submode in MODES[mode]: + self.sources["DEFAULT_SUBMODE"].append_text(submode) + return - ## Default values frame - frame = Gtk.Frame() - frame.set_label("Default values") - vbox = Gtk.VBox() - - # Mode - hbox_temp = Gtk.HBox() - label = Gtk.Label("Mode: ") - label.set_width_chars(17) - label.set_alignment(0, 0.5) - hbox_temp.pack_start(label, False, False, 2) - - self.sources["DEFAULT_MODE"] = Gtk.ComboBoxText() - for mode in sorted(MODES.keys()): - self.sources["DEFAULT_MODE"].append_text(mode) - (section, option) = ("records", "default_mode") - if(have_config and config.has_option(section, option)): - mode = config.get(section, option) - else: - mode = "" - self.sources["DEFAULT_MODE"].set_active(sorted(MODES.keys()).index(mode)) - self.sources["DEFAULT_MODE"].connect("changed", self._on_mode_changed) - hbox_temp.pack_start(self.sources["DEFAULT_MODE"], False, False, 2) - vbox.pack_start(hbox_temp, False, False, 2) - - # Submode - hbox_temp = Gtk.HBox() - label = Gtk.Label("Submode: ") - label.set_width_chars(17) - label.set_alignment(0, 0.5) - hbox_temp.pack_start(label, False, False, 2) - - self.sources["DEFAULT_SUBMODE"] = Gtk.ComboBoxText() - for submode in MODES[mode]: - self.sources["DEFAULT_SUBMODE"].append_text(submode) - (section, option) = ("records", "default_submode") - if(have_config and config.has_option(section, option)): - submode = config.get(section, option) - else: - submode = "" - self.sources["DEFAULT_SUBMODE"].set_active(MODES[mode].index(submode)) - hbox_temp.pack_start(self.sources["DEFAULT_SUBMODE"], False, False, 2) - vbox.pack_start(hbox_temp, False, False, 2) - - # Power - hbox_temp = Gtk.HBox() - label = Gtk.Label("TX Power (W): ") - label.set_width_chars(17) - label.set_alignment(0, 0.5) - hbox_temp.pack_start(label, False, False, 2) - - self.sources["DEFAULT_POWER"] = Gtk.Entry() - (section, option) = ("records", "default_power") - if(have_config and config.has_option(section, option)): - self.sources["DEFAULT_POWER"].set_text(config.get(section, option)) - else: - self.sources["DEFAULT_POWER"].set_text("") - hbox_temp.pack_start(self.sources["DEFAULT_POWER"], False, False, 2) - vbox.pack_start(hbox_temp, False, False, 2) - - frame.add(vbox) - self.pack_start(frame, False, False, 2) - - - # Callsign lookup frame - frame = Gtk.Frame() - frame.set_label("Callsign lookup") - vbox = Gtk.VBox() - - # Callsign database - hbox_temp = Gtk.HBox() - label = Gtk.Label("Database: ") - label.set_width_chars(17) - label.set_alignment(0, 0.5) - hbox_temp.pack_start(label, False, False, 2) - - self.sources["CALLSIGN_DATABASE"] = Gtk.ComboBoxText() - callsign_database = ["", "qrz.com", "hamqth.com"] - for database in callsign_database: - self.sources["CALLSIGN_DATABASE"].append_text(database) - (section, option) = ("records", "callsign_database") - if(have_config and config.has_option(section, option)): - self.sources["CALLSIGN_DATABASE"].set_active(callsign_database.index(config.get(section, option))) - else: - self.sources["CALLSIGN_DATABASE"].set_active(callsign_database.index("")) - hbox_temp.pack_start(self.sources["CALLSIGN_DATABASE"], False, False, 2) - vbox.pack_start(hbox_temp, False, False, 2) - - # Login details - subframe = Gtk.Frame() - subframe.set_label("Login details") - inner_vbox = Gtk.VBox() - - hbox = Gtk.HBox() - label = Gtk.Label("Username: ") - label.set_width_chars(9) - label.set_alignment(0, 0.5) - hbox.pack_start(label, False, False, 2) - self.sources["CALLSIGN_DATABASE_USERNAME"] = Gtk.Entry() - (section, option) = ("records", "callsign_database_username") - if(have_config and config.has_option(section, option)): - self.sources["CALLSIGN_DATABASE_USERNAME"].set_text(config.get(section, option)) - hbox.pack_start(self.sources["CALLSIGN_DATABASE_USERNAME"], False, False, 2) - inner_vbox.pack_start(hbox, False, False, 2) - - hbox = Gtk.HBox() - label = Gtk.Label("Password: ") - label.set_width_chars(9) - label.set_alignment(0, 0.5) - hbox.pack_start(label, False, False, 2) - self.sources["CALLSIGN_DATABASE_PASSWORD"] = Gtk.Entry() - self.sources["CALLSIGN_DATABASE_PASSWORD"].set_visibility(False) # Mask the password with the "*" character. - (section, option) = ("records", "callsign_database_password") - if(have_config and config.has_option(section, option)): - password = base64.b64decode(config.get(section, option)).decode("utf-8") - self.sources["CALLSIGN_DATABASE_PASSWORD"].set_text(password) - hbox.pack_start(self.sources["CALLSIGN_DATABASE_PASSWORD"], False, False, 2) - inner_vbox.pack_start(hbox, False, False, 2) - - label = Gtk.Label("Warning: Login details are currently stored as\nBase64-encoded plain text in the configuration file.") - inner_vbox.pack_start(label, False, False, 2) - - subframe.add(inner_vbox) - vbox.pack_start(subframe, False, False, 2) - - self.sources["IGNORE_PREFIX_SUFFIX"] = Gtk.CheckButton("Ignore callsign prefixes and/or suffixes") - (section, option) = ("records", "ignore_prefix_suffix") - if(have_config and config.has_option(section, option)): - self.sources["IGNORE_PREFIX_SUFFIX"].set_active(config.get(section, option) == "True") - else: - self.sources["IGNORE_PREFIX_SUFFIX"].set_active(True) - vbox.pack_start(self.sources["IGNORE_PREFIX_SUFFIX"], False, False, 2) - - frame.add(vbox) - self.pack_start(frame, False, False, 2) - - logging.debug("Records page of the preferences dialog ready!") - return - - def get_data(self): - logging.debug("Retrieving data from the Records page of the preferences dialog...") - data = {} - data["AUTOCOMPLETE_BAND"] = self.sources["AUTOCOMPLETE_BAND"].get_active() - data["USE_UTC"] = self.sources["USE_UTC"].get_active() - - data["DEFAULT_MODE"] = self.sources["DEFAULT_MODE"].get_active_text() - data["DEFAULT_SUBMODE"] = self.sources["DEFAULT_SUBMODE"].get_active_text() - data["DEFAULT_POWER"] = self.sources["DEFAULT_POWER"].get_text() - - data["CALLSIGN_DATABASE"] = self.sources["CALLSIGN_DATABASE"].get_active_text() - data["CALLSIGN_DATABASE_USERNAME"] = self.sources["CALLSIGN_DATABASE_USERNAME"].get_text() - data["CALLSIGN_DATABASE_PASSWORD"] = base64.b64encode(self.sources["CALLSIGN_DATABASE_PASSWORD"].get_text().encode("utf-8")).decode('utf-8') # Need to convert from bytes to str here. - data["IGNORE_PREFIX_SUFFIX"] = self.sources["IGNORE_PREFIX_SUFFIX"].get_active() - return data - - def _on_mode_changed(self, combo): - """ If the MODE field has changed its value, then fill the SUBMODE field with all the available SUBMODE options for that new MODE. """ - self.sources["DEFAULT_SUBMODE"].get_model().clear() - mode = combo.get_active_text() - for submode in MODES[mode]: - self.sources["DEFAULT_SUBMODE"].append_text(submode) - return - class ADIFPage(Gtk.VBox): - """ The section of the preferences dialog containing ADIF-related preferences. """ - - def __init__(self): - logging.debug("Setting up the ADIF page of the preferences dialog...") - Gtk.VBox.__init__(self, spacing=2) + """ The section of the preferences dialog containing ADIF-related preferences. """ - # Remember that the have_config conditional in the PyQSO class may be out-of-date the next time the user opens up the preferences dialog - # because a configuration file may have been created after launching the application. Let's check to see if one exists again... - config = configparser.ConfigParser() - have_config = (config.read(PREFERENCES_FILE) != []) + def __init__(self): + logging.debug("Setting up the ADIF page of the preferences dialog...") - self.sources = {} + Gtk.VBox.__init__(self, spacing=2) - # Import frame - frame = Gtk.Frame() - frame.set_label("Import") - vbox = Gtk.VBox() - self.sources["MERGE_COMMENT"] = Gtk.CheckButton("Merge any text in the COMMENT field with the NOTES field.") - (section, option) = ("adif", "merge_comment") - if(have_config and config.has_option(section, option)): - self.sources["MERGE_COMMENT"].set_active(config.get(section, option) == "True") - else: - self.sources["MERGE_COMMENT"].set_active(False) - vbox.pack_start(self.sources["MERGE_COMMENT"], False, False, 2) + # Remember that the have_config conditional in the PyQSO class may be out-of-date the next time the user opens up the preferences dialog + # because a configuration file may have been created after launching the application. Let's check to see if one exists again... + config = configparser.ConfigParser() + have_config = (config.read(PREFERENCES_FILE) != []) - frame.add(vbox) - self.pack_start(frame, False, False, 2) - - logging.debug("ADIF page of the preferences dialog ready!") - return + self.sources = {} - def get_data(self): - logging.debug("Retrieving data from the ADIF page of the preferences dialog...") - data = {} - data["MERGE_COMMENT"] = self.sources["MERGE_COMMENT"].get_active() - return data + # Import frame + frame = Gtk.Frame() + frame.set_label("Import") + vbox = Gtk.VBox() + self.sources["MERGE_COMMENT"] = Gtk.CheckButton("Merge any text in the COMMENT field with the NOTES field.") + (section, option) = ("adif", "merge_comment") + if(have_config and config.has_option(section, option)): + self.sources["MERGE_COMMENT"].set_active(config.get(section, option) == "True") + else: + self.sources["MERGE_COMMENT"].set_active(False) + vbox.pack_start(self.sources["MERGE_COMMENT"], False, False, 2) + frame.add(vbox) + self.pack_start(frame, False, False, 2) + + logging.debug("ADIF page of the preferences dialog ready!") + return + + def get_data(self): + logging.debug("Retrieving data from the ADIF page of the preferences dialog...") + data = {} + data["MERGE_COMMENT"] = self.sources["MERGE_COMMENT"].get_active() + return data diff --git a/pyqso/record_dialog.py b/pyqso/record_dialog.py index e077259..5c718b6 100644 --- a/pyqso/record_dialog.py +++ b/pyqso/record_dialog.py @@ -24,619 +24,620 @@ from datetime import datetime from os.path import expanduser import base64 try: - import Hamlib - have_hamlib = True + import Hamlib + have_hamlib = True except ImportError: - logging.warning("Could not import the Hamlib module!") - have_hamlib = False + logging.warning("Could not import the Hamlib module!") + have_hamlib = False from pyqso.adif import * from pyqso.callsign_lookup import * from pyqso.auxiliary_dialogs import * + class RecordDialog(Gtk.Dialog): - """ A dialog through which users can enter information about a QSO/record. """ - def __init__(self, parent, log, index=None): - """ Set up the layout of the record dialog, populate the various fields with the QSO details (if the record already exists), and show the dialog to the user. - - :arg parent: The parent Gtk window. - :arg log: The log to which the record belongs (or will belong). - :arg int index: If specified, then the dialog turns into 'edit record mode' and fills the data sources (e.g. the Gtk.Entry boxes) with the existing data in the log. If not specified (i.e. index is None), then the dialog starts off with nothing in the data sources. - """ + """ A dialog through which users can enter information about a QSO/record. """ - logging.debug("Setting up the record dialog...") - - if(index is not None): - title = "Edit Record %d" % index - else: - title = "Add Record" - Gtk.Dialog.__init__(self, title=title, parent=parent, flags=Gtk.DialogFlags.DESTROY_WITH_PARENT, buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OK, Gtk.ResponseType.OK)) + def __init__(self, parent, log, index=None): + """ Set up the layout of the record dialog, populate the various fields with the QSO details (if the record already exists), and show the dialog to the user. - # Check if a configuration file is present, since we might need it to set up the rest of the dialog. - config = configparser.ConfigParser() - have_config = (config.read(expanduser('~/.config/pyqso/preferences.ini')) != []) - - ## QSO DATA FRAME - qso_frame = Gtk.Frame() - qso_frame.set_label("QSO Information") - self.vbox.add(qso_frame) + :arg parent: The parent Gtk window. + :arg log: The log to which the record belongs (or will belong). + :arg int index: If specified, then the dialog turns into 'edit record mode' and fills the data sources (e.g. the Gtk.Entry boxes) with the existing data in the log. If not specified (i.e. index is None), then the dialog starts off with nothing in the data sources. + """ - hbox_inner = Gtk.HBox(spacing=2) + logging.debug("Setting up the record dialog...") - vbox_inner = Gtk.VBox(spacing=2) - hbox_inner.pack_start(vbox_inner, True, True, 2) + if(index is not None): + title = "Edit Record %d" % index + else: + title = "Add Record" + Gtk.Dialog.__init__(self, title=title, parent=parent, flags=Gtk.DialogFlags.DESTROY_WITH_PARENT, buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OK, Gtk.ResponseType.OK)) - # Create label:entry pairs and store them in a dictionary - self.sources = {} + # Check if a configuration file is present, since we might need it to set up the rest of the dialog. + config = configparser.ConfigParser() + have_config = (config.read(expanduser('~/.config/pyqso/preferences.ini')) != []) - # CALL - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["CALL"], halign=Gtk.Align.START) - label.set_width_chars(15) - label.set_alignment(0, 0.5) - hbox_temp.pack_start(label, False, False, 2) - self.sources["CALL"] = Gtk.Entry() - self.sources["CALL"].set_width_chars(15) - hbox_temp.pack_start(self.sources["CALL"], False, False, 2) - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_INFO, Gtk.IconSize.MENU) - button = Gtk.Button() - button.add(icon) - button.connect("clicked", self.lookup_callback) # Looks up the callsign using an online database, for callsign and station information. - button.set_tooltip_text("Callsign lookup") - hbox_temp.pack_start(button, True, True, 2) - vbox_inner.pack_start(hbox_temp, False, False, 2) + # QSO DATA FRAME + qso_frame = Gtk.Frame() + qso_frame.set_label("QSO Information") + self.vbox.add(qso_frame) - # DATE - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["QSO_DATE"], halign=Gtk.Align.START) - label.set_width_chars(15) - label.set_alignment(0, 0.5) - hbox_temp.pack_start(label, False, False, 2) - self.sources["QSO_DATE"] = Gtk.Entry() - self.sources["QSO_DATE"].set_width_chars(15) - hbox_temp.pack_start(self.sources["QSO_DATE"], False, False, 2) - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_GO_BACK, Gtk.IconSize.MENU) - button = Gtk.Button() - button.add(icon) - button.connect("clicked", self.calendar_callback) - button.set_tooltip_text("Select date from calendar") - hbox_temp.pack_start(button, True, True, 2) - vbox_inner.pack_start(hbox_temp, False, False, 2) + hbox_inner = Gtk.HBox(spacing=2) - # TIME - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["TIME_ON"], halign=Gtk.Align.START) - label.set_alignment(0, 0.5) - label.set_width_chars(15) - hbox_temp.pack_start(label, False, False, 2) - self.sources["TIME_ON"] = Gtk.Entry() - self.sources["TIME_ON"].set_width_chars(15) - hbox_temp.pack_start(self.sources["TIME_ON"], False, False, 2) - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_MEDIA_PLAY, Gtk.IconSize.MENU) - button = Gtk.Button() - button.add(icon) - button.connect("clicked", self.set_current_datetime_callback) - button.set_tooltip_text("Use the current time and date") - hbox_temp.pack_start(button, True, True, 2) - vbox_inner.pack_start(hbox_temp, False, False, 2) + vbox_inner = Gtk.VBox(spacing=2) + hbox_inner.pack_start(vbox_inner, True, True, 2) - # FREQ - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["FREQ"], halign=Gtk.Align.START) - label.set_alignment(0, 0.5) - label.set_width_chars(15) - hbox_temp.pack_start(label, False, False, 2) - self.sources["FREQ"] = Gtk.Entry() - self.sources["FREQ"].set_width_chars(15) - hbox_temp.pack_start(self.sources["FREQ"], False, False, 2) - vbox_inner.pack_start(hbox_temp, False, False, 2) + # Create label:entry pairs and store them in a dictionary + self.sources = {} - # BAND - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["BAND"], halign=Gtk.Align.START) - label.set_alignment(0, 0.5) - label.set_width_chars(15) - hbox_temp.pack_start(label, False, False, 2) + # CALL + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["CALL"], halign=Gtk.Align.START) + label.set_width_chars(15) + label.set_alignment(0, 0.5) + hbox_temp.pack_start(label, False, False, 2) + self.sources["CALL"] = Gtk.Entry() + self.sources["CALL"].set_width_chars(15) + hbox_temp.pack_start(self.sources["CALL"], False, False, 2) + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_INFO, Gtk.IconSize.MENU) + button = Gtk.Button() + button.add(icon) + button.connect("clicked", self.lookup_callback) # Looks up the callsign using an online database, for callsign and station information. + button.set_tooltip_text("Callsign lookup") + hbox_temp.pack_start(button, True, True, 2) + vbox_inner.pack_start(hbox_temp, False, False, 2) - self.sources["BAND"] = Gtk.ComboBoxText() - for band in BANDS: - self.sources["BAND"].append_text(band) - self.sources["BAND"].set_active(0) # Set an empty string as the default option. - hbox_temp.pack_start(self.sources["BAND"], False, False, 2) - vbox_inner.pack_start(hbox_temp, False, False, 2) + # DATE + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["QSO_DATE"], halign=Gtk.Align.START) + label.set_width_chars(15) + label.set_alignment(0, 0.5) + hbox_temp.pack_start(label, False, False, 2) + self.sources["QSO_DATE"] = Gtk.Entry() + self.sources["QSO_DATE"].set_width_chars(15) + hbox_temp.pack_start(self.sources["QSO_DATE"], False, False, 2) + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_GO_BACK, Gtk.IconSize.MENU) + button = Gtk.Button() + button.add(icon) + button.connect("clicked", self.calendar_callback) + button.set_tooltip_text("Select date from calendar") + hbox_temp.pack_start(button, True, True, 2) + vbox_inner.pack_start(hbox_temp, False, False, 2) - # MODE - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["MODE"]) - label.set_alignment(0, 0.5) - label.set_width_chars(15) - hbox_temp.pack_start(label, False, False, 2) + # TIME + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["TIME_ON"], halign=Gtk.Align.START) + label.set_alignment(0, 0.5) + label.set_width_chars(15) + hbox_temp.pack_start(label, False, False, 2) + self.sources["TIME_ON"] = Gtk.Entry() + self.sources["TIME_ON"].set_width_chars(15) + hbox_temp.pack_start(self.sources["TIME_ON"], False, False, 2) + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_MEDIA_PLAY, Gtk.IconSize.MENU) + button = Gtk.Button() + button.add(icon) + button.connect("clicked", self.set_current_datetime_callback) + button.set_tooltip_text("Use the current time and date") + hbox_temp.pack_start(button, True, True, 2) + vbox_inner.pack_start(hbox_temp, False, False, 2) - self.sources["MODE"] = Gtk.ComboBoxText() - for mode in sorted(MODES.keys()): - self.sources["MODE"].append_text(mode) - self.sources["MODE"].set_active(0) # Set an empty string as the default option. - self.sources["MODE"].connect("changed", self._on_mode_changed) - hbox_temp.pack_start(self.sources["MODE"], False, False, 2) - vbox_inner.pack_start(hbox_temp, False, False, 2) + # FREQ + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["FREQ"], halign=Gtk.Align.START) + label.set_alignment(0, 0.5) + label.set_width_chars(15) + hbox_temp.pack_start(label, False, False, 2) + self.sources["FREQ"] = Gtk.Entry() + self.sources["FREQ"].set_width_chars(15) + hbox_temp.pack_start(self.sources["FREQ"], False, False, 2) + vbox_inner.pack_start(hbox_temp, False, False, 2) - # SUBMODE - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["SUBMODE"]) - label.set_alignment(0, 0.5) - label.set_width_chars(15) - hbox_temp.pack_start(label, False, False, 2) + # BAND + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["BAND"], halign=Gtk.Align.START) + label.set_alignment(0, 0.5) + label.set_width_chars(15) + hbox_temp.pack_start(label, False, False, 2) - self.sources["SUBMODE"] = Gtk.ComboBoxText() - self.sources["SUBMODE"].append_text("") - self.sources["SUBMODE"].set_active(0) # Set an empty string initially. As soon as the user selects a particular MODE, the available SUBMODES will appear. - hbox_temp.pack_start(self.sources["SUBMODE"], False, False, 2) - vbox_inner.pack_start(hbox_temp, False, False, 2) - - # POWER - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["TX_PWR"], halign=Gtk.Align.START) - label.set_alignment(0, 0.5) - label.set_width_chars(15) - hbox_temp.pack_start(label, False, False, 2) - self.sources["TX_PWR"] = Gtk.Entry() - self.sources["TX_PWR"].set_width_chars(15) - hbox_temp.pack_start(self.sources["TX_PWR"], False, False, 2) - vbox_inner.pack_start(hbox_temp, False, False, 2) + self.sources["BAND"] = Gtk.ComboBoxText() + for band in BANDS: + self.sources["BAND"].append_text(band) + self.sources["BAND"].set_active(0) # Set an empty string as the default option. + hbox_temp.pack_start(self.sources["BAND"], False, False, 2) + vbox_inner.pack_start(hbox_temp, False, False, 2) - vbox_inner = Gtk.VBox(spacing=2) - hbox_inner.pack_start(Gtk.SeparatorToolItem(), False, False, 0) - hbox_inner.pack_start(vbox_inner, True, True, 2) + # MODE + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["MODE"]) + label.set_alignment(0, 0.5) + label.set_width_chars(15) + hbox_temp.pack_start(label, False, False, 2) - # RST_SENT - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["RST_SENT"]) - label.set_alignment(0, 0.5) - label.set_width_chars(15) - hbox_temp.pack_start(label, False, False, 2) - self.sources["RST_SENT"] = Gtk.Entry() - self.sources["RST_SENT"].set_width_chars(15) - hbox_temp.pack_start(self.sources["RST_SENT"], False, False, 2) - vbox_inner.pack_start(hbox_temp, False, False, 2) + self.sources["MODE"] = Gtk.ComboBoxText() + for mode in sorted(MODES.keys()): + self.sources["MODE"].append_text(mode) + self.sources["MODE"].set_active(0) # Set an empty string as the default option. + self.sources["MODE"].connect("changed", self._on_mode_changed) + hbox_temp.pack_start(self.sources["MODE"], False, False, 2) + vbox_inner.pack_start(hbox_temp, False, False, 2) - # RST_RCVD - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["RST_RCVD"]) - label.set_alignment(0, 0.5) - label.set_width_chars(15) - hbox_temp.pack_start(label, False, False, 2) - self.sources["RST_RCVD"] = Gtk.Entry() - self.sources["RST_RCVD"].set_width_chars(15) - hbox_temp.pack_start(self.sources["RST_RCVD"], False, False, 2) - vbox_inner.pack_start(hbox_temp, False, False, 2) + # SUBMODE + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["SUBMODE"]) + label.set_alignment(0, 0.5) + label.set_width_chars(15) + hbox_temp.pack_start(label, False, False, 2) - # QSL_SENT - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["QSL_SENT"]) - label.set_alignment(0, 0.5) - label.set_width_chars(15) - hbox_temp.pack_start(label, False, False, 2) - qsl_options = ["", "Y", "N", "R", "I"] - self.sources["QSL_SENT"] = Gtk.ComboBoxText() - for option in qsl_options: - self.sources["QSL_SENT"].append_text(option) - self.sources["QSL_SENT"].set_active(0) # Set an empty string as the default option. - hbox_temp.pack_start(self.sources["QSL_SENT"], False, False, 2) - vbox_inner.pack_start(hbox_temp, False, False, 2) + self.sources["SUBMODE"] = Gtk.ComboBoxText() + self.sources["SUBMODE"].append_text("") + self.sources["SUBMODE"].set_active(0) # Set an empty string initially. As soon as the user selects a particular MODE, the available SUBMODES will appear. + hbox_temp.pack_start(self.sources["SUBMODE"], False, False, 2) + vbox_inner.pack_start(hbox_temp, False, False, 2) - # QSL_RCVD - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["QSL_RCVD"]) - label.set_alignment(0, 0.5) - label.set_width_chars(15) - hbox_temp.pack_start(label, False, False, 2) - qsl_options = ["", "Y", "N", "R", "I"] - self.sources["QSL_RCVD"] = Gtk.ComboBoxText() - for option in qsl_options: - self.sources["QSL_RCVD"].append_text(option) - self.sources["QSL_RCVD"].set_active(0) # Set an empty string as the default option. - hbox_temp.pack_start(self.sources["QSL_RCVD"], False, False, 2) - vbox_inner.pack_start(hbox_temp, False, False, 2) + # POWER + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["TX_PWR"], halign=Gtk.Align.START) + label.set_alignment(0, 0.5) + label.set_width_chars(15) + hbox_temp.pack_start(label, False, False, 2) + self.sources["TX_PWR"] = Gtk.Entry() + self.sources["TX_PWR"].set_width_chars(15) + hbox_temp.pack_start(self.sources["TX_PWR"], False, False, 2) + vbox_inner.pack_start(hbox_temp, False, False, 2) - # NOTES - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["NOTES"]) - label.set_alignment(0, 0.5) - label.set_width_chars(15) - hbox_temp.pack_start(label, False, False, 2) - self.textview = Gtk.TextView() - sw = Gtk.ScrolledWindow() - sw.set_shadow_type(Gtk.ShadowType.ETCHED_IN) - sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - sw.add(self.textview) - self.sources["NOTES"] = self.textview.get_buffer() - hbox_temp.pack_start(sw, True, True, 2) - vbox_inner.pack_start(hbox_temp, True, True, 2) + vbox_inner = Gtk.VBox(spacing=2) + hbox_inner.pack_start(Gtk.SeparatorToolItem(), False, False, 0) + hbox_inner.pack_start(vbox_inner, True, True, 2) - qso_frame.add(hbox_inner) + # RST_SENT + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["RST_SENT"]) + label.set_alignment(0, 0.5) + label.set_width_chars(15) + hbox_temp.pack_start(label, False, False, 2) + self.sources["RST_SENT"] = Gtk.Entry() + self.sources["RST_SENT"].set_width_chars(15) + hbox_temp.pack_start(self.sources["RST_SENT"], False, False, 2) + vbox_inner.pack_start(hbox_temp, False, False, 2) + # RST_RCVD + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["RST_RCVD"]) + label.set_alignment(0, 0.5) + label.set_width_chars(15) + hbox_temp.pack_start(label, False, False, 2) + self.sources["RST_RCVD"] = Gtk.Entry() + self.sources["RST_RCVD"].set_width_chars(15) + hbox_temp.pack_start(self.sources["RST_RCVD"], False, False, 2) + vbox_inner.pack_start(hbox_temp, False, False, 2) - ## STATION INFORMATION FRAME - station_frame = Gtk.Frame() - station_frame.set_label("Station Information") - self.vbox.add(station_frame) + # QSL_SENT + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["QSL_SENT"]) + label.set_alignment(0, 0.5) + label.set_width_chars(15) + hbox_temp.pack_start(label, False, False, 2) + qsl_options = ["", "Y", "N", "R", "I"] + self.sources["QSL_SENT"] = Gtk.ComboBoxText() + for option in qsl_options: + self.sources["QSL_SENT"].append_text(option) + self.sources["QSL_SENT"].set_active(0) # Set an empty string as the default option. + hbox_temp.pack_start(self.sources["QSL_SENT"], False, False, 2) + vbox_inner.pack_start(hbox_temp, False, False, 2) - hbox_inner = Gtk.HBox(spacing=2) + # QSL_RCVD + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["QSL_RCVD"]) + label.set_alignment(0, 0.5) + label.set_width_chars(15) + hbox_temp.pack_start(label, False, False, 2) + qsl_options = ["", "Y", "N", "R", "I"] + self.sources["QSL_RCVD"] = Gtk.ComboBoxText() + for option in qsl_options: + self.sources["QSL_RCVD"].append_text(option) + self.sources["QSL_RCVD"].set_active(0) # Set an empty string as the default option. + hbox_temp.pack_start(self.sources["QSL_RCVD"], False, False, 2) + vbox_inner.pack_start(hbox_temp, False, False, 2) - vbox_inner = Gtk.VBox(spacing=2) - hbox_inner.pack_start(vbox_inner, True, True, 2) + # NOTES + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["NOTES"]) + label.set_alignment(0, 0.5) + label.set_width_chars(15) + hbox_temp.pack_start(label, False, False, 2) + self.textview = Gtk.TextView() + sw = Gtk.ScrolledWindow() + sw.set_shadow_type(Gtk.ShadowType.ETCHED_IN) + sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + sw.add(self.textview) + self.sources["NOTES"] = self.textview.get_buffer() + hbox_temp.pack_start(sw, True, True, 2) + vbox_inner.pack_start(hbox_temp, True, True, 2) - # NAME - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["NAME"], halign=Gtk.Align.START) - label.set_width_chars(15) - label.set_alignment(0, 0.5) - hbox_temp.pack_start(label, False, False, 2) - self.sources["NAME"] = Gtk.Entry() - self.sources["NAME"].set_width_chars(15) - hbox_temp.pack_start(self.sources["NAME"], False, False, 2) - vbox_inner.pack_start(hbox_temp, False, False, 2) + qso_frame.add(hbox_inner) - # ADDRESS - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["ADDRESS"], halign=Gtk.Align.START) - label.set_width_chars(15) - label.set_alignment(0, 0.5) - hbox_temp.pack_start(label, False, False, 2) - self.sources["ADDRESS"] = Gtk.Entry() - self.sources["ADDRESS"].set_width_chars(15) - hbox_temp.pack_start(self.sources["ADDRESS"], False, False, 2) - vbox_inner.pack_start(hbox_temp, False, False, 2) + # STATION INFORMATION FRAME + station_frame = Gtk.Frame() + station_frame.set_label("Station Information") + self.vbox.add(station_frame) - # STATE - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["STATE"], halign=Gtk.Align.START) - label.set_width_chars(15) - label.set_alignment(0, 0.5) - hbox_temp.pack_start(label, False, False, 2) - self.sources["STATE"] = Gtk.Entry() - self.sources["STATE"].set_width_chars(15) - hbox_temp.pack_start(self.sources["STATE"], False, False, 2) - vbox_inner.pack_start(hbox_temp, False, False, 2) + hbox_inner = Gtk.HBox(spacing=2) - # COUNTRY - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["COUNTRY"], halign=Gtk.Align.START) - label.set_width_chars(15) - label.set_alignment(0, 0.5) - hbox_temp.pack_start(label, False, False, 2) - self.sources["COUNTRY"] = Gtk.Entry() - self.sources["COUNTRY"].set_width_chars(15) - hbox_temp.pack_start(self.sources["COUNTRY"], False, False, 2) - vbox_inner.pack_start(hbox_temp, False, False, 2) + vbox_inner = Gtk.VBox(spacing=2) + hbox_inner.pack_start(vbox_inner, True, True, 2) - vbox_inner = Gtk.VBox(spacing=2) - hbox_inner.pack_start(Gtk.SeparatorToolItem(), False, False, 0) - hbox_inner.pack_start(vbox_inner, True, True, 2) + # NAME + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["NAME"], halign=Gtk.Align.START) + label.set_width_chars(15) + label.set_alignment(0, 0.5) + hbox_temp.pack_start(label, False, False, 2) + self.sources["NAME"] = Gtk.Entry() + self.sources["NAME"].set_width_chars(15) + hbox_temp.pack_start(self.sources["NAME"], False, False, 2) + vbox_inner.pack_start(hbox_temp, False, False, 2) - # DXCC - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["DXCC"], halign=Gtk.Align.START) - label.set_width_chars(15) - label.set_alignment(0, 0.5) - hbox_temp.pack_start(label, False, False, 2) - self.sources["DXCC"] = Gtk.Entry() - self.sources["DXCC"].set_width_chars(15) - hbox_temp.pack_start(self.sources["DXCC"], False, False, 2) - vbox_inner.pack_start(hbox_temp, False, False, 2) + # ADDRESS + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["ADDRESS"], halign=Gtk.Align.START) + label.set_width_chars(15) + label.set_alignment(0, 0.5) + hbox_temp.pack_start(label, False, False, 2) + self.sources["ADDRESS"] = Gtk.Entry() + self.sources["ADDRESS"].set_width_chars(15) + hbox_temp.pack_start(self.sources["ADDRESS"], False, False, 2) + vbox_inner.pack_start(hbox_temp, False, False, 2) - # CQZ - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["CQZ"], halign=Gtk.Align.START) - label.set_width_chars(15) - label.set_alignment(0, 0.5) - hbox_temp.pack_start(label, False, False, 2) - self.sources["CQZ"] = Gtk.Entry() - self.sources["CQZ"].set_width_chars(15) - hbox_temp.pack_start(self.sources["CQZ"], False, False, 2) - vbox_inner.pack_start(hbox_temp, False, False, 2) + # STATE + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["STATE"], halign=Gtk.Align.START) + label.set_width_chars(15) + label.set_alignment(0, 0.5) + hbox_temp.pack_start(label, False, False, 2) + self.sources["STATE"] = Gtk.Entry() + self.sources["STATE"].set_width_chars(15) + hbox_temp.pack_start(self.sources["STATE"], False, False, 2) + vbox_inner.pack_start(hbox_temp, False, False, 2) - # ITUZ - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["ITUZ"], halign=Gtk.Align.START) - label.set_width_chars(15) - label.set_alignment(0, 0.5) - hbox_temp.pack_start(label, False, False, 2) - self.sources["ITUZ"] = Gtk.Entry() - self.sources["ITUZ"].set_width_chars(15) - hbox_temp.pack_start(self.sources["ITUZ"], False, False, 2) - vbox_inner.pack_start(hbox_temp, False, False, 2) + # COUNTRY + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["COUNTRY"], halign=Gtk.Align.START) + label.set_width_chars(15) + label.set_alignment(0, 0.5) + hbox_temp.pack_start(label, False, False, 2) + self.sources["COUNTRY"] = Gtk.Entry() + self.sources["COUNTRY"].set_width_chars(15) + hbox_temp.pack_start(self.sources["COUNTRY"], False, False, 2) + vbox_inner.pack_start(hbox_temp, False, False, 2) - # IOTA - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["IOTA"], halign=Gtk.Align.START) - label.set_width_chars(15) - label.set_alignment(0, 0.5) - hbox_temp.pack_start(label, False, False, 2) - self.sources["IOTA"] = Gtk.Entry() - self.sources["IOTA"].set_width_chars(15) - hbox_temp.pack_start(self.sources["IOTA"], False, False, 2) - vbox_inner.pack_start(hbox_temp, False, False, 2) + vbox_inner = Gtk.VBox(spacing=2) + hbox_inner.pack_start(Gtk.SeparatorToolItem(), False, False, 0) + hbox_inner.pack_start(vbox_inner, True, True, 2) - station_frame.add(hbox_inner) + # DXCC + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["DXCC"], halign=Gtk.Align.START) + label.set_width_chars(15) + label.set_alignment(0, 0.5) + hbox_temp.pack_start(label, False, False, 2) + self.sources["DXCC"] = Gtk.Entry() + self.sources["DXCC"].set_width_chars(15) + hbox_temp.pack_start(self.sources["DXCC"], False, False, 2) + vbox_inner.pack_start(hbox_temp, False, False, 2) - # Populate various fields, if possible. - if(index is not None): - # The record already exists, so display its current data in the input boxes. - record = log.get_record_by_index(index) - field_names = AVAILABLE_FIELD_NAMES_ORDERED - for i in range(0, len(field_names)): - data = record[field_names[i].lower()] - if(data is None): - data = "" - if(field_names[i] == "BAND"): - self.sources[field_names[i]].set_active(BANDS.index(data)) - elif(field_names[i] == "MODE"): - self.sources[field_names[i]].set_active(sorted(MODES.keys()).index(data)) - - submode_data = record["submode"] - if(submode_data is None): - submode_data = "" - self.sources["SUBMODE"].set_active(MODES[data].index(submode_data)) - elif(field_names[i] == "SUBMODE"): - continue - elif(field_names[i] == "QSL_SENT" or field_names[i] == "QSL_RCVD"): - self.sources[field_names[i]].set_active(qsl_options.index(data)) - elif(field_names[i] == "NOTES"): - # Remember to put the new line escape characters back in when displaying the data in a Gtk.TextView - text = data.replace("\\n", "\n") - self.sources[field_names[i]].set_text(text) + # CQZ + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["CQZ"], halign=Gtk.Align.START) + label.set_width_chars(15) + label.set_alignment(0, 0.5) + hbox_temp.pack_start(label, False, False, 2) + self.sources["CQZ"] = Gtk.Entry() + self.sources["CQZ"].set_width_chars(15) + hbox_temp.pack_start(self.sources["CQZ"], False, False, 2) + vbox_inner.pack_start(hbox_temp, False, False, 2) + + # ITUZ + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["ITUZ"], halign=Gtk.Align.START) + label.set_width_chars(15) + label.set_alignment(0, 0.5) + hbox_temp.pack_start(label, False, False, 2) + self.sources["ITUZ"] = Gtk.Entry() + self.sources["ITUZ"].set_width_chars(15) + hbox_temp.pack_start(self.sources["ITUZ"], False, False, 2) + vbox_inner.pack_start(hbox_temp, False, False, 2) + + # IOTA + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label(AVAILABLE_FIELD_NAMES_FRIENDLY["IOTA"], halign=Gtk.Align.START) + label.set_width_chars(15) + label.set_alignment(0, 0.5) + hbox_temp.pack_start(label, False, False, 2) + self.sources["IOTA"] = Gtk.Entry() + self.sources["IOTA"].set_width_chars(15) + hbox_temp.pack_start(self.sources["IOTA"], False, False, 2) + vbox_inner.pack_start(hbox_temp, False, False, 2) + + station_frame.add(hbox_inner) + + # Populate various fields, if possible. + if(index is not None): + # The record already exists, so display its current data in the input boxes. + record = log.get_record_by_index(index) + field_names = AVAILABLE_FIELD_NAMES_ORDERED + for i in range(0, len(field_names)): + data = record[field_names[i].lower()] + if(data is None): + data = "" + if(field_names[i] == "BAND"): + self.sources[field_names[i]].set_active(BANDS.index(data)) + elif(field_names[i] == "MODE"): + self.sources[field_names[i]].set_active(sorted(MODES.keys()).index(data)) + + submode_data = record["submode"] + if(submode_data is None): + submode_data = "" + self.sources["SUBMODE"].set_active(MODES[data].index(submode_data)) + elif(field_names[i] == "SUBMODE"): + continue + elif(field_names[i] == "QSL_SENT" or field_names[i] == "QSL_RCVD"): + self.sources[field_names[i]].set_active(qsl_options.index(data)) + elif(field_names[i] == "NOTES"): + # Remember to put the new line escape characters back in when displaying the data in a Gtk.TextView + text = data.replace("\\n", "\n") + self.sources[field_names[i]].set_text(text) + else: + self.sources[field_names[i]].set_text(data) + else: + # Automatically fill in the current date and time + self.set_current_datetime_callback() + + # Set up default field values + # Mode + (section, option) = ("records", "default_mode") + if(have_config and config.has_option(section, option)): + mode = config.get(section, option) else: - self.sources[field_names[i]].set_text(data) - else: - # Automatically fill in the current date and time - self.set_current_datetime_callback() + mode = "" + self.sources["MODE"].set_active(sorted(MODES.keys()).index(mode)) - ## Set up default field values - # Mode - (section, option) = ("records", "default_mode") - if(have_config and config.has_option(section, option)): - mode = config.get(section, option) - else: - mode = "" - self.sources["MODE"].set_active(sorted(MODES.keys()).index(mode)) + # Submode + (section, option) = ("records", "default_submode") + if(have_config and config.has_option(section, option)): + submode = config.get(section, option) + else: + submode = "" + self.sources["SUBMODE"].set_active(MODES[mode].index(submode)) - # Submode - (section, option) = ("records", "default_submode") - if(have_config and config.has_option(section, option)): - submode = config.get(section, option) - else: - submode = "" - self.sources["SUBMODE"].set_active(MODES[mode].index(submode)) - - # Power - (section, option) = ("records", "default_power") - if(have_config and config.has_option(section, option)): - power = config.get(section, option) - else: - power = "" - self.sources["TX_PWR"].set_text(power) - - if(have_hamlib): - # If the Hamlib module is present, then use it to fill in the Frequency field if desired. - if(have_config and config.has_option("hamlib", "autofill") and config.has_option("hamlib", "rig_model") and config.has_option("hamlib", "rig_pathname")): - autofill = (config.get("hamlib", "autofill") == "True") - rig_model = config.get("hamlib", "rig_model") - rig_pathname = config.get("hamlib", "rig_pathname") - if(autofill): - # Use Hamlib (if available) to get the frequency - try: - Hamlib.rig_set_debug(Hamlib.RIG_DEBUG_NONE) - rig = Hamlib.Rig(Hamlib.__dict__[rig_model]) # Look up the model's numerical index in Hamlib's symbol dictionary - rig.set_conf("rig_pathname", rig_pathname) - rig.open() - frequency = "%.6f" % (rig.get_freq()/1.0e6) # Converting to MHz here - self.sources["FREQ"].set_text(frequency) - rig.close() - except: - logging.error("Could not obtain Frequency data via Hamlib!") + # Power + (section, option) = ("records", "default_power") + if(have_config and config.has_option(section, option)): + power = config.get(section, option) + else: + power = "" + self.sources["TX_PWR"].set_text(power) - # Do we want PyQSO to autocomplete the Band field based on the Frequency field? - (section, option) = ("records", "autocomplete_band") - if(have_config and config.get(section, option)): - autocomplete_band = (config.get(section, option) == "True") - if(autocomplete_band): + if(have_hamlib): + # If the Hamlib module is present, then use it to fill in the Frequency field if desired. + if(have_config and config.has_option("hamlib", "autofill") and config.has_option("hamlib", "rig_model") and config.has_option("hamlib", "rig_pathname")): + autofill = (config.get("hamlib", "autofill") == "True") + rig_model = config.get("hamlib", "rig_model") + rig_pathname = config.get("hamlib", "rig_pathname") + if(autofill): + # Use Hamlib (if available) to get the frequency + try: + Hamlib.rig_set_debug(Hamlib.RIG_DEBUG_NONE) + rig = Hamlib.Rig(Hamlib.__dict__[rig_model]) # Look up the model's numerical index in Hamlib's symbol dictionary + rig.set_conf("rig_pathname", rig_pathname) + rig.open() + frequency = "%.6f" % (rig.get_freq()/1.0e6) # Converting to MHz here + self.sources["FREQ"].set_text(frequency) + rig.close() + except: + logging.error("Could not obtain Frequency data via Hamlib!") + + # Do we want PyQSO to autocomplete the Band field based on the Frequency field? + (section, option) = ("records", "autocomplete_band") + if(have_config and config.get(section, option)): + autocomplete_band = (config.get(section, option) == "True") + if(autocomplete_band): + self.sources["FREQ"].connect("changed", self._autocomplete_band) + else: + # If no configuration file exists, autocomplete the Band field by default. self.sources["FREQ"].connect("changed", self._autocomplete_band) - else: - # If no configuration file exists, autocomplete the Band field by default. - self.sources["FREQ"].connect("changed", self._autocomplete_band) - self.show_all() + self.show_all() - logging.debug("Record dialog ready!") + logging.debug("Record dialog ready!") - return + return - def get_data(self, field_name): - """ Return the data for a specified field from the Gtk.Entry/Gtk.ComboBoxText/etc boxes in the record dialog. - - :arg str field_name: The name of the field containing the desired data. - :returns: The data in the specified field. - :rtype: str - """ - logging.debug("Retrieving the data in field %s from the record dialog..." % field_name) - if(field_name == "CALL"): - # Always show the callsigns in upper case. - return self.sources[field_name].get_text().upper() - elif(field_name == "MODE"): - return self.sources["MODE"].get_active_text() - elif(field_name == "SUBMODE"): - return self.sources["SUBMODE"].get_active_text() - elif(field_name == "BAND" or field_name == "QSL_SENT" or field_name == "QSL_RCVD"): - return self.sources[field_name].get_active_text() - elif(field_name == "NOTES"): - (start, end) = self.sources[field_name].get_bounds() - text = self.sources[field_name].get_text(start, end, True) - # Replace the escape characters with a slightly different new line marker. - # If we don't do this, the rows in the Gtk.TreeView expand based on the number of new lines. - text = text.replace("\n", "\\n") - return text - else: - return self.sources[field_name].get_text() + def get_data(self, field_name): + """ Return the data for a specified field from the Gtk.Entry/Gtk.ComboBoxText/etc boxes in the record dialog. - def _on_mode_changed(self, combo): - """ If the MODE field has changed its value, then fill the SUBMODE field with all the available SUBMODE options for that new MODE. """ - self.sources["SUBMODE"].get_model().clear() - text = combo.get_active_text() - for submode in MODES[text]: - self.sources["SUBMODE"].append_text(submode) - return - - def _autocomplete_band(self, widget=None): - """ If a value for the Frequency is entered, this function autocompletes the Band field. """ + :arg str field_name: The name of the field containing the desired data. + :returns: The data in the specified field. + :rtype: str + """ + logging.debug("Retrieving the data in field %s from the record dialog..." % field_name) + if(field_name == "CALL"): + # Always show the callsigns in upper case. + return self.sources[field_name].get_text().upper() + elif(field_name == "MODE"): + return self.sources["MODE"].get_active_text() + elif(field_name == "SUBMODE"): + return self.sources["SUBMODE"].get_active_text() + elif(field_name == "BAND" or field_name == "QSL_SENT" or field_name == "QSL_RCVD"): + return self.sources[field_name].get_active_text() + elif(field_name == "NOTES"): + (start, end) = self.sources[field_name].get_bounds() + text = self.sources[field_name].get_text(start, end, True) + # Replace the escape characters with a slightly different new line marker. + # If we don't do this, the rows in the Gtk.TreeView expand based on the number of new lines. + text = text.replace("\n", "\\n") + return text + else: + return self.sources[field_name].get_text() - frequency = self.sources["FREQ"].get_text() - # Check whether we actually have a (valid) value to use. If not, set the BAND field to an empty string (""). - try: - frequency = float(frequency) - except ValueError: - self.sources["BAND"].set_active(0) - return - - # Find which band the frequency lies in. - for i in range(1, len(BANDS)): - if(frequency >= BANDS_RANGES[i][0] and frequency <= BANDS_RANGES[i][1]): - self.sources["BAND"].set_active(i) + def _on_mode_changed(self, combo): + """ If the MODE field has changed its value, then fill the SUBMODE field with all the available SUBMODE options for that new MODE. """ + self.sources["SUBMODE"].get_model().clear() + text = combo.get_active_text() + for submode in MODES[text]: + self.sources["SUBMODE"].append_text(submode) + return + + def _autocomplete_band(self, widget=None): + """ If a value for the Frequency is entered, this function autocompletes the Band field. """ + + frequency = self.sources["FREQ"].get_text() + # Check whether we actually have a (valid) value to use. If not, set the BAND field to an empty string (""). + try: + frequency = float(frequency) + except ValueError: + self.sources["BAND"].set_active(0) return - self.sources["BAND"].set_active(0) # If we've reached this, then the frequency does not lie in any of the specified bands. - return + # Find which band the frequency lies in. + for i in range(1, len(BANDS)): + if(frequency >= BANDS_RANGES[i][0] and frequency <= BANDS_RANGES[i][1]): + self.sources["BAND"].set_active(i) + return + self.sources["BAND"].set_active(0) # If we've reached this, then the frequency does not lie in any of the specified bands. + return - def lookup_callback(self, widget=None): - """ Get the callsign-related data from an online database and store it in the relevant Gtk.Entry boxes, but return None. """ - - # Get the database name. - config = configparser.ConfigParser() - have_config = (config.read(expanduser('~/.config/pyqso/preferences.ini')) != []) - try: - if(have_config and config.has_option("records", "callsign_database")): - database = config.get("records", "callsign_database") - if(database == ""): - raise ValueError - else: - raise ValueError - except ValueError: - error(parent=self, message="To perform a callsign lookup, please specify the name of the callsign database in the Preferences.") - return - - try: - if(database == "qrz.com"): - # QRZ.com - callsign_lookup = CallsignLookupQRZ(parent = self) - elif(database == "hamqth.com"): - # HamQTH - callsign_lookup = CallsignLookupHamQTH(parent = self) - else: - raise ValueError("Unknown callsign database: %s" % database) - except ValueError as e: - logging.exception(e) - error(e) - return + def lookup_callback(self, widget=None): + """ Get the callsign-related data from an online database and store it in the relevant Gtk.Entry boxes, but return None. """ - # Get username and password from configuration file - if(have_config and config.has_option("records", "callsign_database_username") and config.has_option("records", "callsign_database_password")): - username = config.get("records", "callsign_database_username") - password = base64.b64decode(config.get("records", "callsign_database_password")).decode("utf-8") - if(username == "" or password == ""): + # Get the database name. + config = configparser.ConfigParser() + have_config = (config.read(expanduser('~/.config/pyqso/preferences.ini')) != []) + try: + if(have_config and config.has_option("records", "callsign_database")): + database = config.get("records", "callsign_database") + if(database == ""): + raise ValueError + else: + raise ValueError + except ValueError: + error(parent=self, message="To perform a callsign lookup, please specify the name of the callsign database in the Preferences.") + return + + try: + if(database == "qrz.com"): + # QRZ.com + callsign_lookup = CallsignLookupQRZ(parent=self) + elif(database == "hamqth.com"): + # HamQTH + callsign_lookup = CallsignLookupHamQTH(parent=self) + else: + raise ValueError("Unknown callsign database: %s" % database) + except ValueError as e: + logging.exception(e) + error(e) + return + + # Get username and password from configuration file + if(have_config and config.has_option("records", "callsign_database_username") and config.has_option("records", "callsign_database_password")): + username = config.get("records", "callsign_database_username") + password = base64.b64decode(config.get("records", "callsign_database_password")).decode("utf-8") + if(username == "" or password == ""): + details_given = False + else: + details_given = True + else: details_given = False - else: - details_given = True - else: - details_given = False - if(not details_given): - error(parent=self, message="To perform a callsign lookup, please specify your username and password in the Preferences.") - return - - # Connect and look up - connected = callsign_lookup.connect(username, password) - if(connected): - full_callsign = self.sources["CALL"].get_text() - # Check whether we want to ignore any prefixes (e.g. "IA/") or suffixes "(e.g. "/M") in the callsign - # before performing the lookup. - if(have_config and config.has_option("records", "ignore_prefix_suffix")): - ignore_prefix_suffix = (config.get("records", "ignore_prefix_suffix") == "True") - else: - ignore_prefix_suffix = True - - fields_and_data = callsign_lookup.lookup(full_callsign, ignore_prefix_suffix=ignore_prefix_suffix) - for field_name in list(fields_and_data.keys()): - self.sources[field_name].set_text(fields_and_data[field_name]) - return + if(not details_given): + error(parent=self, message="To perform a callsign lookup, please specify your username and password in the Preferences.") + return - def calendar_callback(self, widget): - """ Open up a calendar widget for easy QSO_DATE selection. Return None after the user destroys the dialog. """ - calendar = CalendarDialog(parent = self) - response = calendar.run() - if(response == Gtk.ResponseType.OK): - date = calendar.get_date() - self.sources["QSO_DATE"].set_text(date) - calendar.destroy() - return - - def set_current_datetime_callback(self, widget=None): - """ Insert the current date and time. """ - - # Check if a configuration file is present. - config = configparser.ConfigParser() - have_config = (config.read(expanduser('~/.config/pyqso/preferences.ini')) != []) - - # Do we want to use UTC or the computer's local time? - (section, option) = ("records", "use_utc") - if(have_config and config.has_option(section, option)): - use_utc = (config.get(section, option) == "True") - if(use_utc): - dt = datetime.utcnow() - else: - dt = datetime.now() - else: - dt = datetime.utcnow() # Use UTC by default, since this is expected by ADIF. + # Connect and look up + connected = callsign_lookup.connect(username, password) + if(connected): + full_callsign = self.sources["CALL"].get_text() + # Check whether we want to ignore any prefixes (e.g. "IA/") or suffixes "(e.g. "/M") in the callsign + # before performing the lookup. + if(have_config and config.has_option("records", "ignore_prefix_suffix")): + ignore_prefix_suffix = (config.get("records", "ignore_prefix_suffix") == "True") + else: + ignore_prefix_suffix = True + + fields_and_data = callsign_lookup.lookup(full_callsign, ignore_prefix_suffix=ignore_prefix_suffix) + for field_name in list(fields_and_data.keys()): + self.sources[field_name].set_text(fields_and_data[field_name]) + return + + def calendar_callback(self, widget): + """ Open up a calendar widget for easy QSO_DATE selection. Return None after the user destroys the dialog. """ + calendar = CalendarDialog(parent=self) + response = calendar.run() + if(response == Gtk.ResponseType.OK): + date = calendar.get_date() + self.sources["QSO_DATE"].set_text(date) + calendar.destroy() + return + + def set_current_datetime_callback(self, widget=None): + """ Insert the current date and time. """ + + # Check if a configuration file is present. + config = configparser.ConfigParser() + have_config = (config.read(expanduser('~/.config/pyqso/preferences.ini')) != []) + + # Do we want to use UTC or the computer's local time? + (section, option) = ("records", "use_utc") + if(have_config and config.has_option(section, option)): + use_utc = (config.get(section, option) == "True") + if(use_utc): + dt = datetime.utcnow() + else: + dt = datetime.now() + else: + dt = datetime.utcnow() # Use UTC by default, since this is expected by ADIF. + + self.sources["QSO_DATE"].set_text(dt.strftime("%Y%m%d")) + self.sources["TIME_ON"].set_text(dt.strftime("%H%M")) + + return - self.sources["QSO_DATE"].set_text(dt.strftime("%Y%m%d")) - self.sources["TIME_ON"].set_text(dt.strftime("%H%M")) - - return class CalendarDialog(Gtk.Dialog): - """ A simple dialog containing a Gtk.Calendar widget. Using this ensures the date is in the correct YYYYMMDD format required by ADIF. """ - - def __init__(self, parent): - """ Set up the calendar widget and show it to the user. - - :arg parent: The parent Gtk window/dialog. - """ - logging.debug("Setting up a calendar dialog...") - Gtk.Dialog.__init__(self, title="Select Date", parent=parent, flags=Gtk.DialogFlags.DESTROY_WITH_PARENT, buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OK, Gtk.ResponseType.OK)) - self.calendar = Gtk.Calendar() - self.vbox.add(self.calendar) - self.show_all() - logging.debug("Calendar dialog ready!") - return - def get_date(self): - """ Return the date from the Gtk.Calendar widget in YYYYMMDD format. - - :returns: The date from the calendar in YYYYMMDD format. - :rtype: str - """ - logging.debug("Retrieving the date from the calendar widget...") - (year, month, day) = self.calendar.get_date() - # If necessary, add on leading zeros so the YYYYMMDD format is followed. - if(month + 1 < 10): - month = "0" + str(month + 1) # Note: the months start from an index of 0 when retrieved from the calendar widget. - else: - month += 1 - if(day < 10): - day = "0" + str(day) - date = str(year) + str(month) + str(day) - return date + """ A simple dialog containing a Gtk.Calendar widget. Using this ensures the date is in the correct YYYYMMDD format required by ADIF. """ + def __init__(self, parent): + """ Set up the calendar widget and show it to the user. + + :arg parent: The parent Gtk window/dialog. + """ + logging.debug("Setting up a calendar dialog...") + Gtk.Dialog.__init__(self, title="Select Date", parent=parent, flags=Gtk.DialogFlags.DESTROY_WITH_PARENT, buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OK, Gtk.ResponseType.OK)) + self.calendar = Gtk.Calendar() + self.vbox.add(self.calendar) + self.show_all() + logging.debug("Calendar dialog ready!") + return + + def get_date(self): + """ Return the date from the Gtk.Calendar widget in YYYYMMDD format. + + :returns: The date from the calendar in YYYYMMDD format. + :rtype: str + """ + logging.debug("Retrieving the date from the calendar widget...") + (year, month, day) = self.calendar.get_date() + # If necessary, add on leading zeros so the YYYYMMDD format is followed. + if(month + 1 < 10): + month = "0" + str(month + 1) # Note: the months start from an index of 0 when retrieved from the calendar widget. + else: + month += 1 + if(day < 10): + day = "0" + str(day) + date = str(year) + str(month) + str(day) + return date diff --git a/pyqso/telnet_connection_dialog.py b/pyqso/telnet_connection_dialog.py index 517cf51..bdac8d5 100644 --- a/pyqso/telnet_connection_dialog.py +++ b/pyqso/telnet_connection_dialog.py @@ -20,73 +20,74 @@ from gi.repository import Gtk import logging + class TelnetConnectionDialog(Gtk.Dialog): - """ A simple dialog through which users can specify host and login information for a Telnet server. - This can be used to connect to DX clusters. """ - - def __init__(self, parent): - """ Set up and show the Telnet connection dialog to the user. - - :arg parent: The parent Gtk window/dialog. - """ - logging.debug("Setting up the Telnet connection dialog...") - - Gtk.Dialog.__init__(self, title="New Telnet Connection", parent=parent, flags=Gtk.DialogFlags.DESTROY_WITH_PARENT, buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OK, Gtk.ResponseType.OK)) + """ A simple dialog through which users can specify host and login information for a Telnet server. + This can be used to connect to DX clusters. """ - self.sources = {} + def __init__(self, parent): + """ Set up and show the Telnet connection dialog to the user. - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label("Host: ", halign=Gtk.Align.START) - label.set_width_chars(12) - label.set_alignment(0, 0.5) - hbox_temp.pack_start(label, False, False, 6) - self.sources["HOST"] = Gtk.Entry() - hbox_temp.pack_start(self.sources["HOST"], True, True, 6) - self.vbox.pack_start(hbox_temp, False, False, 6) + :arg parent: The parent Gtk window/dialog. + """ - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label("Port: ", halign=Gtk.Align.START) - label.set_width_chars(12) - label.set_alignment(0, 0.5) - hbox_temp.pack_start(label, False, False, 6) - self.sources["PORT"] = Gtk.Entry() - hbox_temp.pack_start(self.sources["PORT"], True, True, 6) - self.vbox.pack_start(hbox_temp, False, False, 6) + logging.debug("Setting up the Telnet connection dialog...") - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label("Username: ", halign=Gtk.Align.START) - label.set_width_chars(12) - label.set_alignment(0, 0.5) - hbox_temp.pack_start(label, False, False, 6) - self.sources["USERNAME"] = Gtk.Entry() - hbox_temp.pack_start(self.sources["USERNAME"], True, True, 6) - self.vbox.pack_start(hbox_temp, False, False, 6) + Gtk.Dialog.__init__(self, title="New Telnet Connection", parent=parent, flags=Gtk.DialogFlags.DESTROY_WITH_PARENT, buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OK, Gtk.ResponseType.OK)) - hbox_temp = Gtk.HBox(spacing=0) - label = Gtk.Label("Password: ", halign=Gtk.Align.START) - label.set_width_chars(12) - label.set_alignment(0, 0.5) - hbox_temp.pack_start(label, False, False, 6) - self.sources["PASSWORD"] = Gtk.Entry() - self.sources["PASSWORD"].set_visibility(False) # Mask the password with the "*" character. - hbox_temp.pack_start(self.sources["PASSWORD"], True, True, 6) - self.vbox.pack_start(hbox_temp, False, False, 6) - - self.sources["BOOKMARK"] = Gtk.CheckButton("Bookmark server details for next time") - self.vbox.pack_start(self.sources["BOOKMARK"], False, False, 6) + self.sources = {} - logging.debug("Telnet connection dialog ready!") + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label("Host: ", halign=Gtk.Align.START) + label.set_width_chars(12) + label.set_alignment(0, 0.5) + hbox_temp.pack_start(label, False, False, 6) + self.sources["HOST"] = Gtk.Entry() + hbox_temp.pack_start(self.sources["HOST"], True, True, 6) + self.vbox.pack_start(hbox_temp, False, False, 6) - self.show_all() - return + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label("Port: ", halign=Gtk.Align.START) + label.set_width_chars(12) + label.set_alignment(0, 0.5) + hbox_temp.pack_start(label, False, False, 6) + self.sources["PORT"] = Gtk.Entry() + hbox_temp.pack_start(self.sources["PORT"], True, True, 6) + self.vbox.pack_start(hbox_temp, False, False, 6) - def get_connection_info(self): - """ Return the host and login information stored in the Gtk.Entry boxes. - - :returns: A dictionary of Telnet connection-related information (username, password, port, host). - :rtype: dict - """ - logging.debug("Returning Telnet connection information...") - return self.sources + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label("Username: ", halign=Gtk.Align.START) + label.set_width_chars(12) + label.set_alignment(0, 0.5) + hbox_temp.pack_start(label, False, False, 6) + self.sources["USERNAME"] = Gtk.Entry() + hbox_temp.pack_start(self.sources["USERNAME"], True, True, 6) + self.vbox.pack_start(hbox_temp, False, False, 6) + hbox_temp = Gtk.HBox(spacing=0) + label = Gtk.Label("Password: ", halign=Gtk.Align.START) + label.set_width_chars(12) + label.set_alignment(0, 0.5) + hbox_temp.pack_start(label, False, False, 6) + self.sources["PASSWORD"] = Gtk.Entry() + self.sources["PASSWORD"].set_visibility(False) # Mask the password with the "*" character. + hbox_temp.pack_start(self.sources["PASSWORD"], True, True, 6) + self.vbox.pack_start(hbox_temp, False, False, 6) + + self.sources["BOOKMARK"] = Gtk.CheckButton("Bookmark server details for next time") + self.vbox.pack_start(self.sources["BOOKMARK"], False, False, 6) + + logging.debug("Telnet connection dialog ready!") + + self.show_all() + return + + def get_connection_info(self): + """ Return the host and login information stored in the Gtk.Entry boxes. + + :returns: A dictionary of Telnet connection-related information (username, password, port, host). + :rtype: dict + """ + logging.debug("Returning Telnet connection information...") + return self.sources diff --git a/pyqso/toolbar.py b/pyqso/toolbar.py index a9c50b5..129984a 100644 --- a/pyqso/toolbar.py +++ b/pyqso/toolbar.py @@ -20,121 +20,120 @@ from gi.repository import Gtk import logging + class Toolbar(Gtk.HBox): - """ The toolbar underneath the menu bar. """ - def __init__(self, parent): - """ Set up the various buttons in the toolbar, and connect to their corresponding functions. """ + """ The toolbar underneath the menu bar. """ - logging.debug("Setting up the toolbar...") - - Gtk.HBox.__init__(self, spacing=2) + def __init__(self, parent): + """ Set up the various buttons in the toolbar, and connect to their corresponding functions. """ - self.buttons = {} + logging.debug("Setting up the toolbar...") - # Create logbook - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_NEW, Gtk.IconSize.BUTTON) - button = Gtk.Button() - button.add(icon) - button.set_tooltip_text('Create a New Logbook') - button.connect("clicked", parent.logbook.new) - self.pack_start(button, False, False, 0) - self.buttons["NEW_LOGBOOK"] = button - - # Open logbook - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_OPEN, Gtk.IconSize.BUTTON) - button = Gtk.Button() - button.add(icon) - button.set_tooltip_text('Open an Existing Logbook') - button.connect("clicked", parent.logbook.open) - self.pack_start(button, False, False, 0) - self.buttons["OPEN_LOGBOOK"] = button + Gtk.HBox.__init__(self, spacing=2) - # Close logbook - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_CLOSE, Gtk.IconSize.BUTTON) - button = Gtk.Button() - button.add(icon) - button.set_tooltip_text('Close Logbook') - button.connect("clicked", parent.logbook.close) - self.pack_start(button, False, False, 0) - self.buttons["CLOSE_LOGBOOK"] = button + self.buttons = {} - self.pack_start(Gtk.SeparatorToolItem(), False, False, 0) + # Create logbook + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_NEW, Gtk.IconSize.BUTTON) + button = Gtk.Button() + button.add(icon) + button.set_tooltip_text('Create a New Logbook') + button.connect("clicked", parent.logbook.new) + self.pack_start(button, False, False, 0) + self.buttons["NEW_LOGBOOK"] = button - # Add record - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_ADD, Gtk.IconSize.BUTTON) - button = Gtk.Button() - button.add(icon) - button.set_tooltip_text('Add Record') - button.connect("clicked", parent.logbook.add_record_callback) - self.pack_start(button, False, False, 0) - self.buttons["ADD_RECORD"] = button + # Open logbook + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_OPEN, Gtk.IconSize.BUTTON) + button = Gtk.Button() + button.add(icon) + button.set_tooltip_text('Open an Existing Logbook') + button.connect("clicked", parent.logbook.open) + self.pack_start(button, False, False, 0) + self.buttons["OPEN_LOGBOOK"] = button - # Edit record - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_EDIT, Gtk.IconSize.BUTTON) - button = Gtk.Button() - button.add(icon) - button.set_tooltip_text('Edit Record') - button.connect("clicked", parent.logbook.edit_record_callback, None, None) - self.pack_start(button, False, False, 0) - self.buttons["EDIT_RECORD"] = button + # Close logbook + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_CLOSE, Gtk.IconSize.BUTTON) + button = Gtk.Button() + button.add(icon) + button.set_tooltip_text('Close Logbook') + button.connect("clicked", parent.logbook.close) + self.pack_start(button, False, False, 0) + self.buttons["CLOSE_LOGBOOK"] = button - # Delete record - icon = Gtk.Image() - icon.set_from_stock(Gtk.STOCK_DELETE, Gtk.IconSize.BUTTON) - button = Gtk.Button() - button.add(icon) - button.set_tooltip_text('Delete Record') - button.connect("clicked", parent.logbook.delete_record_callback) - self.pack_start(button, False, False, 0) - self.buttons["DELETE_RECORD"] = button + self.pack_start(Gtk.SeparatorToolItem(), False, False, 0) - self.pack_start(Gtk.SeparatorToolItem(), False, False, 0) + # Add record + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_ADD, Gtk.IconSize.BUTTON) + button = Gtk.Button() + button.add(icon) + button.set_tooltip_text('Add Record') + button.connect("clicked", parent.logbook.add_record_callback) + self.pack_start(button, False, False, 0) + self.buttons["ADD_RECORD"] = button - # Filter log - label = Gtk.Label("Filter by callsign: ") - self.pack_start(label, False, False, 0) - self.filter_source = Gtk.Entry() - self.filter_source.set_width_chars(11) - self.filter_source.connect_after("changed", parent.logbook.filter_logs) - self.pack_start(self.filter_source, False, False, 0) + # Edit record + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_EDIT, Gtk.IconSize.BUTTON) + button = Gtk.Button() + button.add(icon) + button.set_tooltip_text('Edit Record') + button.connect("clicked", parent.logbook.edit_record_callback, None, None) + self.pack_start(button, False, False, 0) + self.buttons["EDIT_RECORD"] = button - self.set_logbook_button_sensitive(True) - self.set_record_buttons_sensitive(False) + # Delete record + icon = Gtk.Image() + icon.set_from_stock(Gtk.STOCK_DELETE, Gtk.IconSize.BUTTON) + button = Gtk.Button() + button.add(icon) + button.set_tooltip_text('Delete Record') + button.connect("clicked", parent.logbook.delete_record_callback) + self.pack_start(button, False, False, 0) + self.buttons["DELETE_RECORD"] = button - self.filter_source.set_sensitive(False) + self.pack_start(Gtk.SeparatorToolItem(), False, False, 0) - logging.debug("Toolbar ready!") + # Filter log + label = Gtk.Label("Filter by callsign: ") + self.pack_start(label, False, False, 0) + self.filter_source = Gtk.Entry() + self.filter_source.set_width_chars(11) + self.filter_source.connect_after("changed", parent.logbook.filter_logs) + self.pack_start(self.filter_source, False, False, 0) - return + self.set_logbook_button_sensitive(True) + self.set_record_buttons_sensitive(False) - def set_logbook_button_sensitive(self, sensitive): - """ Enable/disable logbook-related toolbar items. - - :arg bool sensitive: If True, enable the 'new logbook' and 'open logbook' toolbar items. If False, disable them. - """ - logging.debug("Setting logbook-related toolbar item sensitivity to: %s..." % sensitive) - self.buttons["NEW_LOGBOOK"].set_sensitive(sensitive) - self.buttons["OPEN_LOGBOOK"].set_sensitive(sensitive) - self.buttons["CLOSE_LOGBOOK"].set_sensitive(not sensitive) - logging.debug("Set logbook-related toolbar item sensitivity to: %s." % sensitive) - return + self.filter_source.set_sensitive(False) - def set_record_buttons_sensitive(self, sensitive): - """ Enable/disable record-related toolbar items. - - :arg bool sensitive: If True, enable all the record-related toolbar items. If False, disable them all. - """ - logging.debug("Setting record-related toolbar item sensitivity to: %s..." % sensitive) - for button_name in ["ADD_RECORD", "EDIT_RECORD", "DELETE_RECORD"]: - self.buttons[button_name].set_sensitive(sensitive) - logging.debug("Set record-related toolbar item sensitivity to: %s." % sensitive) - return + logging.debug("Toolbar ready!") + return + def set_logbook_button_sensitive(self, sensitive): + """ Enable/disable logbook-related toolbar items. + :arg bool sensitive: If True, enable the 'new logbook' and 'open logbook' toolbar items. If False, disable them. + """ + logging.debug("Setting logbook-related toolbar item sensitivity to: %s..." % sensitive) + self.buttons["NEW_LOGBOOK"].set_sensitive(sensitive) + self.buttons["OPEN_LOGBOOK"].set_sensitive(sensitive) + self.buttons["CLOSE_LOGBOOK"].set_sensitive(not sensitive) + logging.debug("Set logbook-related toolbar item sensitivity to: %s." % sensitive) + return + + def set_record_buttons_sensitive(self, sensitive): + """ Enable/disable record-related toolbar items. + + :arg bool sensitive: If True, enable all the record-related toolbar items. If False, disable them all. + """ + logging.debug("Setting record-related toolbar item sensitivity to: %s..." % sensitive) + for button_name in ["ADD_RECORD", "EDIT_RECORD", "DELETE_RECORD"]: + self.buttons[button_name].set_sensitive(sensitive) + logging.debug("Set record-related toolbar item sensitivity to: %s." % sensitive) + return diff --git a/pyqso/toolbox.py b/pyqso/toolbox.py index 2bea116..107eee4 100644 --- a/pyqso/toolbox.py +++ b/pyqso/toolbox.py @@ -24,42 +24,43 @@ from pyqso.dx_cluster import * from pyqso.grey_line import * from pyqso.awards import * + class Toolbox(Gtk.Frame): - """ Contains a Gtk.Notebook full of amateur radio-related tools. """ - def __init__(self, parent): - """ Instantiate and insert the various tools into the toolbox. """ + """ Contains a Gtk.Notebook full of amateur radio-related tools. """ - logging.debug("Setting up the toolbox...") - - Gtk.Frame.__init__(self) - self.set_label("Toolbox") - self.parent = parent + def __init__(self, parent): + """ Instantiate and insert the various tools into the toolbox. """ - self.tools = Gtk.Notebook() + logging.debug("Setting up the toolbox...") - self.dx_cluster = DXCluster(self.parent) - self.tools.insert_page(self.dx_cluster, Gtk.Label("DX Cluster"), 0) - self.grey_line = GreyLine(self.parent) - self.tools.insert_page(self.grey_line, Gtk.Label("Grey Line"), 1) - self.awards = Awards(self.parent) - self.tools.insert_page(self.awards, Gtk.Label("Awards"), 2) + Gtk.Frame.__init__(self) + self.set_label("Toolbox") + self.parent = parent - self.add(self.tools) - self.tools.connect_after("switch-page", self._on_switch_page) + self.tools = Gtk.Notebook() - logging.debug("Toolbox ready!") + self.dx_cluster = DXCluster(self.parent) + self.tools.insert_page(self.dx_cluster, Gtk.Label("DX Cluster"), 0) + self.grey_line = GreyLine(self.parent) + self.tools.insert_page(self.grey_line, Gtk.Label("Grey Line"), 1) + self.awards = Awards(self.parent) + self.tools.insert_page(self.awards, Gtk.Label("Awards"), 2) - return + self.add(self.tools) + self.tools.connect_after("switch-page", self._on_switch_page) - def toggle_visible_callback(self, widget=None): - """ Show/hide the toolbox. """ - self.set_visible(not self.get_visible()) - return + logging.debug("Toolbox ready!") - def _on_switch_page(self, widget, label, new_page): - """ Re-draw the Grey Line if the user switches to the grey line tab. """ - if(type(label) == GreyLine): - label.draw() # Note that 'label' is actually a GreyLine object. - return + return + def toggle_visible_callback(self, widget=None): + """ Show/hide the toolbox. """ + self.set_visible(not self.get_visible()) + return + + def _on_switch_page(self, widget, label, new_page): + """ Re-draw the Grey Line if the user switches to the grey line tab. """ + if(isinstance(label, GreyLine)): + label.draw() # Note that 'label' is actually a GreyLine object. + return diff --git a/setup.py b/setup.py index a6bca9f..8cdd22a 100644 --- a/setup.py +++ b/setup.py @@ -26,16 +26,15 @@ setup(name='PyQSO', author_email='c.jacobs10@imperial.ac.uk', url='https://github.com/ctjacobs/pyqso', packages=['pyqso'], - package_dir = {'pyqso': 'pyqso'}, + package_dir={'pyqso': 'pyqso'}, scripts=["bin/pyqso"], data_files=[("icons", ["icons/log_64x64.png"])], classifiers=[ - 'Development Status :: 3 - Alpha', - 'Intended Audience :: End Users/Desktop', - 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', - 'Natural Language :: English', - 'Programming Language :: Python :: 3', - 'Topic :: Communications :: Ham Radio', - ] - ) - + 'Development Status :: 3 - Alpha', + 'Intended Audience :: End Users/Desktop', + 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', + 'Natural Language :: English', + 'Programming Language :: Python :: 3', + 'Topic :: Communications :: Ham Radio', + ] + )