From 87e7831546dfb04a1f047848de7e4a2439b3bbf6 Mon Sep 17 00:00:00 2001 From: namratakpackt <77480410+namratakpackt@users.noreply.github.com> Date: Wed, 7 Apr 2021 16:16:45 +0530 Subject: [PATCH 01/32] Initial commit --- LICENSE | 21 +++++++++++++++++++++ README.md | 2 ++ 2 files changed, 23 insertions(+) create mode 100644 LICENSE create mode 100644 README.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..18a3304 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Packt + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a93dd87 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# Python-GUI-Programming-with-Tkinter-2E +Python GUI Programming with Tkinter 2E.Published by Packt From cba324e7b808e19054267359ee302c7951b4c008 Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Tue, 27 Apr 2021 08:25:21 -0500 Subject: [PATCH 02/32] Added code for chapters 1 - 4 --- Chapter01/banana_survey_no_vars.py | 169 +++++++++++ Chapter01/banana_survey_pack_version.py | 169 +++++++++++ Chapter01/banana_survey_variables.py | 214 ++++++++++++++ Chapter01/hello_tkinter.py | 16 + Chapter02/abq_data_entry_spec.rst | 102 +++++++ Chapter03/abq_data_entry_spec.rst | 102 +++++++ Chapter03/data_entry_app.py | 302 +++++++++++++++++++ Chapter03/ttk_tour.py | 135 +++++++++ Chapter04/banana.py | 109 +++++++ Chapter04/data_entry_app.py | 337 ++++++++++++++++++++++ Chapter04/discrete_label_input_example.py | 10 + Chapter04/tkinter_classes_demo.py | 140 +++++++++ 12 files changed, 1805 insertions(+) create mode 100644 Chapter01/banana_survey_no_vars.py create mode 100644 Chapter01/banana_survey_pack_version.py create mode 100644 Chapter01/banana_survey_variables.py create mode 100644 Chapter01/hello_tkinter.py create mode 100644 Chapter02/abq_data_entry_spec.rst create mode 100644 Chapter03/abq_data_entry_spec.rst create mode 100644 Chapter03/data_entry_app.py create mode 100644 Chapter03/ttk_tour.py create mode 100644 Chapter04/banana.py create mode 100644 Chapter04/data_entry_app.py create mode 100644 Chapter04/discrete_label_input_example.py create mode 100644 Chapter04/tkinter_classes_demo.py diff --git a/Chapter01/banana_survey_no_vars.py b/Chapter01/banana_survey_no_vars.py new file mode 100644 index 0000000..6987910 --- /dev/null +++ b/Chapter01/banana_survey_no_vars.py @@ -0,0 +1,169 @@ +"""A banana preferences survey written in Python with Tkinter""" + +import tkinter as tk + +# Create the root window +root = tk.Tk() + +# set the title +root.title('Banana interest survey') + +# set the root window size +root.geometry('640x480+300+300') +root.resizable(False, False) + +########### +# Widgets # +########### + +# Use a Label to show the title +# 'font' lets us set a font +title = tk.Label( + root, + text='Please take the survey', + font=('Arial 16 bold'), + bg='brown', + fg='#FF0' +) + +# Use an Entry to get a string +name_label = tk.Label(root, text='What is your name?') +name_inp = tk.Entry(root) + +# Use Checkbutton to get a boolean +eater_inp = tk.Checkbutton(root, text='Check this box if you eat bananas') + +# Spinboxes are good for number entry +num_label = tk.Label(root, text='How many bananas do you eat per day?') +num_inp = tk.Spinbox(root, from_=0, increment=1, value=3) + +# Listbox is good for choices + +color_label = tk.Label(root, text='What is the best color for a banana?') +color_inp = tk.Listbox(root, height=1) # Only show selected item +# add choices +color_choices = ( + 'Any', + 'Green', + 'Green-Yellow', + 'Yellow', + 'Brown spotted', + 'Black' +) +for choice in color_choices: + # END is a tkinter constant that means the end of an input + color_inp.insert(tk.END, choice) + + +# RadioButtons are good for small choices + +plantain_label = tk.Label(root, text='Do you eat plantains?') +# Use a Frame to keep widgets together +plantain_frame = tk.Frame(root) +plantain_yes_inp = tk.Radiobutton(plantain_frame, text='Yes') +plantain_no_inp = tk.Radiobutton(plantain_frame, text='Ewww, no!') + +# The Text widget is good for long pieces of text +banana_haiku_label = tk.Label(root, text='Write a haiku about bananas') +banana_haiku_inp = tk.Text(root, height=3) + +# Buttons are used to trigger actions + +submit_btn = tk.Button(root, text='Submit Survey') + +# Use a label to display a line of output +# 'anchor' sets where the text is stuck if the label is wider than needed. +# 'justify' determines how multiple lines of text are aligned +output_line = tk.Label(root, text='', anchor='w', justify='left') + + +####################### +# Geometry Management # +####################### +# Using Grid instead of pack +# Put our widgets on the root window +#title.grid() +# columnspan allows the widget to span multiple columns +title.grid(columnspan=2) + +# add name label and input +# Column defaults to 0 +name_label.grid(row=1, column=0) + +# The grid automatically expands +# when we add a widget to the next row or column +name_inp.grid(row=1, column=1) + +# 'sticky' attaches the widget to the named sides, +# so it will expand with the grid +eater_inp.grid(row=2, columnspan=2, sticky='we') +# tk constants can be used instead of strings +num_label.grid(row=3, sticky=tk.W) +num_inp.grid(row=3, column=1, sticky=(tk.W + tk.E)) + +#padx and pady can still be used to add horizontal or vertical padding +color_label.grid(row=4, columnspan=2, sticky=tk.W, pady=10) +color_inp.grid(row=5, columnspan=2, sticky=tk.W + tk.E, padx=25) + +# We can still use pack on the plantain frame. +# pack and grid can be mixed in a layout as long as we don't +# use them in the same frame +plantain_yes_inp.pack(side='left', fill='x', ipadx=10, ipady=5) +plantain_no_inp.pack(side='left', fill='x', ipadx=10, ipady=5) +plantain_label.grid(row=6, columnspan=2, sticky=tk.W) +plantain_frame.grid(row=7, columnspan=2, stick=tk.W) + +# Sticky on all sides will allow the widget to fill vertical and horizontal +banana_haiku_label.grid(row=8, sticky=tk.W) +banana_haiku_inp.grid(row=9, columnspan=2, sticky='NSEW') + +# Add the button and output +submit_btn.grid(row=99) +output_line.grid(row=100, columnspan=2, sticky='NSEW') + +# columnconfigure can be used to set options on the columns of the grid +# 'weight' means that column will be preferred for expansion +root.columnconfigure(1, weight=1) + +# rowconfigure works for rows +root.rowconfigure(99, weight=2) +root.rowconfigure(100, weight=1) + +##################### +# Add some behavior # +##################### + +def on_submit(): + """To be run when the user submits the form""" + + # Many widgets use "get" to retrieve contents + name = name_inp.get() + # spinboxes return a str, not a float or int! + number = num_inp.get() + # Listboxes are more involved + selected_idx = color_inp.curselection() + color = color_inp.get(selected_idx) + + # We're going to need some way to get our button values! + # banana_eater = ???? + + # Text widgets require a range + haiku = banana_haiku_inp.get('1.0', tk.END) + + # Update the text in our output + message = ( + f'Thanks for taking the survey, {name}.\n' + f'Enjoy your {number} {color} bananas!' + ) + output_line.configure(text=message) + print(haiku) + + +# configure the button to trigger submission +submit_btn.configure(command=on_submit) + +############### +# Execute App # +############### + +root.mainloop() diff --git a/Chapter01/banana_survey_pack_version.py b/Chapter01/banana_survey_pack_version.py new file mode 100644 index 0000000..83128f5 --- /dev/null +++ b/Chapter01/banana_survey_pack_version.py @@ -0,0 +1,169 @@ +"""A banana preferences survey written in Python with Tkinter""" + +import tkinter as tk + +# Create the root window +root = tk.Tk() + +# set the title +root.title('Banana interest survey') +# set the root window size +root.geometry('640x480+300+300') +root.resizable(False, False) + +########### +# Widgets # +########### + +# Use a Label to show the title +# 'font' lets us set a font +title = tk.Label( + root, + text='Please take the survey', + font=('Arial 16 bold'), + bg='brown', + fg='#FF0' +) + + +# Use an Entry to get a string +name_label = tk.Label(root, text='What is your name?') +name_inp = tk.Entry(root) + +# Use Checkbutton to get a boolean +eater_inp = tk.Checkbutton(root, text='Check this box if you eat bananas') + +# Spinboxes are good for number entry +num_label = tk.Label(root, text='How many bananas do you eat per day?') +num_inp = tk.Spinbox(root, from_=0, to=1000, increment=1, value=3) + +# Listbox is good for choices + +color_label = tk.Label(root, text='What is the best color for a banana?') +color_inp = tk.Listbox(root, height=1) # Only show selected item +# add choices +color_choices = ( + 'Any', + 'Green', + 'Green-Yellow', + 'Yellow', + 'Brown Spotted', + 'Black' + ) +for choice in color_choices: + # END is a tkinter constant that means the end of an input + color_inp.insert(tk.END, choice) + + +# RadioButtons are good for small choices + +plantain_label = tk.Label(root, text='Do you eat plantains?') +# Use a Frame to keep widgets together +plantain_frame = tk.Frame(root) +plantain_yes_inp = tk.Radiobutton(plantain_frame, text='Yes') +plantain_no_inp = tk.Radiobutton(plantain_frame, text='Ewww, no!') + +# The Text widget is good for long pieces of text +banana_haiku_label = tk.Label(root, text='Write a haiku about bananas') +banana_haiku_inp = tk.Text(root, height=3) + +# Buttons are used to trigger actions + +submit_btn = tk.Button(root, text='Submit Survey') + +# Use a label to display a line of output +# 'anchor' sets where the text is stuck if the label is wider than needed. +# 'justify' determines how multiple lines of text are aligned +output_line = tk.Label(root, text='', anchor='w', justify='left') + + +####################### +# Geometry Management # +####################### + +# Put our widgets on the root window +title.pack() + + +# add name label. It shows up underneath +# 'anchor' specifies which side our widget sticks to +# 'fill' allows our widget to expand into extra space in x, y, or both axes +name_label.pack(anchor='w') +name_inp.pack(fill='x') + +# tk constants can be used instead of strings +eater_inp.pack(anchor=tk.W) +num_label.pack(anchor=tk.W) +num_inp.pack(fill=tk.X) + +#padx and pady can be used to add horizontal or vertical padding +color_label.pack(anchor=tk.W, pady=10) +color_inp.pack(fill=tk.X, padx=25) + +# Use side to change the orientation of packing +# Note that we're packing into the plantain frame, not root! +# This keeps our radio buttons side-by-side +# ipad is "internal padding". +# Using this over regular padding not only separates the buttons, +# it increases the space we can click on to select a button +plantain_yes_inp.pack(side='left', fill='x', ipadx=10, ipady=5) +plantain_no_inp.pack(side='left', fill='x', ipadx=10, ipady=5) +plantain_label.pack(fill='x', padx=10, pady=5) +plantain_frame.pack(fill='x') + +# fill both ways +# 'expand' means this widget will get any extra space left in the parent +banana_haiku_label.pack(anchor='w') +banana_haiku_inp.pack(fill='both', expand=True) + +# Add the button and output +# Specifying side='bottom' means the widgets will be packed +# from the bottom up, so specify the last widget first +# Specifying 'expand' on another widget means extra space +# will be divided amongst the expandable widgets +output_line.pack(side='bottom', fill='x', expand=True) +submit_btn.pack(side='bottom') + + +##################### +# Add some behavior # +##################### + +def on_submit(): + """To be run when the user submits the form""" + + # Many widgets use "get" to retrieve contents + name = name_inp.get() + # spinboxes return a str, not a float or int! + number = num_inp.get() + # Listboxes are more involved + selected_idx = color_inp.curselection() + color = color_inp.get(selected_idx) + + # We're going to need some way to get our button values! + # banana_eater = ???? + + # Text widgets require a range + haiku = banana_haiku_inp.get('1.0', tk.END) + + # Update the text in our output + message = ( + f'Thanks for taking the survey, {name}.\n' + f'Enjoy your {number} {color} bananas!' + ) + output_line.configure(text=message) + print(haiku) + + +# configure the button to trigger submission +submit_btn.configure(command=on_submit) + +# an alternate approach +# note that this sends an event object to the functions +#submit_btn.bind('', on_submit) + +############### +# Execute App # +############### + +root.mainloop() diff --git a/Chapter01/banana_survey_variables.py b/Chapter01/banana_survey_variables.py new file mode 100644 index 0000000..26e4261 --- /dev/null +++ b/Chapter01/banana_survey_variables.py @@ -0,0 +1,214 @@ +"""A banana preferences survey written in Python with Tkinter""" + +import tkinter as tk + +# Create the root window +root = tk.Tk() + +# set the title +root.title('Banana interest survey') + +# set the root window size +root.geometry('640x480+300+300') +root.resizable(False, False) + +########### +# Widgets # +########### + +# Use a Label to show the title +# 'font' lets us set a font +title = tk.Label( + root, + text='Please take the survey', + font=('Arial 16 bold'), + bg='brown', + fg='#FF0' +) + + +# Use string vars for strings +name_var = tk.StringVar(root) +name_label = tk.Label(root, text='What is your name?') +name_inp = tk.Entry(root, textvariable=name_var) + +# Use boolean var for True/False +eater_var = tk.BooleanVar() +eater_inp = tk.Checkbutton( + root, variable=eater_var, text='Check this box if you eat bananas' +) + +# Use int var for whole numbers +# Value can set a default +num_var = tk.IntVar(value=3) +num_label = tk.Label(text='How many bananas do you eat per day?') +# note that even with an intvar, the key is still 'textvariable' +num_inp = tk.Spinbox( + root, + textvariable=num_var, + from_=0, + increment=1, + value=3 +) + +# Listboxes don't work well with variables, +# However OptionMenu works great! +color_var = tk.StringVar(value='Any') +color_label = tk.Label(root, text='What is the best color for a banana?') +color_choices = ( + 'Any', 'Green', 'Green Yellow', 'Yellow', 'Brown Spotted', 'Black' +) +color_inp = tk.OptionMenu( + root, color_var, *color_choices +) + +plantain_label = tk.Label(root, text='Do you eat plantains?') +# Use a Frame to keep widgets together +plantain_frame = tk.Frame(root) + +# We can use any kind of var with Radiobuttons, +# as long as each button's 'value' property is the +# correct type +plantain_var = tk.BooleanVar() +# The radio buttons are connected by using the same variable +# The value of the var will be set to the button's 'value' property value +plantain_yes_inp = tk.Radiobutton( + plantain_frame, + text='Yes', + value=True, + variable=plantain_var +) +plantain_no_inp = tk.Radiobutton( + plantain_frame, + text='Ewww, no!', + value=False, + variable=plantain_var +) + +# The Text widget doesn't support variables, sadly +# There is no analogous widget that does +banana_haiku_label = tk.Label(root, text='Write a haiku about bananas') +banana_haiku_inp = tk.Text(root, height=3) + +# Buttons are used to trigger actions + +submit_btn = tk.Button(root, text='Submit Survey') + +# Labels can use a StringVar for their contents +output_var = tk.StringVar(value='') +output_line = tk.Label( + root, + textvariable=output_var, + anchor='w', + justify='left' +) + + +####################### +# Geometry Management # +####################### +# Using Grid instead of pack +# Put our widgets on the root window +#title.grid() +# columnspan allows the widget to span multiple columns +title.grid(columnspan=2) + +# add name label and input +# Column defaults to 0 +name_label.grid(row=1, column=0) + +# The grid automatically expands +# when we add a widget to the next row or column +name_inp.grid(row=1, column=1) + +# 'sticky' attaches the widget to the named sides, +# so it will expand with the grid +eater_inp.grid(row=2, columnspan=2, sticky='we') +# tk constants can be used instead of strings +num_label.grid(row=3, sticky=tk.W) +num_inp.grid(row=3, column=1, sticky=(tk.W + tk.E)) + +#padx and pady can still be used to add horizontal or vertical padding +color_label.grid(row=4, columnspan=2, sticky=tk.W, pady=10) +color_inp.grid(row=5, columnspan=2, sticky=tk.W + tk.E, padx=25) + +# We can still use pack on the plantain frame. +# pack and grid can be mixed in a layout as long as we don't +# use them in the same frame +plantain_yes_inp.pack(side='left', fill='x', ipadx=10, ipady=5) +plantain_no_inp.pack(side='left', fill='x', ipadx=10, ipady=5) +plantain_label.grid(row=6, columnspan=2, sticky=tk.W) +plantain_frame.grid(row=7, columnspan=2, stick=tk.W) + +# Sticky on all sides will allow the widget to fill vertical and horizontal +banana_haiku_label.grid(row=8, sticky=tk.W) +banana_haiku_inp.grid(row=9, columnspan=2, sticky='NSEW') + +# Add the button and output +submit_btn.grid(row=99) +output_line.grid(row=100, columnspan=2, sticky='NSEW') + +# columnconfigure can be used to set options on the columns of the grid +# 'weight' means that column will be preferred for expansion +root.columnconfigure(1, weight=1) + +# rowconfigure works for rows +root.rowconfigure(99, weight=2) +root.rowconfigure(100, weight=1) + +##################### +# Add some behavior # +##################### + +def on_submit(): + """To be run when the user submits the form""" + + # Vars all use 'get()' to retreive their variables + name = name_var.get() + # Because we used an IntVar, .get() will try to convert + # the contents of num_var to int. + try: + number = num_var.get() + except tk.TclError: + number = 10000 + + # With the variable, OptionMenu makes things simple + color = color_var.get() + + # Checkbutton and Radiobutton values are now simple + banana_eater = eater_var.get() + plantain_eater = plantain_var.get() + + # Text widgets require a range + haiku = banana_haiku_inp.get('1.0', tk.END) + + # Update the text in our output + message = f'Thanks for taking the survey, {name}.\n' + + if not banana_eater: + message += "Sorry you don't like bananas!\n" + + else: + message += f'Enjoy your {number} {color} bananas!\n' + + if plantain_eater: + message += 'Enjoy your plantains!' + else: + message += 'May you successfully avoid plantains!' + + if haiku.strip(): + message += f'\n\nYour Haiku:\n{haiku}' + + # Set the value of a variable using .set() + # DON'T DO THIS: output_var = 'my string' + output_var.set(message) + + +# configure the button to trigger submission +submit_btn.configure(command=on_submit) + +############### +# Execute App # +############### + +root.mainloop() diff --git a/Chapter01/hello_tkinter.py b/Chapter01/hello_tkinter.py new file mode 100644 index 0000000..1b0f33e --- /dev/null +++ b/Chapter01/hello_tkinter.py @@ -0,0 +1,16 @@ +"""Hello World application for Tkinter""" + +# Import all the classes from tkinter +from tkinter import * + +# Create a root window +root = Tk() + +# Create a widget +label = Label(root, text="Hello World") + +# Place the label on the root window +label.pack() + +# Run the event loop +root.mainloop() diff --git a/Chapter02/abq_data_entry_spec.rst b/Chapter02/abq_data_entry_spec.rst new file mode 100644 index 0000000..41d3c18 --- /dev/null +++ b/Chapter02/abq_data_entry_spec.rst @@ -0,0 +1,102 @@ +====================================== + ABQ Data Entry Program specification +====================================== + +Description +----------- +This program facilitates entry of laboratory observations +into a CSV file. + +Functionality Required +---------------------- + +The program must: + + * allow all relevant, valid data to be entered, + as per the data dictionary + * append entered data to a CSV file: + - The CSV file must have a filename of + abq_data_record_CURRENTDATE.csv, where CURRENTDATE is the date + of the laboratory observations in ISO format (Year-month-day) + - The CSV file must include all fields + listed in the data dictionary + - The CSV headers will avoid cryptic abbreviations + * enforce correct datatypes per field + +The program should try, whenever possible, to: + + * enforce reasonable limits on data entered, per the data dict + * Auto-fill data to save time + * Suggest likely correct values + * Provide a smooth and efficient workflow + * Store data in a format easily understandable by Python + +Functionality Not Required +-------------------------- + +The program does not need to: + + * Allow editing of data. + * Allow deletion of data. + +Users can perform both actions in LibreOffice if needed. + + +Limitations +----------- + +The program must: + + * Be efficiently operable by keyboard-only users. + * Be accessible to color blind users. + * Run on Debian GNU/Linux. + * Run acceptably on a low-end PC. + +Data Dictionary +--------------- ++------------+--------+----+---------------+--------------------+ +|Field | Type |Unit| Valid Values |Description | ++============+========+====+===============+====================+ +|Date |Date | | |Date of record | ++------------+--------+----+---------------+--------------------+ +|Time |Time | |8:00, 12:00, |Time period | +| | | |16:00, or 20:00| | ++------------+--------+----+---------------+--------------------+ +|Lab |String | | A - C |Lab ID | ++------------+--------+----+---------------+--------------------+ +|Technician |String | | |Technician name | ++------------+--------+----+---------------+--------------------+ +|Plot |Int | | 1 - 20 |Plot ID | ++------------+--------+----+---------------+--------------------+ +|Seed |String | | 6-character |Seed sample ID | +|sample | | | string | | ++------------+--------+----+---------------+--------------------+ +|Fault |Bool | | True, False |Environmental | +| | | | |Sensor Fault | ++------------+--------+----+---------------+--------------------+ +|Light |Decimal |klx | 0 - 100 |Light at plot. | +| | | | |blank on fault. | ++------------+--------+----+---------------+--------------------+ +|Humidity |Decimal |g/m³| 0.5 - 52.0 |Abs humidity at plot| +| | | | |blank on fault. | ++------------+--------+----+---------------+--------------------+ +|Temperature |Decimal |°C | 4 - 40 |Temperature at plot | +| | | | |blank on fault. | ++------------+--------+----+---------------+--------------------+ +|Blossoms |Int | | 0 - 1000 |No. blossoms in plot| ++------------+--------+----+---------------+--------------------+ +|Fruit |Int | | 0 - 1000 |No. fruits in plot | ++------------+--------+----+---------------+--------------------+ +|Plants |Int | | 0 - 20 |No. plants in plot | ++------------+--------+----+---------------+--------------------+ +|Max height |Decimal |cm | 0 - 1000 |Height of tallest | +| | | | |plant in plot | ++------------+--------+----+---------------+--------------------+ +|Min height |Decimal |cm | 0 - 1000 |Height of shortest | +| | | | |plant in plot | ++------------+--------+----+---------------+--------------------+ +|Median |Decimal |cm | 0 - 1000 |Median height of | +|height | | | |plants in plot | ++------------+--------+----+---------------+--------------------+ +|Notes |String | | |Miscellaneous notes | ++------------+--------+----+---------------+--------------------+ diff --git a/Chapter03/abq_data_entry_spec.rst b/Chapter03/abq_data_entry_spec.rst new file mode 100644 index 0000000..41d3c18 --- /dev/null +++ b/Chapter03/abq_data_entry_spec.rst @@ -0,0 +1,102 @@ +====================================== + ABQ Data Entry Program specification +====================================== + +Description +----------- +This program facilitates entry of laboratory observations +into a CSV file. + +Functionality Required +---------------------- + +The program must: + + * allow all relevant, valid data to be entered, + as per the data dictionary + * append entered data to a CSV file: + - The CSV file must have a filename of + abq_data_record_CURRENTDATE.csv, where CURRENTDATE is the date + of the laboratory observations in ISO format (Year-month-day) + - The CSV file must include all fields + listed in the data dictionary + - The CSV headers will avoid cryptic abbreviations + * enforce correct datatypes per field + +The program should try, whenever possible, to: + + * enforce reasonable limits on data entered, per the data dict + * Auto-fill data to save time + * Suggest likely correct values + * Provide a smooth and efficient workflow + * Store data in a format easily understandable by Python + +Functionality Not Required +-------------------------- + +The program does not need to: + + * Allow editing of data. + * Allow deletion of data. + +Users can perform both actions in LibreOffice if needed. + + +Limitations +----------- + +The program must: + + * Be efficiently operable by keyboard-only users. + * Be accessible to color blind users. + * Run on Debian GNU/Linux. + * Run acceptably on a low-end PC. + +Data Dictionary +--------------- ++------------+--------+----+---------------+--------------------+ +|Field | Type |Unit| Valid Values |Description | ++============+========+====+===============+====================+ +|Date |Date | | |Date of record | ++------------+--------+----+---------------+--------------------+ +|Time |Time | |8:00, 12:00, |Time period | +| | | |16:00, or 20:00| | ++------------+--------+----+---------------+--------------------+ +|Lab |String | | A - C |Lab ID | ++------------+--------+----+---------------+--------------------+ +|Technician |String | | |Technician name | ++------------+--------+----+---------------+--------------------+ +|Plot |Int | | 1 - 20 |Plot ID | ++------------+--------+----+---------------+--------------------+ +|Seed |String | | 6-character |Seed sample ID | +|sample | | | string | | ++------------+--------+----+---------------+--------------------+ +|Fault |Bool | | True, False |Environmental | +| | | | |Sensor Fault | ++------------+--------+----+---------------+--------------------+ +|Light |Decimal |klx | 0 - 100 |Light at plot. | +| | | | |blank on fault. | ++------------+--------+----+---------------+--------------------+ +|Humidity |Decimal |g/m³| 0.5 - 52.0 |Abs humidity at plot| +| | | | |blank on fault. | ++------------+--------+----+---------------+--------------------+ +|Temperature |Decimal |°C | 4 - 40 |Temperature at plot | +| | | | |blank on fault. | ++------------+--------+----+---------------+--------------------+ +|Blossoms |Int | | 0 - 1000 |No. blossoms in plot| ++------------+--------+----+---------------+--------------------+ +|Fruit |Int | | 0 - 1000 |No. fruits in plot | ++------------+--------+----+---------------+--------------------+ +|Plants |Int | | 0 - 20 |No. plants in plot | ++------------+--------+----+---------------+--------------------+ +|Max height |Decimal |cm | 0 - 1000 |Height of tallest | +| | | | |plant in plot | ++------------+--------+----+---------------+--------------------+ +|Min height |Decimal |cm | 0 - 1000 |Height of shortest | +| | | | |plant in plot | ++------------+--------+----+---------------+--------------------+ +|Median |Decimal |cm | 0 - 1000 |Median height of | +|height | | | |plants in plot | ++------------+--------+----+---------------+--------------------+ +|Notes |String | | |Miscellaneous notes | ++------------+--------+----+---------------+--------------------+ diff --git a/Chapter03/data_entry_app.py b/Chapter03/data_entry_app.py new file mode 100644 index 0000000..93109b3 --- /dev/null +++ b/Chapter03/data_entry_app.py @@ -0,0 +1,302 @@ +"""The ABQ Data Entry application + +Chapter 3 Version +""" + +# Tkinter imports +import tkinter as tk +from tkinter import ttk + +# For creating the filename +from datetime import datetime + +# For file operations +from pathlib import Path + +# For creating the CSV file +import csv + +# Create a dict to hold our variables +variables = dict() +# Variable to store the number of records saved +records_saved = 0 + +# Configure the root window +root = tk.Tk() +root.title('ABQ Data Entry Application') +root.columnconfigure(0, weight=1) + +# Application heading +ttk.Label( + root, + text="ABQ Data Entry Application", + font=("TkDefaultFont", 16) +).grid() + +#################### +# Data Record Form # +#################### + +# Build the data record form in a Frame +# to keep geometry management simpler +drf = ttk.Frame(root) +drf.grid(padx=10, sticky=(tk.E + tk.W)) +drf.columnconfigure(0, weight=1) + +############################## +# Record information Frame # +############################## +r_info = ttk.LabelFrame(drf, text='Record Information') +r_info.grid(sticky=(tk.W + tk.E)) +for i in range(3): + r_info.columnconfigure(i, weight=1 ) + +# Date +variables['Date'] = tk.StringVar() +ttk.Label(r_info, text='Date').grid( + row=0, column=0, sticky=(tk.W + tk.E) +) +ttk.Entry( + r_info, textvariable=variables['Date'] +).grid(row=1, column=0, sticky=(tk.W + tk.E)) + +# Time +time_values = ['8:00', '12:00', '16:00', '20:00'] +variables['Time'] = tk.StringVar() +ttk.Label(r_info, text='Time').grid(row=0, column=1) +ttk.Combobox( + r_info, textvariable=variables['Time'], values=time_values +).grid(row=1, column=1, sticky=(tk.W + tk.E)) + +# Technician +variables['Technician'] = tk.StringVar() +ttk.Label(r_info, text='Technician').grid(row=0, column=2) +ttk.Entry( + r_info, textvariable=variables['Technician'] +).grid(row=1, column=2, sticky=(tk.W + tk.E)) + +# Lab +variables['Lab'] = tk.StringVar() +ttk.Label(r_info, text='Lab').grid(row=2, column=0) +labframe = ttk.Frame(r_info) +for lab in ('A', 'B', 'C'): + ttk.Radiobutton( + labframe, value=lab, text=lab, variable=variables['Lab'] +).pack(side=tk.LEFT, expand=True) +labframe.grid(row=3, column=0, sticky=(tk.W + tk.E)) + +# Plot +variables['Plot'] = tk.IntVar() +ttk.Label(r_info, text='Plot').grid(row=2, column=1) +ttk.Combobox( + r_info, + textvariable=variables['Plot'], + values=list(range(1, 21)) +).grid(row=3, column=1, sticky=(tk.W + tk.E)) + +# Seed Sample +variables['Seed sample'] = tk.StringVar() +ttk.Label(r_info, text='Seed Sample').grid(row=2, column=2) +ttk.Entry( + r_info, + textvariable=variables['Seed sample'] +).grid(row=3, column=2, sticky=(tk.W + tk.E)) + +################################# +# Environment information Frame # +################################# +e_info = ttk.LabelFrame(drf, text="Environment Data") +e_info.grid(sticky=(tk.W + tk.E)) +for i in range(3): + e_info.columnconfigure(i, weight=1) + +# Humidity +variables['Humidity'] = tk.DoubleVar() +ttk.Label(e_info, text="Humidity (g/m³)").grid(row=0, column=0) +ttk.Spinbox( + e_info, textvariable=variables['Humidity'], + from_=0.5, to=52.0, increment=0.01, +).grid(row=1, column=0, sticky=(tk.W + tk.E)) + +# Light +variables['Light'] = tk.DoubleVar() +ttk.Label(e_info, text='Light (klx)').grid(row=0, column=1) +ttk.Spinbox( + e_info, textvariable=variables['Light'], + from_=0, to=100, increment=0.01 +).grid(row=1, column=1, sticky=(tk.W + tk.E)) + +# Temperature +variables['Temperature'] = tk.DoubleVar() +ttk.Label(e_info, text='Temperature (°C)').grid(row=0, column=2) +ttk.Spinbox( + e_info, textvariable=variables['Temperature'], + from_=4, to=40, increment=.01 +).grid(row=1, column=2, sticky=(tk.W + tk.E)) + +# Equipment Fault +variables['Equipment Fault'] = tk.BooleanVar(value=False) +ttk.Checkbutton( + e_info, variable=variables['Equipment Fault'], + text='Equipment Fault' +).grid(row=2, column=0, sticky=tk.W, pady=5) + + +########################### +# Plant information Frame # +########################### +p_info = ttk.LabelFrame(drf, text="Plant Data") +p_info.grid(sticky=(tk.W + tk.E)) +for i in range(3): + p_info.columnconfigure(i, weight=1) + +# Plants +variables['Plants'] = tk.IntVar() +ttk.Label(p_info, text='Plants').grid(row=0, column=0) +ttk.Spinbox( + p_info, textvariable=variables['Plants'], + from_=0, to=20, increment=1 +).grid(row=1, column=0, sticky=(tk.W + tk.E)) + +# Blossoms +variables['Blossoms'] = tk.IntVar() +ttk.Label(p_info, text='Blossoms').grid(row=0, column=1) +ttk.Spinbox( + p_info, textvariable=variables['Blossoms'], + from_=0, to=1000, increment=1 +).grid(row=1, column=1, sticky=(tk.W + tk.E)) + +# Fruit +variables['Fruit'] = tk.IntVar() +ttk.Label(p_info, text='Fruit').grid(row=0, column=2) +ttk.Spinbox( + p_info, textvariable=variables['Fruit'], + from_=0, to=1000, increment=1 +).grid(row=1, column=2, sticky=(tk.W + tk.E)) + +# Min Height +variables['Min Height'] = tk.DoubleVar() +ttk.Label(p_info, text='Min Height (cm)').grid(row=2, column=0) +ttk.Spinbox( + p_info, textvariable=variables['Min Height'], + from_=0, to=1000, increment=0.01 +).grid(row=3, column=0, sticky=(tk.W + tk.E)) + +# Max Height +variables['Max Height'] = tk.DoubleVar() +ttk.Label(p_info, text='Max Height (cm)').grid(row=2, column=1) +ttk.Spinbox( + p_info, textvariable=variables['Max Height'], + from_=0, to=1000, increment=0.01 +).grid(row=3, column=1, sticky=(tk.W + tk.E)) + +# Med Height +variables['Med Height'] = tk.DoubleVar() +ttk.Label(p_info, text='Median Height (cm)').grid(row=2, column=2) +ttk.Spinbox( + p_info, textvariable=variables['Med Height'], + from_=0, to=1000, increment=0.01 +).grid(row=3, column=2, sticky=(tk.W + tk.E)) + + +################# +# Notes Section # +################# + +ttk.Label(drf, text="Notes").grid() +# we can't use a variable for a Text input +notes_inp = tk.Text(drf, width=75, height=10) +notes_inp.grid(sticky=(tk.W + tk.E)) + + +######################## +# Save & Reset Buttons # +######################## +buttons = ttk.Frame(drf) +buttons.grid(sticky=tk.E + tk.W) +save_button = ttk.Button(buttons, text='Save') +save_button.pack(side=tk.RIGHT) + +reset_button = ttk.Button(buttons, text='Reset') +reset_button.pack(side=tk.RIGHT) + +############## +# Status Bar # +############## + +# This is attached to the root window, not the form! +status_variable = tk.StringVar() +ttk.Label( + root, textvariable=status_variable +).grid(sticky=tk.W + tk.E, row=99, padx=10) + +############# +# Functions # +############# + + +def on_reset(): + """Called when reset button is clicked, or after save""" + + for variable in variables.values(): + if isinstance(variable, tk.BooleanVar): + variable.set(False) + else: + variable.set('') + + # reset notes_inp + notes_inp.delete('1.0', tk.END) + + +reset_button.configure(command=on_reset) + +def on_save(): + """Handle save button clicks""" + + global records_saved + # For now, we save to a hardcoded filename with a datestring. + # If it doesnt' exist, create it, + # otherwise just append to the existing file + datestring = datetime.today().strftime("%Y-%m-%d") + filename = f"abq_data_record_{datestring}.csv" + newfile = not Path(filename).exists() + + # get the data from the variables + data = dict() + for key, variable in variables.items(): + try: + data[key] = variable.get() + except tk.TclError: + status_variable.set( + f'Error in field: {key}. Data was not saved!') + return + # get the Text widget contents separately + data['Notes'] = notes_inp.get('1.0', tk.END) + + # clear the environmental data when there is a sensor fault + if data['Equipment Fault']: + data['Light'] = '' + data['Humidity'] = '' + data['Temperature'] = '' + + # Append the record to a CSV + with open(filename, 'a') as fh: + csvwriter = csv.DictWriter(fh, fieldnames=data.keys()) + if newfile: + csvwriter.writeheader() + csvwriter.writerow(data) + + records_saved += 1 + status_variable.set( + f"{records_saved} records saved this session") + on_reset() + +save_button.configure(command=on_save) + +# Reset the form +on_reset() + +#################### +# Execute Mainloop # +#################### +root.mainloop() diff --git a/Chapter03/ttk_tour.py b/Chapter03/ttk_tour.py new file mode 100644 index 0000000..30f0a95 --- /dev/null +++ b/Chapter03/ttk_tour.py @@ -0,0 +1,135 @@ +"""A tour of ttk widgets we can use in our application""" + +import tkinter as tk +from tkinter import ttk + + +root = tk.Tk() + +my_string_var = tk.StringVar(value='Test') +my_int_var = tk.IntVar() +my_dbl_var = tk.DoubleVar() +my_bool_var = tk.BooleanVar() +pack_args = {"padx": 20, "pady": 20} + +def my_callback(*_): + print("Callback called!") + +# Entry + +myentry = ttk.Entry(root, textvariable=my_string_var, width=20) +myentry.pack(**pack_args) + +# Spinbox + +myspinbox = ttk.Spinbox( + root, + from_=0, to=100, increment=.01, + textvariable=my_int_var, + command=my_callback +) +myspinbox.pack(**pack_args) + +# Checkbutton + +mycheckbutton = ttk.Checkbutton( + root, + variable=my_bool_var, + textvariable=my_string_var, + command=my_callback +) +mycheckbutton.pack(**pack_args) + +mycheckbutton2 = ttk.Checkbutton( + root, + variable=my_dbl_var, + text='Would you like Pi?', + onvalue=3.14159, + offvalue=0, + underline=15 +) +mycheckbutton2.pack(**pack_args) + +# Radiobutton +buttons = tk.Frame(root) +buttons.pack() + +r1 = ttk.Radiobutton( + buttons, + variable=my_int_var, + value=1, + text='One' +) +r2 = ttk.Radiobutton( + buttons, + variable=my_int_var, + value=2, + text='Two' +) +r1.pack(side='left') +r2.pack(side='left') + +# Combobox Widget + +mycombo = ttk.Combobox( + root, textvariable=my_string_var, + values=['This option', 'That option', 'Another option'] +) +mycombo.pack(**pack_args) + +# Text widget + +mytext = tk.Text( + root, + undo=True, maxundo=100, + spacing1=10, spacing2=2, spacing3=5, + height=5, wrap='char' +) +mytext.pack(**pack_args) + +# insert a string at the beginning +mytext.insert('1.0', "I love my text widget!") +# insert a string into the current text +mytext.insert('1.2', 'REALLY ') +# get the whole string +mytext.get('1.0', tk.END) +# delete the last character. +# Note that there is always a newline character inserted automatically +# at the end of the input, so we backup 2 chars instead of 1. +mytext.delete('end - 2 chars') + + +# Button widget + +mybutton = ttk.Button( + root, + command=my_callback, + text='Click Me!', + default='active' +) +mybutton.pack(**pack_args) + +# LabelFrame Widget +mylabelframe = ttk.LabelFrame( + root, + text='Button frame' +) + +b1 = ttk.Button( + mylabelframe, + text='Button 1' +) +b2 = ttk.Button( + mylabelframe, + text='Button 2' +) +b1.pack() +b2.pack() +mylabelframe.pack(**pack_args) + +# Label Widget + +mylabel = ttk.Label(root, text='This is a label') +mylabel.pack(**pack_args) + +root.mainloop() diff --git a/Chapter04/banana.py b/Chapter04/banana.py new file mode 100644 index 0000000..acbe6ee --- /dev/null +++ b/Chapter04/banana.py @@ -0,0 +1,109 @@ +"""A sample class definition""" + +#################### +# Class Definition # +#################### + +class Banana: + """A tasty tropical fruit""" + +#################### +# Class Attributes # +#################### + + # class variables are shared by all objects in the class + food_group = 'fruit' + colors = ['green', 'green-yellow', 'yellow', 'brown spotted', 'black'] + +###################### +# Methods, instances # +###################### + + # Instance methods get the object as an automatic first argument + # We traditionally call it `self` + def peel(self): + """Peel the banana""" + self.peeled = True + + def set_color(self, color): + """Set the color of the banana""" + if color in self.colors: + # create instance variables by attaching them to `self` + self.color = color + else: + raise ValueError(f'A banana cannot be {color}!') + + # Class methods only have access to the class, not the instance + # The class is passed in as a first argument + @classmethod + def check_color(cls, color): + """Test a color string to see if it is valid.""" + return color in cls.colors + + @classmethod + def make_greenie(cls): + """Create a green banana object""" + banana = cls() + banana.set_color('green') + return banana + + # Static methods have no access to class or instance + @staticmethod + def estimate_calories(num_bananas): + """Given `num_bananas`, estimate the number of calories""" + return num_bananas * 105 + +################# +# Magic Methods # +################# + + # "Magic methods" define how our object responds to operators and built-ins + def __str__(self): + # "Magic Attributes" contain metadata about the object or class + return f'A {self.color} {self.__class__.__name__}' + + # __init__ is the most important Magic Method + def __init__(self, color='green'): + + if not self.check_color(color): + raise ValueError(f'A {self.__class__.__name__} cannot be {color}') + self.color = color + + # instance vars should be first declared in __init__ when possible + self.peeled = False + + +################################# +# Private and Protected Members # +################################# + __ripe_colors = ['yellow', 'brown spotted'] + + def _is_ripe(self): + """Protected method to see if the banana is ripe.""" + return self.color in self.__ripe_colors + + + def can_eat(self, must_be_ripe=False): + """Check if I can eat the banana.""" + if must_be_ripe and not self._is_ripe(): + return False + return True + +################ +# Sub-classing # +################ + +class RedBanana(Banana): + """Bananas of the red variety""" + + colors = ['green', 'orange', 'red', 'brown', 'black'] + botanical_name = 'red dacca' + + def set_color(self, color): + if color not in self.colors: + raise ValueError(f'A Red Banana cannot be {color}!') + + def peel(self): + # Use `super()` to access the parent class methods + super().peel() + print('It looks like a regular banana inside!') diff --git a/Chapter04/data_entry_app.py b/Chapter04/data_entry_app.py new file mode 100644 index 0000000..31e9e11 --- /dev/null +++ b/Chapter04/data_entry_app.py @@ -0,0 +1,337 @@ +"""The ABQ Data Entry Application + +Chapter 4 version +""" + +from datetime import datetime +from pathlib import Path +import csv +import tkinter as tk +from tkinter import ttk + +############################################## +# Fixing the Text widget to accept variables # +############################################## +class BoundText(tk.Text): + """A Text widget with a bound variable.""" + + def __init__(self, *args, textvariable=None, **kwargs): + super().__init__(*args, **kwargs) + self._variable = textvariable + self._modifying = False + if self._variable: + # insert any default value + self.insert('1.0', self._variable.get()) + self._variable.trace_add('write', self._set_content) + self.bind('<>', self._set_var) + + def _clear_modified_flag(self): + # This also triggers a '<>' Event + self.tk.call(self._w, 'edit', 'modified', 0) + + def _set_var(self, *_): + """Set the variable to the text contents""" + if self._modifying: + return + self._modifying = True + # remove trailing newline from content + content = self.get('1.0', tk.END)[:-1] + self._variable.set(content) + self._clear_modified_flag() + self._modifying = False + + def _set_content(self, *_): + """Set the text contents to the variable""" + if self._modifying: + return + self._modifying = True + self.delete('1.0', tk.END) + self.insert('1.0', self._variable.get()) + self._modifying = False + + +######################################### +# Creating a LabelInput compound widget # +######################################### + +class LabelInput(tk.Frame): + """A widget containing a label and input together.""" + + def __init__( + self, parent, label, var, input_class=ttk.Entry, + input_args=None, label_args=None, **kwargs + ): + super().__init__(parent, **kwargs) + input_args = input_args or {} + label_args = label_args or {} + self.variable = var + self.variable.label_widget = self + + # setup the label + if input_class in (ttk.Checkbutton, ttk.Button): + # Buttons don't need labels, they're built-in + input_args["text"] = label + else: + self.label = ttk.Label(self, text=label, **label_args) + self.label.grid(row=0, column=0, sticky=(tk.W + tk.E)) + + # setup the variable + if input_class in (ttk.Checkbutton, ttk.Button, ttk.Radiobutton): + input_args["variable"] = self.variable + else: + input_args["textvariable"] = self.variable + + # Setup the input + if input_class == ttk.Radiobutton: + # for Radiobutton, create one input per value + self.input = tk.Frame(self) + for v in input_args.pop('values', []): + button = ttk.Radiobutton( + self.input, value=v, text=v, **input_args) + button.pack(side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x') + else: + self.input = input_class(self, **input_args) + + self.input.grid(row=1, column=0, sticky=(tk.W + tk.E)) + self.columnconfigure(0, weight=1) + + def grid(self, sticky=(tk.E + tk.W), **kwargs): + """Override grid to add default sticky values""" + super().grid(sticky=sticky, **kwargs) + +####################################### +# Building our application components # +####################################### + +class DataRecordForm(tk.Frame): + """The input form for our widgets""" + + def _add_frame(self, label, cols=3): + """Add a labelframe to the form""" + + frame = ttk.LabelFrame(self, text=label) + frame.grid(sticky=tk.W + tk.E) + for i in range(cols): + frame.columnconfigure(i, weight=1) + return frame + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Create a dict to keep track of input widgets + self._vars = { + 'Date': tk.StringVar(), + 'Time': tk.StringVar(), + 'Technician': tk.StringVar(), + 'Lab': tk.StringVar(), + 'Plot': tk.IntVar(), + 'Seed Sample': tk.StringVar(), + 'Humidity': tk.DoubleVar(), + 'Light': tk.DoubleVar(), + 'Temperature': tk.DoubleVar(), + 'Equipment Fault': tk.BooleanVar(), + 'Plants': tk.IntVar(), + 'Blossoms': tk.IntVar(), + 'Fruit': tk.IntVar(), + 'Min Height': tk.DoubleVar(), + 'Max Height': tk.DoubleVar(), + 'Med Height': tk.DoubleVar(), + 'Notes': tk.StringVar() + } + + # Build the form + self.columnconfigure(0, weight=1) + + # Record info section + r_info = self._add_frame("Record Information") + + # line 1 + LabelInput( + r_info, "Date", var=self._vars['Date'] + ).grid(row=0, column=0) + LabelInput( + r_info, "Time", input_class=ttk.Combobox, var=self._vars['Time'], + input_args={"values": ["8:00", "12:00", "16:00", "20:00"]} + ).grid(row=0, column=1) + LabelInput( + r_info, "Technician", var=self._vars['Technician'] + ).grid(row=0, column=2) + + # line 2 + LabelInput( + r_info, "Lab", input_class=ttk.Radiobutton, + var=self._vars['Lab'], input_args={"values": ["A", "B", "C"]} + ).grid(row=1, column=0) + LabelInput( + r_info, "Plot", input_class=ttk.Combobox, var=self._vars['Plot'], + input_args={"values": list(range(1, 21))} + ).grid(row=1, column=1) + LabelInput( + r_info, "Seed Sample", var=self._vars['Seed Sample'] + ).grid(row=1, column=2) + + + + # Environment Data + e_info = self._add_frame("Environment Data") + + LabelInput( + e_info, "Humidity (g/m³)", + input_class=ttk.Spinbox, var=self._vars['Humidity'], + input_args={"from_": 0.5, "to": 52.0, "increment": .01} + ).grid(row=0, column=0) + LabelInput( + e_info, "Light (klx)", input_class=ttk.Spinbox, + var=self._vars['Light'], + input_args={"from_": 0, "to": 100, "increment": .01} + ).grid(row=0, column=1) + LabelInput( + e_info, "Temperature (°C)", + input_class=ttk.Spinbox, var=self._vars['Temperature'], + input_args={"from_": 4, "to": 40, "increment": .01} + ).grid(row=0, column=2) + LabelInput( + e_info, "Equipment Fault", + input_class=ttk.Checkbutton, var=self._vars['Equipment Fault'] + ).grid(row=1, column=0, columnspan=3) + + + # Plant Data section + p_info = self._add_frame("Plant Data") + + LabelInput( + p_info, "Plants", input_class=ttk.Spinbox, var=self._vars['Plants'], + input_args={"from_": 0, "to": 20} + ).grid(row=0, column=0) + LabelInput( + p_info, "Blossoms", input_class=ttk.Spinbox, + var=self._vars['Blossoms'], + input_args={"from_": 0, "to": 1000} + ).grid(row=0, column=1) + LabelInput( + p_info, "Fruit", input_class=ttk.Spinbox, var=self._vars['Fruit'], + input_args={"from_": 0, "to": 1000} + ).grid(row=0, column=2) + + # Height data + LabelInput( + p_info, "Min Height (cm)", + input_class=ttk.Spinbox, var=self._vars['Min Height'], + input_args={"from_": 0, "to": 1000, "increment": .01} + ).grid(row=1, column=0) + LabelInput( + p_info, "Max Height (cm)", + input_class=ttk.Spinbox, var=self._vars['Max Height'], + input_args={"from_": 0, "to": 1000, "increment": .01} + ).grid(row=1, column=1) + LabelInput( + p_info, "Median Height (cm)", + input_class=ttk.Spinbox, var=self._vars['Med Height'], + input_args={"from_": 0, "to": 1000, "increment": .01} + ).grid(row=1, column=2) + + + # Notes section + LabelInput( + self, "Notes", + input_class=BoundText, var=self._vars['Notes'], + input_args={"width": 75, "height": 10} + ).grid(sticky=(tk.W + tk.E), row=3, column=0) + + # buttons + buttons = ttk.Frame(self) + buttons.grid(sticky=tk.W + tk.E, row=4) + self.savebutton = ttk.Button( + buttons, text="Save", command=self.master.on_save) + self.savebutton.pack(side=tk.RIGHT) + + self.resetbutton = ttk.Button( + buttons, text="Reset", command=self.reset) + self.resetbutton.pack(side=tk.RIGHT) + + # default the form + self.reset() + + def get(self): + """Retrieve data from form as a dict""" + + # We need to retrieve the data from Tkinter variables + # and place it in regular Python objects + data = dict() + for key, variable in self._vars.items(): + try: + data[key] = variable.get() + except tk.TclError: + message = f'Error in field: {key}. Data was not saved!' + raise ValueError(message) + + return data + + def reset(self): + """Resets the form entries""" + + # clear all values + for var in self._vars.values(): + if isinstance(var, tk.BooleanVar): + var.set(False) + else: + var.set('') + + +class Application(tk.Tk): + """Application root window""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.title("ABQ Data Entry Application") + self.columnconfigure(0, weight=1) + + ttk.Label( + self, text="ABQ Data Entry Application", + font=("TkDefaultFont", 16) + ).grid(row=0) + + self.recordform = DataRecordForm(self) + self.recordform.grid(row=1, padx=10, sticky=(tk.W + tk.E)) + + # status bar + self.status = tk.StringVar() + ttk.Label( + self, textvariable=self.status + ).grid(sticky=(tk.W + tk.E), row=2, padx=10) + + self._records_saved = 0 + + def on_save(self): + """Handles save button clicks""" + + # For now, we save to a hardcoded filename with a datestring. + # If it doesnt' exist, create it, + # otherwise just append to the existing file + datestring = datetime.today().strftime("%Y-%m-%d") + filename = "abq_data_record_{}.csv".format(datestring) + newfile = not Path(filename).exists() + + try: + data = self.recordform.get() + except ValueError as e: + self.status.set(str(e)) + return + + with open(filename, 'a') as fh: + csvwriter = csv.DictWriter(fh, fieldnames=data.keys()) + if newfile: + csvwriter.writeheader() + csvwriter.writerow(data) + + self._records_saved += 1 + self.status.set( + "{} records saved this session".format(self._records_saved)) + self.recordform.reset() + + +if __name__ == "__main__": + + app = Application() + app.mainloop() diff --git a/Chapter04/discrete_label_input_example.py b/Chapter04/discrete_label_input_example.py new file mode 100644 index 0000000..45a9af7 --- /dev/null +++ b/Chapter04/discrete_label_input_example.py @@ -0,0 +1,10 @@ +from tkinter import Frame, Label, Entry + +form = Frame() +label = Label(form, text='Name') +name_input = Entry(form) +label.grid(row=0, column=0) +name_input.grid(row=1, column=0) + +form.pack() +form.mainloop() diff --git a/Chapter04/tkinter_classes_demo.py b/Chapter04/tkinter_classes_demo.py new file mode 100644 index 0000000..ee3d1bf --- /dev/null +++ b/Chapter04/tkinter_classes_demo.py @@ -0,0 +1,140 @@ +"""Demonstration of using classes with tkinter""" +import tkinter as tk +import json + + +############################# +# Improving Tkinter classes # +############################# + +# Create a JSONVar + +class JSONVar(tk.StringVar): + """A Tk variable that can hold dicts and lists""" + + def __init__(self, *args, **kwargs): + if kwargs.get('value'): + kwargs['value'] = json.dumps(kwargs['value']) + super().__init__(*args, **kwargs) + + def set(self, value, *args, **kwargs): + string = json.dumps(value) + super().set(string, *args, **kwargs) + + def get(self, *args, **kwargs): + """Get the list or dict value""" + string = super().get(*args, **kwargs) + return json.loads(string) + + +# Uncomment to try it, but comment this code before sub-classing Tk +#root = tk.Tk() +#var1 = JSONVar(root) +#var1.set([1, 2, 3, 4, 5]) +# +#var2 = JSONVar(root, value={'a': 10, 'b': 15}) +#print("Var1: ", var1.get()[1]) +#print("Var2: ", var2.get()['b']) +#root.mainloop() +#exit() + +############################# +# Creating compound widgets # +############################# + +class LabelInput(tk.Frame): + """A label and input combined together""" + + def __init__(self, parent, label, inp_class, inp_args, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + self.label = tk.Label(self, text=label, anchor='w') + self.input = inp_class(self, **inp_args) + + # side-by-side layout + self.columnconfigure(1, weight=1) + self.label.grid(sticky=tk.E + tk.W) + self.input.grid(row=0, column=1, sticky=tk.E + tk.W) + + # label-on-top layout + #self.columnconfigure(0, weight=1) + #self.label.grid(sticky=tk.E + tk.W) + #self.input.grid(sticky=tk.E + tk.W) + + + +# Uncomment to try it, but comment this code again before sub-classing tk +#root = tk.Tk() +#li1 = LabelInput(root, 'Name', tk.Entry, {'bg': 'red'}) +#li1.grid() +#age_var = tk.IntVar(root, value=21) +#li2 = LabelInput( +# root, +# 'Age', +# tk.Spinbox, +# {'textvariable': age_var, 'from_': 10, 'to': 150} +#) +#li2.grid() +#root.mainloop() +#exit() + +############################## +# Building component objects # +############################## + +class MyForm(tk.Frame): + + def __init__(self, parent, data_var, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + self.data_var = data_var + self._vars = { + 'name': tk.StringVar(), + 'age': tk.IntVar(value=2) + } + LabelInput( + self, + 'Name', + tk.Entry, + {'textvariable': self._vars['name']} + ).grid(sticky=tk.E + tk.W) + LabelInput( + self, + 'Age', + tk.Spinbox, + {'textvariable': self._vars['age'], 'from_': 10, 'to': 150} + ).grid(sticky=tk.E + tk.W) + tk.Button(self, text='Submit', command=self._on_submit).grid() + + def _on_submit(self): + data = { key: var.get() for key, var in self._vars.items() } + self.data_var.set(data) + + +# We can even subclass Tk + +class Application(tk.Tk): + """A simple form application""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.jsonvar = JSONVar() + self.output_var = tk.StringVar() + tk.Label(self, text='Please fill the form').grid(sticky='ew') + MyForm(self, self.jsonvar).grid(sticky='nsew') + tk.Label(self, textvariable=self.output_var).grid(sticky='ew') + self.columnconfigure(0, weight=1) + self.rowconfigure(1, weight=1) + + self.jsonvar.trace_add('write', self._on_data_change) + + def _on_data_change(self, *args, **kwargs): + data = self.jsonvar.get() + output = ''.join([ + f'{key} = {value}\n' + for key, value in data.items() + ]) + self.output_var.set(output) + +#root.mainloop() + +app = Application() +app.mainloop() From 9e52e13635106d4e402590377cad7fe0f8c5ad0d Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Tue, 4 May 2021 13:44:53 -0500 Subject: [PATCH 03/32] added chapter 5 code --- Chapter05/DateEntry.py | 61 +++ Chapter05/MixinExample.py | 34 ++ Chapter05/abq_data_entry_spec.rst | 96 ++++ Chapter05/data_entry_app.py | 691 +++++++++++++++++++++++++++++ Chapter05/five_char_entry.py | 28 ++ Chapter05/five_char_entry_class.py | 34 ++ Chapter05/validate_demo.py | 56 +++ 7 files changed, 1000 insertions(+) create mode 100644 Chapter05/DateEntry.py create mode 100644 Chapter05/MixinExample.py create mode 100644 Chapter05/abq_data_entry_spec.rst create mode 100644 Chapter05/data_entry_app.py create mode 100644 Chapter05/five_char_entry.py create mode 100644 Chapter05/five_char_entry_class.py create mode 100644 Chapter05/validate_demo.py diff --git a/Chapter05/DateEntry.py b/Chapter05/DateEntry.py new file mode 100644 index 0000000..a259fac --- /dev/null +++ b/Chapter05/DateEntry.py @@ -0,0 +1,61 @@ +import tkinter as tk +from tkinter import ttk +from datetime import datetime + +class DateEntry(ttk.Entry): + """An Entry for ISO-style dates (Year-month-day)""" + + def __init__(self, parent, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + self.configure( + validate='all', + validatecommand=(self.register(self._validate), '%S', '%i', '%V', '%d'), + invalidcommand=(self.register(self._on_invalid), '%V') + ) + self.error = tk.StringVar() + + def _toggle_error(self, error=''): + self.error.set(error) + if error: + self.config(foreground='red') + else: + self.config(foreground='black') + + def _validate(self, char, index, event, action): + + # reset error state + self._toggle_error() + valid = True + + # ISO dates only need digits and hyphens + if event == 'key': + if action == '0': + valid = True + elif index in ('0', '1', '2', '3', '5', '6', '8', '9'): + valid = char.isdigit() + elif index in ('4', '7'): + valid = char == '-' + else: + valid = False + elif event == 'focusout': + try: + datetime.strptime(self.get(), '%Y-%m-%d') + except ValueError: + valid = False + return valid + + def _on_invalid(self, event): + if event != 'key': + self._toggle_error('Not a valid date') + +if __name__ == '__main__': + root = tk.Tk() + entry = DateEntry(root) + entry.pack() + ttk.Label( + textvariable=entry.error, foreground='red' + ).pack() + + # add this so we can unfocus the DateEntry + ttk.Entry(root).pack() + root.mainloop() diff --git a/Chapter05/MixinExample.py b/Chapter05/MixinExample.py new file mode 100644 index 0000000..f1a1b5b --- /dev/null +++ b/Chapter05/MixinExample.py @@ -0,0 +1,34 @@ +"""This example shows the use of a mixin class""" + +class Fruit(): + + _taste = 'sweet' + + def taste(self): + print(f'It tastes {self._taste}') + +class PeelableMixin(): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._peeled = False + + def peel(self): + self._peeled = True + + def taste(self): + if not self._peeled: + print('I will peel it first') + self.peel() + super().taste() + +class Plantain(PeelableMixin, Fruit): + + _taste = 'starchy' + + def peel(self): + print('It has a tough peel!') + super().peel() + +plantain = Plantain() +plantain.taste() diff --git a/Chapter05/abq_data_entry_spec.rst b/Chapter05/abq_data_entry_spec.rst new file mode 100644 index 0000000..d199a73 --- /dev/null +++ b/Chapter05/abq_data_entry_spec.rst @@ -0,0 +1,96 @@ +====================================== + ABQ Data Entry Program specification +====================================== + + +Description +----------- +The program is being created to minimize data entry errors for laboratory measurements. + +Functionality Required +---------------------- + +The program must: + +* allow all relevant, valid data to be entered, as per the field chart +* append entered data to a CSV file + - The CSV file must have a filename of abq_data_record_CURRENTDATE.csv, + where CURRENTDATE is the date of the checks in ISO format (Year-month-day) + - The CSV file must have all the fields as per the chart +* enforce correct datatypes per field +* have inputs that: + - ignore meaningless keystrokes + - display an error if the value is invalid on focusout + - display an error if a required field is empty on focusout +* prevent saving the record when errors are present + +The program should try, whenever possible, to: + +* enforce reasonable limits on data entered +* Auto-fill data +* Suggest likely correct values +* Provide a smooth and efficient workflow + +Functionality Not Required +-------------------------- + +The program does not need to: + +* Allow editing of data. This can be done in LibreOffice if necessary. +* Allow deletion of data. + +Limitations +----------- + +The program must: + +* Be efficiently operable by keyboard-only users. +* Be accessible to color blind users. +* Run on Debian Linux. +* Run acceptably on a low-end PC. + +Data Dictionary +--------------- ++------------+----------+------+-------------+------------------------+ +|Field | Datatype | Units| Range |Descripton | ++============+==========+======+=============+========================+ +|Date |Date | | |Date of record | ++------------+----------+------+-------------+------------------------+ +|Time |Time | |8:00, 12:00, |Time period | +| | | |16:00, 20:00 | | ++------------+----------+------+-------------+------------------------+ +|Lab |String | | A - C |Lab ID | ++------------+----------+------+-------------+------------------------+ +|Technician |String | | |Technician name | ++------------+----------+------+-------------+------------------------+ +|Plot |Int | | 1 - 20 |Plot ID | ++------------+----------+------+-------------+------------------------+ +|Seed |String | | |Seed sample ID | +|sample | | | | | ++------------+----------+------+-------------+------------------------+ +|Fault |Bool | | |Fault on environmental | +| | | | |sensor | ++------------+----------+------+-------------+------------------------+ +|Light |Decimal |klx | 0 - 100 |Light at plot | ++------------+----------+------+-------------+------------------------+ +|Humidity |Decimal |g/m³ | 0.5 - 52.0 |Abs humidity at plot | ++------------+----------+------+-------------+------------------------+ +|Temperature |Decimal |°C | 4 - 40 |Temperature at plot | ++------------+----------+------+-------------+------------------------+ +|Blossoms |Int | | 0 - 1000 |Num of blossoms in plot | ++------------+----------+------+-------------+------------------------+ +|Fruit |Int | | 0 - 1000 |Num of fruits in plot | ++------------+----------+------+-------------+------------------------+ +|Plants |Int | | 0 - 20 |Num of plants in plot | ++------------+----------+------+-------------+------------------------+ +|Max height |Decimal |cm | 0 - 1000 |Height of tallest | +| | | | |plant in plot | ++------------+----------+------+-------------+------------------------+ +|Min height |Decimal |cm | 0 - 1000 |Height of shortest | +| | | | |plant in plot | ++------------+----------+------+-------------+------------------------+ +|Median |Decimal |cm | 0 - 1000 |Median height of | +|height | | | |plants in plot | ++------------+----------+------+-------------+------------------------+ +|Notes |String | | |Miscellaneous notes | ++------------+----------+------+-------------+------------------------+ diff --git a/Chapter05/data_entry_app.py b/Chapter05/data_entry_app.py new file mode 100644 index 0000000..a164e94 --- /dev/null +++ b/Chapter05/data_entry_app.py @@ -0,0 +1,691 @@ +"""ABQ Data Entry + +Chapter 5 version +""" +import tkinter as tk +from tkinter import ttk +from datetime import datetime +from pathlib import Path +import csv +from decimal import Decimal, InvalidOperation + + +################## +# Widget Classes # +################## + +class ValidatedMixin: + """Adds a validation functionality to an input widget""" + + def __init__(self, *args, error_var=None, **kwargs): + self.error = error_var or tk.StringVar() + super().__init__(*args, **kwargs) + + vcmd = self.register(self._validate) + invcmd = self.register(self._invalid) + + self.configure( + validate='all', + validatecommand=(vcmd, '%P', '%s', '%S', '%V', '%i', '%d'), + invalidcommand=(invcmd, '%P', '%s', '%S', '%V', '%i', '%d') + ) + + def _toggle_error(self, on=False): + self.configure(foreground=('red' if on else 'black')) + + def _validate(self, proposed, current, char, event, index, action): + """The validation method. + + Don't override this, override _key_validate, and _focus_validate + """ + self.error.set('') + self._toggle_error() + + valid = True + # if the widget is disabled, don't validate + state = str(self.configure('state')[-1]) + if state == tk.DISABLED: + return valid + + if event == 'focusout': + valid = self._focusout_validate(event=event) + elif event == 'key': + valid = self._key_validate( + proposed=proposed, + current=current, + char=char, + event=event, + index=index, + action=action + ) + return valid + + def _focusout_validate(self, **kwargs): + return True + + def _key_validate(self, **kwargs): + return True + + def _invalid(self, proposed, current, char, event, index, action): + if event == 'focusout': + self._focusout_invalid(event=event) + elif event == 'key': + self._key_invalid( + proposed=proposed, + current=current, + char=char, + event=event, + index=index, + action=action + ) + + def _focusout_invalid(self, **kwargs): + """Handle invalid data on a focus event""" + self._toggle_error(True) + + def _key_invalid(self, **kwargs): + """Handle invalid data on a key event. By default we want to do nothing""" + pass + + def trigger_focusout_validation(self): + valid = self._validate('', '', '', 'focusout', '', '') + if not valid: + self._focusout_invalid(event='focusout') + return valid + + + +class RequiredEntry(ValidatedMixin, ttk.Entry): + """An Entry that requires a value""" + + def _focusout_validate(self, event): + valid = True + if not self.get(): + valid = False + self.error.set('A value is required') + return valid + + +class DateEntry(ValidatedMixin, ttk.Entry): + """An Entry that only accepts ISO Date strings""" + + def _key_validate(self, action, index, char, **kwargs): + valid = True + + if action == '0': # This is a delete action + valid = True + elif index in ('0', '1', '2', '3', '5', '6', '8', '9'): + valid = char.isdigit() + elif index in ('4', '7'): + valid = char == '-' + else: + valid = False + return valid + + def _focusout_validate(self, event): + valid = True + if not self.get(): + self.error.set('A value is required') + valid = False + try: + datetime.strptime(self.get(), '%Y-%m-%d') + except ValueError: + self.error.set('Invalid date') + valid = False + return valid + + +class ValidatedCombobox(ValidatedMixin, ttk.Combobox): + """A combobox that only takes values from its string list""" + + def _key_validate(self, proposed, action, **kwargs): + valid = True + # if the user tries to delete, + # just clear the field + if action == '0': + self.set('') + return True + + # get our values list + values = self.cget('values') + # Do a case-insensitve match against the entered text + matching = [ + x for x in values + if x.lower().startswith(proposed.lower()) + ] + if len(matching) == 0: + valid = False + elif len(matching) == 1: + self.set(matching[0]) + self.icursor(tk.END) + valid = False + return valid + + def _focusout_validate(self, **kwargs): + valid = True + if not self.get(): + valid = False + self.error.set('A value is required') + return valid + + +class ValidatedSpinbox(ValidatedMixin, ttk.Spinbox): + """A Spinbox that only accepts Numbers""" + + def __init__(self, *args, min_var=None, max_var=None, + focus_update_var=None, from_='-Infinity', to='Infinity', **kwargs + ): + super().__init__(*args, from_=from_, to=to, **kwargs) + increment = Decimal(str(kwargs.get('increment', '1.0'))) + self.precision = increment.normalize().as_tuple().exponent + # there should always be a variable, + # or some of our code will fail + self.variable = kwargs.get('textvariable') + if not self.variable: + self.variable = tk.DoubleVar() + self.configure(textvariable=self.variable) + + if min_var: + self.min_var = min_var + self.min_var.trace_add('write', self._set_minimum) + if max_var: + self.max_var = max_var + self.max_var.trace_add('write', self._set_maximum) + self.focus_update_var = focus_update_var + self.bind('', self._set_focus_update_var) + + def _set_focus_update_var(self, event): + value = self.get() + if self.focus_update_var and not self.error.get(): + self.focus_update_var.set(value) + + def _set_minimum(self, *args): + current = self.get() + try: + new_min = self.min_var.get() + self.config(from_=new_min) + except (tk.TclError, ValueError): + pass + if not current: + self.delete(0, tk.END) + else: + self.variable.set(current) + self.trigger_focusout_validation() + + def _set_maximum(self, *args): + current = self.get() + try: + new_max = self.max_var.get() + self.config(to=new_max) + except (tk.TclError, ValueError): + pass + if not current: + self.delete(0, tk.END) + else: + self.variable.set(current) + self.trigger_focusout_validation() + + def _key_validate( + self, char, index, current, proposed, action, **kwargs + ): + valid = True + min_val = self.cget('from') + max_val = self.cget('to') + no_negative = min_val >= 0 + no_decimal = self.precision >= 0 + if action == '0': + return True + + # First, filter out obviously invalid keystrokes + if any([ + (char not in '-1234567890.'), + (char == '-' and (no_negative or index != '0')), + (char == '.' and (no_decimal or '.' in current)) + ]): + return False + + # At this point, proposed is either '-', '.', '-.', + # or a valid Decimal string + if proposed in '-.': + return True + + # Proposed is a valid Decimal string + # convert to Decimal and check more: + proposed = Decimal(proposed) + proposed_precision = proposed.as_tuple().exponent + + if any([ + (proposed > max_val), + (proposed_precision < self.precision) + ]): + return False + + return valid + + def _focusout_validate(self, **kwargs): + valid = True + value = self.get() + min_val = self.cget('from') + max_val = self.cget('to') + + try: + value = Decimal(value) + except InvalidOperation: + self.error.set('Invalid number string: {}'.format(value)) + return False + + if value < min_val: + self.error.set('Value is too low (min {})'.format(min_val)) + valid = False + if value > max_val: + self.error.set('Value is too high (max {})'.format(max_val)) + + return valid + +class BoundText(tk.Text): + """A Text widget with a bound variable.""" + + def __init__(self, *args, textvariable=None, **kwargs): + super().__init__(*args, **kwargs) + self._variable = textvariable + self._modifying = False + if self._variable: + # insert any default value + self.insert('1.0', self._variable.get()) + self._variable.trace_add('write', self._set_content) + self.bind('<>', self._set_var) + + def _clear_modified_flag(self): + # This also triggers a '<>' Event + self.tk.call(self._w, 'edit', 'modified', 0) + + def _set_var(self, *_): + """Set the variable to the text contents""" + if self._modifying: + return + self._modifying = True + # remove trailing newline from content + content = self.get('1.0', tk.END)[:-1] + self._variable.set(content) + self._clear_modified_flag() + self._modifying = False + + def _set_content(self, *_): + """Set the text contents to the variable""" + if self._modifying: + return + self._modifying = True + self.delete('1.0', tk.END) + self.insert('1.0', self._variable.get()) + self._modifying = False + +################## +# Module Classes # +################## + + +class LabelInput(ttk.Frame): + """A widget containing a label and input together.""" + + def __init__( + self, parent, label, var, input_class=ttk.Entry, + input_args=None, label_args=None, disable_var=None, + **kwargs + ): + super().__init__(parent, **kwargs) + input_args = input_args or {} + label_args = label_args or {} + self.variable = var + self.variable.label_widget = self + + # setup the label + if input_class in (ttk.Checkbutton, ttk.Button): + # Buttons don't need labels, they're built-in + input_args["text"] = label + else: + self.label = ttk.Label(self, text=label, **label_args) + self.label.grid(row=0, column=0, sticky=(tk.W + tk.E)) + + # setup the variable + if input_class in (ttk.Checkbutton, ttk.Button, ttk.Radiobutton): + input_args["variable"] = self.variable + else: + input_args["textvariable"] = self.variable + + # Setup the input + if input_class == ttk.Radiobutton: + # for Radiobutton, create one input per value + self.input = tk.Frame(self) + for v in input_args.pop('values', []): + button = ttk.Radiobutton( + self.input, value=v, text=v, **input_args) + button.pack(side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x') + else: + self.input = input_class(self, **input_args) + self.input.grid(row=1, column=0, sticky=(tk.W + tk.E)) + self.columnconfigure(0, weight=1) + + # Set up error handling & display + self.error = getattr(self.input, 'error', tk.StringVar()) + ttk.Label(self, textvariable=self.error).grid( + row=2, column=0, sticky=(tk.W + tk.E) + ) + + # Set up disable variable + if disable_var: + self.disable_var = disable_var + self.disable_var.trace_add('write', self._check_disable) + + def _check_disable(self, *_): + if not hasattr(self, 'disable_var'): + return + + if self.disable_var.get(): + self.input.configure(state=tk.DISABLED) + self.variable.set('') + self.error.set('') + else: + self.input.configure(state=tk.NORMAL) + + def grid(self, sticky=(tk.E + tk.W), **kwargs): + """Override grid to add default sticky values""" + super().grid(sticky=sticky, **kwargs) + + +class DataRecordForm(tk.Frame): + """The input form for our widgets""" + + def _add_frame(self, label, cols=3): + """Add a labelframe to the form""" + + frame = ttk.LabelFrame(self, text=label) + frame.grid(sticky=tk.W + tk.E) + for i in range(cols): + frame.columnconfigure(i, weight=1) + return frame + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Create a dict to keep track of input widgets + self._vars = { + 'Date': tk.StringVar(), + 'Time': tk.StringVar(), + 'Technician': tk.StringVar(), + 'Lab': tk.StringVar(), + 'Plot': tk.IntVar(), + 'Seed Sample': tk.StringVar(), + 'Humidity': tk.DoubleVar(), + 'Light': tk.DoubleVar(), + 'Temperature': tk.DoubleVar(), + 'Equipment Fault': tk.BooleanVar(), + 'Plants': tk.IntVar(), + 'Blossoms': tk.IntVar(), + 'Fruit': tk.IntVar(), + 'Min Height': tk.DoubleVar(), + 'Max Height': tk.DoubleVar(), + 'Med Height': tk.DoubleVar(), + 'Notes': tk.StringVar() + } + + # Build the form + self.columnconfigure(0, weight=1) + + # Record info section + r_info = self._add_frame("Record Information") + + # line 1 + LabelInput( + r_info, "Date", var=self._vars['Date'], input_class=DateEntry + ).grid(row=0, column=0) + LabelInput( + r_info, "Time", input_class=ValidatedCombobox, + var=self._vars['Time'], + input_args={"values": ["8:00", "12:00", "16:00", "20:00"]} + ).grid(row=0, column=1) + LabelInput( + r_info, "Technician", var=self._vars['Technician'], + input_class=RequiredEntry + ).grid(row=0, column=2) + + # line 2 + LabelInput( + r_info, "Lab", input_class=ttk.Radiobutton, + var=self._vars['Lab'], input_args={"values": ["A", "B", "C"]} + ).grid(row=1, column=0) + LabelInput( + r_info, "Plot", input_class=ValidatedCombobox, + var=self._vars['Plot'], input_args={"values": list(range(1, 21))} + ).grid(row=1, column=1) + LabelInput( + r_info, "Seed Sample", var=self._vars['Seed Sample'], + input_class=RequiredEntry + ).grid(row=1, column=2) + + + # Environment Data + e_info = self._add_frame("Environment Data") + + LabelInput( + e_info, "Humidity (g/m³)", + input_class=ValidatedSpinbox, var=self._vars['Humidity'], + input_args={"from_": 0.5, "to": 52.0, "increment": .01}, + disable_var=self._vars['Equipment Fault'] + ).grid(row=0, column=0) + LabelInput( + e_info, "Light (klx)", input_class=ValidatedSpinbox, + var=self._vars['Light'], + input_args={"from_": 0, "to": 100, "increment": .01}, + disable_var=self._vars['Equipment Fault'] + ).grid(row=0, column=1) + LabelInput( + e_info, "Temperature (°C)", + input_class=ValidatedSpinbox, var=self._vars['Temperature'], + input_args={"from_": 4, "to": 40, "increment": .01}, + disable_var=self._vars['Equipment Fault'] + ).grid(row=0, column=2) + LabelInput( + e_info, "Equipment Fault", + input_class=ttk.Checkbutton, var=self._vars['Equipment Fault'] + ).grid(row=1, column=0, columnspan=3) + + + # Plant Data section + p_info = self._add_frame("Plant Data") + + LabelInput( + p_info, "Plants", input_class=ValidatedSpinbox, + var=self._vars['Plants'], input_args={"from_": 0, "to": 20} + ).grid(row=0, column=0) + LabelInput( + p_info, "Blossoms", input_class=ValidatedSpinbox, + var=self._vars['Blossoms'], input_args={"from_": 0, "to": 1000} + ).grid(row=0, column=1) + LabelInput( + p_info, "Fruit", input_class=ValidatedSpinbox, + var=self._vars['Fruit'], input_args={"from_": 0, "to": 1000} + ).grid(row=0, column=2) + + # Height data + # create variables to be updated for min/max height + # they can be referenced for min/max variables + min_height_var = tk.DoubleVar(value='-infinity') + max_height_var = tk.DoubleVar(value='infinity') + + LabelInput( + p_info, "Min Height (cm)", + input_class=ValidatedSpinbox, var=self._vars['Min Height'], + input_args={ + "from_": 0, "to": 1000, "increment": .01, + "max_var": max_height_var, "focus_update_var": min_height_var + } + ).grid(row=1, column=0) + LabelInput( + p_info, "Max Height (cm)", + input_class=ValidatedSpinbox, var=self._vars['Max Height'], + input_args={ + "from_": 0, "to": 1000, "increment": .01, + "min_var": min_height_var, "focus_update_var": max_height_var + } + ).grid(row=1, column=1) + LabelInput( + p_info, "Median Height (cm)", + input_class=ValidatedSpinbox, var=self._vars['Med Height'], + input_args={ + "from_": 0, "to": 1000, "increment": .01, + "min_var": min_height_var, "max_var": max_height_var + } + ).grid(row=1, column=2) + + + # Notes section + LabelInput( + self, "Notes", + input_class=BoundText, var=self._vars['Notes'], + input_args={"width": 75, "height": 10} + ).grid(sticky=(tk.W + tk.E), row=3, column=0) + + # buttons + buttons = tk.Frame(self) + buttons.grid(sticky=tk.W + tk.E, row=4) + self.savebutton = ttk.Button( + buttons, text="Save", command=self.master.on_save) + self.savebutton.pack(side=tk.RIGHT) + + self.resetbutton = ttk.Button( + buttons, text="Reset", command=self.reset) + self.resetbutton.pack(side=tk.RIGHT) + + # default the form + self.reset() + + def get(self): + """Retrieve data from form as a dict""" + + # We need to retrieve the data from Tkinter variables + # and place it in regular Python objects + data = dict() + for key, variable in self._vars.items(): + try: + data[key] = variable.get() + except tk.TclError as e: + message = f'Error in field: {key}. Data was not saved!' + raise ValueError(message) from e + + return data + + def reset(self): + """Resets the form entries""" + + lab = self._vars['Lab'].get() + time = self._vars['Time'].get() + technician = self._vars['Technician'].get() + try: + plot = self._vars['Plot'].get() + except tk.TclError: + plot = '' + plot_values = self._vars['Plot'].label_widget.input.cget('values') + + # clear all values + for var in self._vars.values(): + if isinstance(var, tk.BooleanVar): + var.set(False) + else: + var.set('') + + # Autofill Date + current_date = datetime.today().strftime('%Y-%m-%d') + self._vars['Date'].set(current_date) + self._vars['Time'].label_widget.input.focus() + + # check if we need to put our values back, then do it. + if plot not in ('', 0, plot_values[-1]): + self._vars['Lab'].set(lab) + self._vars['Time'].set(time) + self._vars['Technician'].set(technician) + next_plot_index = plot_values.index(str(plot)) + 1 + self._vars['Plot'].set(plot_values[next_plot_index]) + self._vars['Seed Sample'].label_widget.input.focus() + + def get_errors(self): + """Get a list of field errors in the form""" + + errors = dict() + for key, var in self._vars.items(): + inp = var.label_widget.input + error = var.label_widget.error + + if hasattr(inp, 'trigger_focusout_validation'): + inp.trigger_focusout_validation() + if error.get(): + errors[key] = error.get() + + return errors + + +class Application(tk.Tk): + """Application root window""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.title("ABQ Data Entry Application") + self.columnconfigure(0, weight=1) + + ttk.Label( + self, + text="ABQ Data Entry Application", + font=("TkDefaultFont", 16) + ).grid(row=0) + + + self.recordform = DataRecordForm(self) + self.recordform.grid(row=1, padx=10, sticky=(tk.W + tk.E)) + + # status bar + self.status = tk.StringVar() + ttk.Label( + self, textvariable=self.status + ).grid(sticky=(tk.W + tk.E), row=3, padx=10) + + self._records_saved = 0 + + def on_save(self): + """Handles save button clicks""" + + # Check for errors first + + errors = self.recordform.get_errors() + if errors: + self.status.set( + "Cannot save, error in fields: {}" + .format(', '.join(errors.keys())) + ) + return + + # For now, we save to a hardcoded filename with a datestring. + # If it doesnt' exist, create it, + # otherwise just append to the existing file + datestring = datetime.today().strftime("%Y-%m-%d") + filename = "abq_data_record_{}.csv".format(datestring) + newfile = not Path(filename).exists() + + data = self.recordform.get() + + with open(filename, 'a') as fh: + csvwriter = csv.DictWriter(fh, fieldnames=data.keys()) + if newfile: + csvwriter.writeheader() + csvwriter.writerow(data) + + self._records_saved += 1 + self.status.set( + "{} records saved this session".format(self._records_saved) + ) + self.recordform.reset() + + +if __name__ == "__main__": + + app = Application() + app.mainloop() diff --git a/Chapter05/five_char_entry.py b/Chapter05/five_char_entry.py new file mode 100644 index 0000000..99bc49c --- /dev/null +++ b/Chapter05/five_char_entry.py @@ -0,0 +1,28 @@ +"""Five Character Entry Demo""" + +import tkinter as tk + +root = tk.Tk() + +entry3 = tk.Entry(root) +entry3.grid() +entry3_error = tk.Label(root, fg='red') +entry3_error.grid() + +def only_five_chars(proposed): + return len(proposed) < 6 + +def only_five_chars_error(proposed): + entry3_error.configure( + text=f'{proposed} is too long, only 5 chars allowed.' + ) +validate3_ref = root.register(only_five_chars) +invalid3_ref = root.register(only_five_chars_error) + +entry3.configure( + validate='all', + validatecommand=(validate3_ref, '%P'), + invalidcommand=(invalid3_ref, '%P') +) + +root.mainloop() diff --git a/Chapter05/five_char_entry_class.py b/Chapter05/five_char_entry_class.py new file mode 100644 index 0000000..1802b9f --- /dev/null +++ b/Chapter05/five_char_entry_class.py @@ -0,0 +1,34 @@ +"""Five Character Entry widget, class-based""" + +import tkinter as tk +from tkinter import ttk + +class FiveCharEntry(ttk.Entry): + """An Entry that truncates to five characters on exit.""" + + def __init__(self, parent, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + self.error = tk.StringVar() + self.configure( + validate='all', + validatecommand=(self.register(self._validate), '%P'), + invalidcommand=(self.register(self._on_invalid), '%P') + ) + + def _validate(self, proposed): + return len(proposed) <= 5 + + def _on_invalid(self, proposed): + self.error.set( + f'{proposed} is too long, only 5 chars allowed!' + ) + +root = tk.Tk() +entry = FiveCharEntry(root) +error_label = ttk.Label( + root, textvariable=entry.error, foreground='red' +) +entry.grid() +error_label.grid() + +root.mainloop() diff --git a/Chapter05/validate_demo.py b/Chapter05/validate_demo.py new file mode 100644 index 0000000..8495428 --- /dev/null +++ b/Chapter05/validate_demo.py @@ -0,0 +1,56 @@ +import tkinter as tk + +root = tk.Tk() +entry = tk.Entry(root) +entry.grid() + +# create a pointless validation command +def always_good(): + return True + +validate_ref = root.register(always_good) + +entry.configure( + validate='all', + validatecommand=(validate_ref,) +) + +# a more useful validation command + + +entry2 = tk.Entry(root) +entry2.grid() + +def no_t_for_me(proposed): + return 't' not in proposed + +validate2_ref = root.register(no_t_for_me) +entry2.configure( + validate='all', + validatecommand=(validate2_ref, '%P') +) + +# An Entry that displays an error + +entry3 = tk.Entry(root) +entry3.grid() +entry3_error = tk.Label(root, fg='red') +entry3_error.grid() + +def only_five_chars(proposed): + return len(proposed) < 6 + +def only_five_chars_error(proposed): + entry3_error.configure( + text=f'{proposed} is too long, only 5 chars allowed.' + ) +validate3_ref = root.register(only_five_chars) +invalid3_ref = root.register(only_five_chars_error) + +entry3.configure( + validate='all', + validatecommand=(validate3_ref, '%P'), + invalidcommand=(invalid3_ref, '%P') +) + +root.mainloop() From d664e4cc28e1d427f81e4673ab69cf0305d30465 Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Sun, 16 May 2021 12:57:15 -0500 Subject: [PATCH 04/32] Added code for Chapter 06 --- Chapter06/ABQ_Data_Entry/.gitignore | 2 + Chapter06/ABQ_Data_Entry/README.rst | 43 ++ Chapter06/ABQ_Data_Entry/abq_data_entry.py | 4 + .../ABQ_Data_Entry/abq_data_entry/__init__.py | 0 .../abq_data_entry/application.py | 57 +++ .../abq_data_entry/constants.py | 12 + .../ABQ_Data_Entry/abq_data_entry/models.py | 68 +++ .../ABQ_Data_Entry/abq_data_entry/views.py | 241 ++++++++++ .../ABQ_Data_Entry/abq_data_entry/widgets.py | 411 ++++++++++++++++++ .../docs/Application_layout.png | Bin 0 -> 9117 bytes .../docs/abq_data_entry_spec.rst | 96 ++++ .../docs/lab-tech-paper-form.png | Bin 0 -> 22018 bytes 12 files changed, 934 insertions(+) create mode 100644 Chapter06/ABQ_Data_Entry/.gitignore create mode 100644 Chapter06/ABQ_Data_Entry/README.rst create mode 100644 Chapter06/ABQ_Data_Entry/abq_data_entry.py create mode 100644 Chapter06/ABQ_Data_Entry/abq_data_entry/__init__.py create mode 100644 Chapter06/ABQ_Data_Entry/abq_data_entry/application.py create mode 100644 Chapter06/ABQ_Data_Entry/abq_data_entry/constants.py create mode 100644 Chapter06/ABQ_Data_Entry/abq_data_entry/models.py create mode 100644 Chapter06/ABQ_Data_Entry/abq_data_entry/views.py create mode 100644 Chapter06/ABQ_Data_Entry/abq_data_entry/widgets.py create mode 100644 Chapter06/ABQ_Data_Entry/docs/Application_layout.png create mode 100644 Chapter06/ABQ_Data_Entry/docs/abq_data_entry_spec.rst create mode 100644 Chapter06/ABQ_Data_Entry/docs/lab-tech-paper-form.png diff --git a/Chapter06/ABQ_Data_Entry/.gitignore b/Chapter06/ABQ_Data_Entry/.gitignore new file mode 100644 index 0000000..d646835 --- /dev/null +++ b/Chapter06/ABQ_Data_Entry/.gitignore @@ -0,0 +1,2 @@ +*.pyc +__pycache__/ diff --git a/Chapter06/ABQ_Data_Entry/README.rst b/Chapter06/ABQ_Data_Entry/README.rst new file mode 100644 index 0000000..5a47dd7 --- /dev/null +++ b/Chapter06/ABQ_Data_Entry/README.rst @@ -0,0 +1,43 @@ +============================ + ABQ Data Entry Application +============================ + +Description +=========== + +This program provides a data entry form for ABQ Agrilabs laboratory data. + +Features +-------- + + * Provides a validated entry form to ensure correct data + * Stores data to ABQ-format CSV files + * Auto-fills form fields whenever possible + +Authors +======= + +Alan D Moore, 2021 + +Requirements +============ + + * Python 3.7 or higher + * Tkinter + +Usage +===== + +To start the application, run:: + + python3 ABQ_Data_Entry/abq_data_entry.py + + +General Notes +============= + +The CSV file will be saved to your current directory in the format +``abq_data_record_CURRENTDATE.csv``, where CURRENTDATE is today's date in ISO format. + +This program only appends to the CSV file. You should have a spreadsheet program +installed in case you need to edit or check the file. diff --git a/Chapter06/ABQ_Data_Entry/abq_data_entry.py b/Chapter06/ABQ_Data_Entry/abq_data_entry.py new file mode 100644 index 0000000..a3b3a0d --- /dev/null +++ b/Chapter06/ABQ_Data_Entry/abq_data_entry.py @@ -0,0 +1,4 @@ +from abq_data_entry.application import Application + +app = Application() +app.mainloop() diff --git a/Chapter06/ABQ_Data_Entry/abq_data_entry/__init__.py b/Chapter06/ABQ_Data_Entry/abq_data_entry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Chapter06/ABQ_Data_Entry/abq_data_entry/application.py b/Chapter06/ABQ_Data_Entry/abq_data_entry/application.py new file mode 100644 index 0000000..4a385e5 --- /dev/null +++ b/Chapter06/ABQ_Data_Entry/abq_data_entry/application.py @@ -0,0 +1,57 @@ +"""The application/controller class for ABQ Data Entry""" + +import tkinter as tk +from tkinter import ttk +from . import views as v +from . import models as m + + +class Application(tk.Tk): + """Application root window""" + + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.model = m.CSVModel() + + self.title("ABQ Data Entry Application") + self.columnconfigure(0, weight=1) + + ttk.Label( + self, + text="ABQ Data Entry Application", + font=("TkDefaultFont", 16) + ).grid(row=0) + + self.recordform = v.DataRecordForm(self, self.model) + self.recordform.grid(row=1, padx=10, sticky=(tk.W + tk.E)) + self.recordform.bind('<>', self._on_save) + + # status bar + self.status = tk.StringVar() + self.statusbar = ttk.Label(self, textvariable=self.status) + self.statusbar.grid(sticky=(tk.W + tk.E), row=3, padx=10) + + self.records_saved = 0 + + def _on_save(self, *_): + """Handles file-save requests""" + + # Check for errors first + + errors = self.recordform.get_errors() + if errors: + self.status.set( + "Cannot save, error in fields: {}" + .format(', '.join(errors.keys())) + ) + return False + + data = self.recordform.get() + self.model.save_record(data) + self.records_saved += 1 + self.status.set( + "{} records saved this session".format(self.records_saved) + ) + self.recordform.reset() diff --git a/Chapter06/ABQ_Data_Entry/abq_data_entry/constants.py b/Chapter06/ABQ_Data_Entry/abq_data_entry/constants.py new file mode 100644 index 0000000..e747dce --- /dev/null +++ b/Chapter06/ABQ_Data_Entry/abq_data_entry/constants.py @@ -0,0 +1,12 @@ +"""Global constants and classes needed by other modules in ABQ Data Entry""" +from enum import Enum, auto + +class FieldTypes(Enum): + string = auto() + string_list = auto() + short_string_list = auto() + iso_date_string = auto() + long_string = auto() + decimal = auto() + integer = auto() + boolean = auto() diff --git a/Chapter06/ABQ_Data_Entry/abq_data_entry/models.py b/Chapter06/ABQ_Data_Entry/abq_data_entry/models.py new file mode 100644 index 0000000..e60aa27 --- /dev/null +++ b/Chapter06/ABQ_Data_Entry/abq_data_entry/models.py @@ -0,0 +1,68 @@ +import csv +from pathlib import Path +import os +from .constants import FieldTypes as FT +from decimal import Decimal +from datetime import datetime + +class CSVModel: + """CSV file storage""" + + fields = { + "Date": {'req': True, 'type': FT.iso_date_string}, + "Time": {'req': True, 'type': FT.string_list, + 'values': ['8:00', '12:00', '16:00', '20:00']}, + "Technician": {'req': True, 'type': FT.string}, + "Lab": {'req': True, 'type': FT.short_string_list, + 'values': ['A', 'B', 'C']}, + "Plot": {'req': True, 'type': FT.string_list, + 'values': [str(x) for x in range(1, 21)]}, + "Seed Sample": {'req': True, 'type': FT.string}, + "Humidity": {'req': True, 'type': FT.decimal, + 'min': 0.5, 'max': 52.0, 'inc': .01}, + "Light": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 100.0, 'inc': .01}, + "Temperature": {'req': True, 'type': FT.decimal, + 'min': 4, 'max': 40, 'inc': .01}, + "Equipment Fault": {'req': False, 'type': FT.boolean}, + "Plants": {'req': True, 'type': FT.integer, 'min': 0, 'max': 20}, + "Blossoms": {'req': True, 'type': FT.integer, 'min': 0, 'max': 1000}, + "Fruit": {'req': True, 'type': FT.integer, 'min': 0, 'max': 1000}, + "Min Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Max Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Med Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Notes": {'req': False, 'type': FT.long_string} + } + + + def __init__(self): + + datestring = datetime.today().strftime("%Y-%m-%d") + filename = "abq_data_record_{}.csv".format(datestring) + self.file = Path(filename) + + # Check for append permissions: + file_exists = os.access(self.file, os.F_OK) + parent_writeable = os.access(self.file.parent, os.W_OK) + file_writeable = os.access(self.file, os.W_OK) + if ( + (not file_exists and not parent_writeable) or + (file_exists and not file_writeable) + ): + msg = f'Permission denied accessing file: {filename}' + raise PermissionError(msg) + + + def save_record(self, data): + """Save a dict of data to the CSV file""" + newfile = not self.file.exists() + + with open(self.file, 'a') as fh: + csvwriter = csv.DictWriter(fh, fieldnames=self.fields.keys()) + if newfile: + csvwriter.writeheader() + + csvwriter.writerow(data) diff --git a/Chapter06/ABQ_Data_Entry/abq_data_entry/views.py b/Chapter06/ABQ_Data_Entry/abq_data_entry/views.py new file mode 100644 index 0000000..83c983a --- /dev/null +++ b/Chapter06/ABQ_Data_Entry/abq_data_entry/views.py @@ -0,0 +1,241 @@ +import tkinter as tk +from tkinter import ttk +from datetime import datetime +from . import widgets as w +from .constants import FieldTypes as FT + +class DataRecordForm(tk.Frame): + """The input form for our widgets""" + + var_types = { + FT.string: tk.StringVar, + FT.string_list: tk.StringVar, + FT.short_string_list: tk.StringVar, + FT.iso_date_string: tk.StringVar, + FT.long_string: tk.StringVar, + FT.decimal: tk.DoubleVar, + FT.integer: tk.IntVar, + FT.boolean: tk.BooleanVar + } + + def _add_frame(self, label, cols=3): + """Add a labelframe to the form""" + + frame = ttk.LabelFrame(self, text=label) + frame.grid(sticky=tk.W + tk.E) + for i in range(cols): + frame.columnconfigure(i, weight=1) + return frame + + def __init__(self, parent, model, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + + self.model= model + fields = self.model.fields + + # Create a dict to keep track of input widgets + self._vars = { + key: self.var_types[spec['type']]() + for key, spec in fields.items() + } + + # Build the form + self.columnconfigure(0, weight=1) + + # Record info section + r_info = self._add_frame("Record Information") + + # line 1 + w.LabelInput( + r_info, "Date", + field_spec=fields['Date'], + var=self._vars['Date'], + ).grid(row=0, column=0) + w.LabelInput( + r_info, "Time", + field_spec=fields['Time'], + var=self._vars['Time'], + ).grid(row=0, column=1) + w.LabelInput( + r_info, "Lab", + field_spec=fields['Lab'], + var=self._vars['Lab'], + ).grid(row=0, column=2) + # line 2 + w.LabelInput( + r_info, "Plot", + field_spec=fields['Plot'], + var=self._vars['Plot'], + ).grid(row=1, column=0) + w.LabelInput( + r_info, "Technician", + field_spec=fields['Technician'], + var=self._vars['Technician'], + ).grid(row=1, column=1) + w.LabelInput( + r_info, "Seed Sample", + field_spec=fields['Seed Sample'], + var=self._vars['Seed Sample'], + ).grid(row=1, column=2) + + + # Environment Data + e_info = self._add_frame("Environment Data") + + w.LabelInput( + e_info, "Humidity (g/m³)", + field_spec=fields['Humidity'], + var=self._vars['Humidity'], + disable_var=self._vars['Equipment Fault'] + ).grid(row=0, column=0) + w.LabelInput( + e_info, "Light (klx)", + field_spec=fields['Light'], + var=self._vars['Light'], + disable_var=self._vars['Equipment Fault'] + ).grid(row=0, column=1) + w.LabelInput( + e_info, "Temperature (°C)", + field_spec=fields['Temperature'], + var=self._vars['Temperature'], + disable_var=self._vars['Equipment Fault'] + ).grid(row=0, column=2) + w.LabelInput( + e_info, "Equipment Fault", + field_spec=fields['Equipment Fault'], + var=self._vars['Equipment Fault'], + ).grid(row=1, column=0, columnspan=3) + + # Plant Data section + p_info = self._add_frame("Plant Data") + + w.LabelInput( + p_info, "Plants", + field_spec=fields['Plants'], + var=self._vars['Plants'], + ).grid(row=0, column=0) + w.LabelInput( + p_info, "Blossoms", + field_spec=fields['Blossoms'], + var=self._vars['Blossoms'], + ).grid(row=0, column=1) + w.LabelInput( + p_info, "Fruit", + field_spec=fields['Fruit'], + var=self._vars['Fruit'], + ).grid(row=0, column=2) + + # Height data + # create variables to be updated for min/max height + # they can be referenced for min/max variables + min_height_var = tk.DoubleVar(value='-infinity') + max_height_var = tk.DoubleVar(value='infinity') + + w.LabelInput( + p_info, "Min Height (cm)", + field_spec=fields['Min Height'], + var=self._vars['Min Height'], + input_args={ + "max_var": max_height_var, "focus_update_var": min_height_var + }).grid(row=1, column=0) + w.LabelInput( + p_info, "Max Height (cm)", + field_spec=fields['Max Height'], + var=self._vars['Max Height'], + input_args={ + "min_var": min_height_var, "focus_update_var": max_height_var + }).grid(row=1, column=1) + w.LabelInput( + p_info, "Median Height (cm)", + field_spec=fields['Med Height'], + var=self._vars['Med Height'], + input_args={ + "min_var": min_height_var, "max_var": max_height_var + }).grid(row=1, column=2) + + + # Notes section + w.LabelInput( + self, "Notes", field_spec=fields['Notes'], + var=self._vars['Notes'], input_args={"width": 85, "height": 10} + ).grid(sticky="nsew", row=3, column=0, padx=10, pady=10) + + # buttons + buttons = tk.Frame(self) + buttons.grid(sticky=tk.W + tk.E, row=4) + self.savebutton = ttk.Button( + buttons, text="Save", command=self._on_save) + self.savebutton.pack(side=tk.RIGHT) + + self.resetbutton = ttk.Button( + buttons, text="Reset", command=self.reset) + self.resetbutton.pack(side=tk.RIGHT) + + # default the form + self.reset() + + def _on_save(self): + self.event_generate('<>') + + def get(self): + """Retrieve data from form as a dict""" + + # We need to retrieve the data from Tkinter variables + # and place it in regular Python objects + data = dict() + for key, variable in self._vars.items(): + try: + data[key] = variable.get() + except tk.TclError: + message = f'Error in field: {key}. Data was not saved!' + raise ValueError(message) + + return data + + def reset(self): + """Resets the form entries""" + + lab = self._vars['Lab'].get() + time = self._vars['Time'].get() + technician = self._vars['Technician'].get() + try: + plot = self._vars['Plot'].get() + except tk.TclError: + plot = '' + plot_values = self._vars['Plot'].label_widget.input.cget('values') + + # clear all values + for var in self._vars.values(): + if isinstance(var, tk.BooleanVar): + var.set(False) + else: + var.set('') + + # Autofill Date + current_date = datetime.today().strftime('%Y-%m-%d') + self._vars['Date'].set(current_date) + self._vars['Time'].label_widget.input.focus() + + # check if we need to put our values back, then do it. + if plot not in ('', 0, plot_values[-1]): + self._vars['Lab'].set(lab) + self._vars['Time'].set(time) + self._vars['Technician'].set(technician) + next_plot_index = plot_values.index(plot) + 1 + self._vars['Plot'].set(plot_values[next_plot_index]) + self._vars['Seed Sample'].label_widget.input.focus() + + def get_errors(self): + """Get a list of field errors in the form""" + + errors = dict() + for key, var in self._vars.items(): + inp = var.label_widget.input + error = var.label_widget.error + + if hasattr(inp, 'trigger_focusout_validation'): + inp.trigger_focusout_validation() + if error.get(): + errors[key] = error.get() + + return errors diff --git a/Chapter06/ABQ_Data_Entry/abq_data_entry/widgets.py b/Chapter06/ABQ_Data_Entry/abq_data_entry/widgets.py new file mode 100644 index 0000000..14783c1 --- /dev/null +++ b/Chapter06/ABQ_Data_Entry/abq_data_entry/widgets.py @@ -0,0 +1,411 @@ +import tkinter as tk +from tkinter import ttk +from datetime import datetime +from decimal import Decimal, InvalidOperation +from .constants import FieldTypes as FT + + +################## +# Widget Classes # +################## + +class ValidatedMixin: + """Adds a validation functionality to an input widget""" + + def __init__(self, *args, error_var=None, **kwargs): + self.error = error_var or tk.StringVar() + super().__init__(*args, **kwargs) + + vcmd = self.register(self._validate) + invcmd = self.register(self._invalid) + + self.configure( + validate='all', + validatecommand=(vcmd, '%P', '%s', '%S', '%V', '%i', '%d'), + invalidcommand=(invcmd, '%P', '%s', '%S', '%V', '%i', '%d') + ) + + def _toggle_error(self, on=False): + self.configure(foreground=('red' if on else 'black')) + + def _validate(self, proposed, current, char, event, index, action): + """The validation method. + + Don't override this, override _key_validate, and _focus_validate + """ + self.error.set('') + self._toggle_error() + + valid = True + # if the widget is disabled, don't validate + state = str(self.configure('state')[-1]) + if state == tk.DISABLED: + return valid + + if event == 'focusout': + valid = self._focusout_validate(event=event) + elif event == 'key': + valid = self._key_validate( + proposed=proposed, + current=current, + char=char, + event=event, + index=index, + action=action + ) + return valid + + def _focusout_validate(self, **kwargs): + return True + + def _key_validate(self, **kwargs): + return True + + def _invalid(self, proposed, current, char, event, index, action): + if event == 'focusout': + self._focusout_invalid(event=event) + elif event == 'key': + self._key_invalid( + proposed=proposed, + current=current, + char=char, + event=event, + index=index, + action=action + ) + + def _focusout_invalid(self, **kwargs): + """Handle invalid data on a focus event""" + self._toggle_error(True) + + def _key_invalid(self, **kwargs): + """Handle invalid data on a key event. By default we want to do nothing""" + pass + + def trigger_focusout_validation(self): + valid = self._validate('', '', '', 'focusout', '', '') + if not valid: + self._focusout_invalid(event='focusout') + return valid + + +class DateEntry(ValidatedMixin, ttk.Entry): + + def _key_validate(self, action, index, char, **kwargs): + valid = True + + if action == '0': # This is a delete action + valid = True + elif index in ('0', '1', '2', '3', '5', '6', '8', '9'): + valid = char.isdigit() + elif index in ('4', '7'): + valid = char == '-' + else: + valid = False + return valid + + def _focusout_validate(self, event): + valid = True + if not self.get(): + self.error.set('A value is required') + valid = False + try: + datetime.strptime(self.get(), '%Y-%m-%d') + except ValueError: + self.error.set('Invalid date') + valid = False + return valid + + +class RequiredEntry(ValidatedMixin, ttk.Entry): + + def _focusout_validate(self, event): + valid = True + if not self.get(): + valid = False + self.error.set('A value is required') + return valid + + +class ValidatedCombobox(ValidatedMixin, ttk.Combobox): + + def _key_validate(self, proposed, action, **kwargs): + valid = True + # if the user tries to delete, + # just clear the field + if action == '0': + self.set('') + return True + + # get our values list + values = self.cget('values') + # Do a case-insensitve match against the entered text + matching = [ + x for x in values + if x.lower().startswith(proposed.lower()) + ] + if len(matching) == 0: + valid = False + elif len(matching) == 1: + self.set(matching[0]) + self.icursor(tk.END) + valid = False + return valid + + def _focusout_validate(self, **kwargs): + valid = True + if not self.get(): + valid = False + self.error.set('A value is required') + return valid + + +class ValidatedSpinbox(ValidatedMixin, ttk.Spinbox): + """A Spinbox that only accepts Numbers""" + + def __init__(self, *args, min_var=None, max_var=None, + focus_update_var=None, from_='-Infinity', to='Infinity', **kwargs + ): + super().__init__(*args, from_=from_, to=to, **kwargs) + increment = Decimal(str(kwargs.get('increment', '1.0'))) + self.precision = increment.normalize().as_tuple().exponent + # there should always be a variable, + # or some of our code will fail + self.variable = kwargs.get('textvariable') + if not self.variable: + self.variable = tk.DoubleVar() + self.configure(textvariable=self.variable) + + if min_var: + self.min_var = min_var + self.min_var.trace_add('write', self._set_minimum) + if max_var: + self.max_var = max_var + self.max_var.trace_add('write', self._set_maximum) + self.focus_update_var = focus_update_var + self.bind('', self._set_focus_update_var) + + def _set_focus_update_var(self, event): + value = self.get() + if self.focus_update_var and not self.error.get(): + self.focus_update_var.set(value) + + def _set_minimum(self, *args): + current = self.get() + try: + new_min = self.min_var.get() + self.config(from_=new_min) + except (tk.TclError, ValueError): + pass + if not current: + self.delete(0, tk.END) + else: + self.variable.set(current) + self.trigger_focusout_validation() + + def _set_maximum(self, *args): + current = self.get() + try: + new_max = self.max_var.get() + self.config(to=new_max) + except (tk.TclError, ValueError): + pass + if not current: + self.delete(0, tk.END) + else: + self.variable.set(current) + self.trigger_focusout_validation() + + def _key_validate( + self, char, index, current, proposed, action, **kwargs + ): + valid = True + min_val = self.cget('from') + max_val = self.cget('to') + no_negative = min_val >= 0 + no_decimal = self.precision >= 0 + if action == '0': + return True + + # First, filter out obviously invalid keystrokes + if any([ + (char not in '-1234567890.'), + (char == '-' and (no_negative or index != '0')), + (char == '.' and (no_decimal or '.' in current)) + ]): + return False + + # At this point, proposed is either '-', '.', '-.', + # or a valid Decimal string + if proposed in '-.': + return True + + # Proposed is a valid Decimal string + # convert to Decimal and check more: + proposed = Decimal(proposed) + proposed_precision = proposed.as_tuple().exponent + + if any([ + (proposed > max_val), + (proposed_precision < self.precision) + ]): + return False + + return valid + + def _focusout_validate(self, **kwargs): + valid = True + value = self.get() + min_val = self.cget('from') + max_val = self.cget('to') + + try: + value = Decimal(value) + except InvalidOperation: + self.error.set('Invalid number string: {}'.format(value)) + return False + + if value < min_val: + self.error.set('Value is too low (min {})'.format(min_val)) + valid = False + if value > max_val: + self.error.set('Value is too high (max {})'.format(max_val)) + + return valid + +class BoundText(tk.Text): + """A Text widget with a bound variable.""" + + def __init__(self, *args, textvariable=None, **kwargs): + super().__init__(*args, **kwargs) + self._variable = textvariable + self._modifying = False + if self._variable: + # insert any default value + self.insert('1.0', self._variable.get()) + self._variable.trace_add('write', self._set_content) + self.bind('<>', self._set_var) + + def _clear_modified_flag(self): + # This also triggers a '<>' Event + self.tk.call(self._w, 'edit', 'modified', 0) + + def _set_var(self, *_): + """Set the variable to the text contents""" + if self._modifying: + return + self._modifying = True + # remove trailing newline from content + content = self.get('1.0', tk.END)[:-1] + self._variable.set(content) + self._clear_modified_flag() + self._modifying = False + + def _set_content(self, *_): + """Set the text contents to the variable""" + if self._modifying: + return + self._modifying = True + self.delete('1.0', tk.END) + self.insert('1.0', self._variable.get()) + self._modifying = False + + +########################### +# Compound Widget Classes # +########################### + + +class LabelInput(ttk.Frame): + """A widget containing a label and input together.""" + + field_types = { + FT.string: RequiredEntry, + FT.string_list: ValidatedCombobox, + FT.short_string_list: ttk.Radiobutton, + FT.iso_date_string: DateEntry, + FT.long_string: BoundText, + FT.decimal: ValidatedSpinbox, + FT.integer: ValidatedSpinbox, + FT.boolean: ttk.Checkbutton + } + + def __init__( + self, parent, label, var, input_class=None, + input_args=None, label_args=None, field_spec=None, + disable_var=None, **kwargs + ): + super().__init__(parent, **kwargs) + input_args = input_args or {} + label_args = label_args or {} + self.variable = var + self.variable.label_widget = self + + # Process the field spec to determine input_class and validation + if field_spec: + field_type = field_spec.get('type', FT.string) + input_class = input_class or self.field_types.get(field_type) + # min, max, increment + if 'min' in field_spec and 'from_' not in input_args: + input_args['from_'] = field_spec.get('min') + if 'max' in field_spec and 'to' not in input_args: + input_args['to'] = field_spec.get('max') + if 'inc' in field_spec and 'increment' not in input_args: + input_args['increment'] = field_spec.get('inc') + # values + if 'values' in field_spec and 'values' not in input_args: + input_args['values'] = field_spec.get('values') + + # setup the label + if input_class in (ttk.Checkbutton, ttk.Button): + # Buttons don't need labels, they're built-in + input_args["text"] = label + else: + self.label = ttk.Label(self, text=label, **label_args) + self.label.grid(row=0, column=0, sticky=(tk.W + tk.E)) + + # setup the variable + if input_class in (ttk.Checkbutton, ttk.Button, ttk.Radiobutton): + input_args["variable"] = self.variable + else: + input_args["textvariable"] = self.variable + + # Setup the input + if input_class == ttk.Radiobutton: + # for Radiobutton, create one input per value + self.input = tk.Frame(self) + for v in input_args.pop('values', []): + button = ttk.Radiobutton( + self.input, value=v, text=v, **input_args) + button.pack(side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x') + else: + self.input = input_class(self, **input_args) + + self.input.grid(row=1, column=0, sticky=(tk.W + tk.E)) + self.columnconfigure(0, weight=1) + + # Set up error handling & display + self.error = getattr(self.input, 'error', tk.StringVar()) + ttk.Label(self, textvariable=self.error).grid( + row=2, column=0, sticky=(tk.W + tk.E) + ) + + # Set up disable variable + if disable_var: + self.disable_var = disable_var + self.disable_var.trace_add('write', self._check_disable) + + def _check_disable(self, *_): + if not hasattr(self, 'disable_var'): + return + + if self.disable_var.get(): + self.input.configure(state=tk.DISABLED) + self.variable.set('') + self.error.set('') + else: + self.input.configure(state=tk.NORMAL) + + def grid(self, sticky=(tk.E + tk.W), **kwargs): + """Override grid to add default sticky values""" + super().grid(sticky=sticky, **kwargs) diff --git a/Chapter06/ABQ_Data_Entry/docs/Application_layout.png b/Chapter06/ABQ_Data_Entry/docs/Application_layout.png new file mode 100644 index 0000000000000000000000000000000000000000..93990f232d19518ca465e7715bbf4f0f10cabce9 GIT binary patch literal 9117 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 mufwd~J*Rr~aor9m$$ITZW{ Date: Tue, 1 Jun 2021 22:52:01 -0500 Subject: [PATCH 05/32] Added code for chapter7 --- Chapter07/ABQ_Data_Entry/.gitignore | 2 + Chapter07/ABQ_Data_Entry/README.rst | 43 ++ Chapter07/ABQ_Data_Entry/abq_data_entry.py | 4 + .../ABQ_Data_Entry/abq_data_entry/__init__.py | 0 .../abq_data_entry/application.py | 159 +++++++ .../abq_data_entry/constants.py | 12 + .../ABQ_Data_Entry/abq_data_entry/mainmenu.py | 70 +++ .../ABQ_Data_Entry/abq_data_entry/models.py | 122 ++++++ .../ABQ_Data_Entry/abq_data_entry/views.py | 297 +++++++++++++ .../ABQ_Data_Entry/abq_data_entry/widgets.py | 411 ++++++++++++++++++ .../docs/Application_layout.png | Bin 0 -> 9117 bytes .../docs/abq_data_entry_spec.rst | 96 ++++ .../docs/lab-tech-paper-form.png | Bin 0 -> 22018 bytes Chapter07/filedialog_demo.py | 19 + Chapter07/menu_demo.py | 65 +++ Chapter07/messagebox_demo.py | 17 + Chapter07/messagebox_demo_short.py | 8 + Chapter07/simpledialog_demo.py | 17 + 18 files changed, 1342 insertions(+) create mode 100644 Chapter07/ABQ_Data_Entry/.gitignore create mode 100644 Chapter07/ABQ_Data_Entry/README.rst create mode 100644 Chapter07/ABQ_Data_Entry/abq_data_entry.py create mode 100644 Chapter07/ABQ_Data_Entry/abq_data_entry/__init__.py create mode 100644 Chapter07/ABQ_Data_Entry/abq_data_entry/application.py create mode 100644 Chapter07/ABQ_Data_Entry/abq_data_entry/constants.py create mode 100644 Chapter07/ABQ_Data_Entry/abq_data_entry/mainmenu.py create mode 100644 Chapter07/ABQ_Data_Entry/abq_data_entry/models.py create mode 100644 Chapter07/ABQ_Data_Entry/abq_data_entry/views.py create mode 100644 Chapter07/ABQ_Data_Entry/abq_data_entry/widgets.py create mode 100644 Chapter07/ABQ_Data_Entry/docs/Application_layout.png create mode 100644 Chapter07/ABQ_Data_Entry/docs/abq_data_entry_spec.rst create mode 100644 Chapter07/ABQ_Data_Entry/docs/lab-tech-paper-form.png create mode 100644 Chapter07/filedialog_demo.py create mode 100644 Chapter07/menu_demo.py create mode 100644 Chapter07/messagebox_demo.py create mode 100644 Chapter07/messagebox_demo_short.py create mode 100644 Chapter07/simpledialog_demo.py diff --git a/Chapter07/ABQ_Data_Entry/.gitignore b/Chapter07/ABQ_Data_Entry/.gitignore new file mode 100644 index 0000000..d646835 --- /dev/null +++ b/Chapter07/ABQ_Data_Entry/.gitignore @@ -0,0 +1,2 @@ +*.pyc +__pycache__/ diff --git a/Chapter07/ABQ_Data_Entry/README.rst b/Chapter07/ABQ_Data_Entry/README.rst new file mode 100644 index 0000000..5a47dd7 --- /dev/null +++ b/Chapter07/ABQ_Data_Entry/README.rst @@ -0,0 +1,43 @@ +============================ + ABQ Data Entry Application +============================ + +Description +=========== + +This program provides a data entry form for ABQ Agrilabs laboratory data. + +Features +-------- + + * Provides a validated entry form to ensure correct data + * Stores data to ABQ-format CSV files + * Auto-fills form fields whenever possible + +Authors +======= + +Alan D Moore, 2021 + +Requirements +============ + + * Python 3.7 or higher + * Tkinter + +Usage +===== + +To start the application, run:: + + python3 ABQ_Data_Entry/abq_data_entry.py + + +General Notes +============= + +The CSV file will be saved to your current directory in the format +``abq_data_record_CURRENTDATE.csv``, where CURRENTDATE is today's date in ISO format. + +This program only appends to the CSV file. You should have a spreadsheet program +installed in case you need to edit or check the file. diff --git a/Chapter07/ABQ_Data_Entry/abq_data_entry.py b/Chapter07/ABQ_Data_Entry/abq_data_entry.py new file mode 100644 index 0000000..a3b3a0d --- /dev/null +++ b/Chapter07/ABQ_Data_Entry/abq_data_entry.py @@ -0,0 +1,4 @@ +from abq_data_entry.application import Application + +app = Application() +app.mainloop() diff --git a/Chapter07/ABQ_Data_Entry/abq_data_entry/__init__.py b/Chapter07/ABQ_Data_Entry/abq_data_entry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Chapter07/ABQ_Data_Entry/abq_data_entry/application.py b/Chapter07/ABQ_Data_Entry/abq_data_entry/application.py new file mode 100644 index 0000000..804a5c1 --- /dev/null +++ b/Chapter07/ABQ_Data_Entry/abq_data_entry/application.py @@ -0,0 +1,159 @@ +"""The application/controller class for ABQ Data Entry""" + +import tkinter as tk +from tkinter import ttk +from tkinter import messagebox +from tkinter import filedialog + +from . import views as v +from . import models as m +from .mainmenu import MainMenu + +class Application(tk.Tk): + """Application root window""" + + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Hide window while GUI is built + self.withdraw() + + # Authenticate + if not self._show_login(): + self.destroy() + return + + # show the window + self.deiconify() + + # Create model + self.model = m.CSVModel() + + # Load settings + # self.settings = { + # 'autofill date': tk.BooleanVar(), + # 'autofill sheet data': tk.BoleanVar() + # } + self.settings_model = m.SettingsModel() + self._load_settings() + + # Begin building GUI + self.title("ABQ Data Entry Application") + self.columnconfigure(0, weight=1) + + # Create the menu + menu = MainMenu(self, self.settings) + self.config(menu=menu) + event_callbacks = { + '<>': self._on_file_select, + '<>': lambda _: self.quit(), + } + for sequence, callback in event_callbacks.items(): + self.bind(sequence, callback) + + + ttk.Label( + self, + text="ABQ Data Entry Application", + font=("TkDefaultFont", 16) + ).grid(row=0) + + self.recordform = v.DataRecordForm(self, self.model, self.settings) + self.recordform.grid(row=1, padx=10, sticky=(tk.W + tk.E)) + self.recordform.bind('<>', self._on_save) + + # status bar + self.status = tk.StringVar() + self.statusbar = ttk.Label(self, textvariable=self.status) + self.statusbar.grid(sticky=(tk.W + tk.E), row=3, padx=10) + + + self.records_saved = 0 + + + def _on_save(self, *_): + """Handles file-save requests""" + + # Check for errors first + + errors = self.recordform.get_errors() + if errors: + self.status.set( + "Cannot save, error in fields: {}" + .format(', '.join(errors.keys())) + ) + message = "Cannot save record" + detail = "The following fields have errors: \n * {}".format( + '\n * '.join(errors.keys()) + ) + messagebox.showerror( + title='Error', + message=message, + detail=detail + ) + return False + + data = self.recordform.get() + self.model.save_record(data) + self.records_saved += 1 + self.status.set( + "{} records saved this session".format(self.records_saved) + ) + self.recordform.reset() + + def _on_file_select(self, *_): + """Handle the file->select action""" + + filename = filedialog.asksaveasfilename( + title='Select the target file for saving records', + defaultextension='.csv', + filetypes=[('CSV', '*.csv *.CSV')] + ) + if filename: + self.model = m.CSVModel(filename=filename) + + @staticmethod + def _simple_login(username, password): + """A basic authentication backend with a hardcoded user and password""" + return username == 'abq' and password == 'Flowers' + + def _show_login(self): + """Show login dialog and attempt to login""" + error = '' + title = "Login to ABQ Data Entry" + while True: + login = v.LoginDialog(self, title, error) + if not login.result: # User canceled + return False + username, password = login.result + if self._simple_login(username, password): + return True + error = 'Login Failed' # loop and redisplay + + def _load_settings(self): + """Load settings into our self.settings dict.""" + + vartypes = { + 'bool': tk.BooleanVar, + 'str': tk.StringVar, + 'int': tk.IntVar, + 'float': tk.DoubleVar + } + + # create our dict of settings variables from the model's settings. + self.settings = dict() + for key, data in self.settings_model.fields.items(): + vartype = vartypes.get(data['type'], tk.StringVar) + self.settings[key] = vartype(value=data['value']) + + # put a trace on the variables so they get stored when changed. + for var in self.settings.values(): + var.trace_add('write', self._save_settings) + + def _save_settings(self, *_): + """Save the current settings to a preferences file""" + + for key, variable in self.settings.items(): + self.settings_model.set(key, variable.get()) + self.settings_model.save() diff --git a/Chapter07/ABQ_Data_Entry/abq_data_entry/constants.py b/Chapter07/ABQ_Data_Entry/abq_data_entry/constants.py new file mode 100644 index 0000000..e747dce --- /dev/null +++ b/Chapter07/ABQ_Data_Entry/abq_data_entry/constants.py @@ -0,0 +1,12 @@ +"""Global constants and classes needed by other modules in ABQ Data Entry""" +from enum import Enum, auto + +class FieldTypes(Enum): + string = auto() + string_list = auto() + short_string_list = auto() + iso_date_string = auto() + long_string = auto() + decimal = auto() + integer = auto() + boolean = auto() diff --git a/Chapter07/ABQ_Data_Entry/abq_data_entry/mainmenu.py b/Chapter07/ABQ_Data_Entry/abq_data_entry/mainmenu.py new file mode 100644 index 0000000..36b62ee --- /dev/null +++ b/Chapter07/ABQ_Data_Entry/abq_data_entry/mainmenu.py @@ -0,0 +1,70 @@ +"""The Main Menu class for ABQ Data Entry""" + +import tkinter as tk +from tkinter import messagebox + +class MainMenu(tk.Menu): + """The Application's main menu""" + + def _event(self, sequence): + """Return a callback function that generates the sequence""" + def callback(*_): + root = self.master.winfo_toplevel() + root.event_generate(sequence) + + return callback + + def __init__(self, parent, settings, **kwargs): + """Constructor for MainMenu + + arguments: + parent - The parent widget + settings - a dict containing Tkinter variables + """ + super().__init__(parent, **kwargs) + self.settings = settings + + # The help menu + help_menu = tk.Menu(self, tearoff=False) + help_menu.add_command(label='About…', command=self.show_about) + + # The file menu + file_menu = tk.Menu(self, tearoff=False) + file_menu.add_command( + label="Select file…", + command=self._event('<>') + ) + + file_menu.add_separator() + file_menu.add_command( + label="Quit", + command=self._event('<>') + ) + + # The options menu + options_menu = tk.Menu(self, tearoff=False) + options_menu.add_checkbutton( + label='Autofill Date', + variable=self.settings['autofill date'] + ) + options_menu.add_checkbutton( + label='Autofill Sheet data', + variable=self.settings['autofill sheet data'] + ) + + # add the menus in order to the main menu + self.add_cascade(label='File', menu=file_menu) + self.add_cascade(label='Options', menu=options_menu) + self.add_cascade(label='Help', menu=help_menu) + + + def show_about(self): + """Show the about dialog""" + + about_message = 'ABQ Data Entry' + about_detail = ( + 'by Alan D Moore\n' + 'For assistance please contact the author.' + ) + + messagebox.showinfo(title='About', message=about_message, detail=about_detail) diff --git a/Chapter07/ABQ_Data_Entry/abq_data_entry/models.py b/Chapter07/ABQ_Data_Entry/abq_data_entry/models.py new file mode 100644 index 0000000..7b4f837 --- /dev/null +++ b/Chapter07/ABQ_Data_Entry/abq_data_entry/models.py @@ -0,0 +1,122 @@ +import csv +from pathlib import Path +import json +import os + +from .constants import FieldTypes as FT +from decimal import Decimal +from datetime import datetime + +class CSVModel: + """CSV file storage""" + + fields = { + "Date": {'req': True, 'type': FT.iso_date_string}, + "Time": {'req': True, 'type': FT.string_list, + 'values': ['8:00', '12:00', '16:00', '20:00']}, + "Technician": {'req': True, 'type': FT.string}, + "Lab": {'req': True, 'type': FT.short_string_list, + 'values': ['A', 'B', 'C']}, + "Plot": {'req': True, 'type': FT.string_list, + 'values': [str(x) for x in range(1, 21)]}, + "Seed Sample": {'req': True, 'type': FT.string}, + "Humidity": {'req': True, 'type': FT.decimal, + 'min': 0.5, 'max': 52.0, 'inc': .01}, + "Light": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 100.0, 'inc': .01}, + "Temperature": {'req': True, 'type': FT.decimal, + 'min': 4, 'max': 40, 'inc': .01}, + "Equipment Fault": {'req': False, 'type': FT.boolean}, + "Plants": {'req': True, 'type': FT.integer, 'min': 0, 'max': 20}, + "Blossoms": {'req': True, 'type': FT.integer, 'min': 0, 'max': 1000}, + "Fruit": {'req': True, 'type': FT.integer, 'min': 0, 'max': 1000}, + "Min Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Max Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Med Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Notes": {'req': False, 'type': FT.long_string} + } + + + def __init__(self, filename=None): + + if not filename: + datestring = datetime.today().strftime("%Y-%m-%d") + filename = "abq_data_record_{}.csv".format(datestring) + self.file = Path(filename) + + # Check for append permissions: + file_exists = os.access(self.file, os.F_OK) + parent_writeable = os.access(self.file.parent, os.W_OK) + file_writeable = os.access(self.file, os.W_OK) + if ( + (not file_exists and not parent_writeable) or + (file_exists and not file_writeable) + ): + msg = f'Permission denied accessing file: {filename}' + raise PermissionError(msg) + + + + def save_record(self, data): + """Save a dict of data to the CSV file""" + newfile = not self.file.exists() + + with open(self.file, 'a') as fh: + csvwriter = csv.DictWriter(fh, fieldnames=self.fields.keys()) + if newfile: + csvwriter.writeheader() + + csvwriter.writerow(data) + + +class SettingsModel: + """A model for saving settings""" + + fields = { + 'autofill date': {'type': 'bool', 'value': True}, + 'autofill sheet data': {'type': 'bool', 'value': True} + } + + def __init__(self): + # determine the file path + filename = 'abq_settings.json' + self.filepath = Path.home() / filename + + # load in saved values + self.load() + + def set(self, key, value): + """Set a variable value""" + if ( + key in self.fields and + type(value).__name__ == self.fields[key]['type'] + ): + self.fields[key]['value'] = value + else: + raise ValueError("Bad key or wrong variable type") + + def save(self): + """Save the current settings to the file""" + json_string = json.dumps(self.fields) + with open(self.filepath, 'w') as fh: + fh.write(json_string) + + def load(self): + """Load the settings from the file""" + + # if the file doesn't exist, return + if not self.filepath.exists(): + return + + # open the file and read in the raw values + with open(self.filepath, 'r') as fh: + raw_values = json.loads(fh.read()) + + # don't implicitly trust the raw values, but only get known keys + for key in self.fields: + if key in raw_values and 'value' in raw_values[key]: + raw_value = raw_values[key]['value'] + self.fields[key]['value'] = raw_value diff --git a/Chapter07/ABQ_Data_Entry/abq_data_entry/views.py b/Chapter07/ABQ_Data_Entry/abq_data_entry/views.py new file mode 100644 index 0000000..d1947fb --- /dev/null +++ b/Chapter07/ABQ_Data_Entry/abq_data_entry/views.py @@ -0,0 +1,297 @@ +import tkinter as tk +from tkinter import ttk +from tkinter.simpledialog import Dialog +from datetime import datetime +from . import widgets as w +from .constants import FieldTypes as FT + +class DataRecordForm(tk.Frame): + """The input form for our widgets""" + + var_types = { + FT.string: tk.StringVar, + FT.string_list: tk.StringVar, + FT.short_string_list: tk.StringVar, + FT.iso_date_string: tk.StringVar, + FT.long_string: tk.StringVar, + FT.decimal: tk.DoubleVar, + FT.integer: tk.IntVar, + FT.boolean: tk.BooleanVar + } + + def _add_frame(self, label, cols=3): + """Add a labelframe to the form""" + + frame = ttk.LabelFrame(self, text=label) + frame.grid(sticky=tk.W + tk.E) + for i in range(cols): + frame.columnconfigure(i, weight=1) + return frame + + def __init__(self, parent, model, settings, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + + self.model= model + self.settings = settings + fields = self.model.fields + + # Create a dict to keep track of input widgets + self._vars = { + key: self.var_types[spec['type']]() + for key, spec in fields.items() + } + + # Build the form + self.columnconfigure(0, weight=1) + + # Record info section + r_info = self._add_frame("Record Information") + + # line 1 + w.LabelInput( + r_info, "Date", + field_spec=fields['Date'], + var=self._vars['Date'], + ).grid(row=0, column=0) + w.LabelInput( + r_info, "Time", + field_spec=fields['Time'], + var=self._vars['Time'], + ).grid(row=0, column=1) + w.LabelInput( + r_info, "Lab", + field_spec=fields['Lab'], + var=self._vars['Lab'], + ).grid(row=0, column=2) + # line 2 + w.LabelInput( + r_info, "Plot", + field_spec=fields['Plot'], + var=self._vars['Plot'], + ).grid(row=1, column=0) + w.LabelInput( + r_info, "Technician", + field_spec=fields['Technician'], + var=self._vars['Technician'], + ).grid(row=1, column=1) + w.LabelInput( + r_info, "Seed Sample", + field_spec=fields['Seed Sample'], + var=self._vars['Seed Sample'], + ).grid(row=1, column=2) + + + # Environment Data + e_info = self._add_frame("Environment Data") + + w.LabelInput( + e_info, "Humidity (g/m³)", + field_spec=fields['Humidity'], + var=self._vars['Humidity'], + disable_var=self._vars['Equipment Fault'] + ).grid(row=0, column=0) + w.LabelInput( + e_info, "Light (klx)", + field_spec=fields['Light'], + var=self._vars['Light'], + disable_var=self._vars['Equipment Fault'] + ).grid(row=0, column=1) + w.LabelInput( + e_info, "Temperature (°C)", + field_spec=fields['Temperature'], + var=self._vars['Temperature'], + disable_var=self._vars['Equipment Fault'] + ).grid(row=0, column=2) + w.LabelInput( + e_info, "Equipment Fault", + field_spec=fields['Equipment Fault'], + var=self._vars['Equipment Fault'], + ).grid(row=1, column=0, columnspan=3) + + # Plant Data section + p_info = self._add_frame("Plant Data") + + w.LabelInput( + p_info, "Plants", + field_spec=fields['Plants'], + var=self._vars['Plants'], + ).grid(row=0, column=0) + w.LabelInput( + p_info, "Blossoms", + field_spec=fields['Blossoms'], + var=self._vars['Blossoms'], + ).grid(row=0, column=1) + w.LabelInput( + p_info, "Fruit", + field_spec=fields['Fruit'], + var=self._vars['Fruit'], + ).grid(row=0, column=2) + + # Height data + # create variables to be updated for min/max height + # they can be referenced for min/max variables + min_height_var = tk.DoubleVar(value='-infinity') + max_height_var = tk.DoubleVar(value='infinity') + + w.LabelInput( + p_info, "Min Height (cm)", + field_spec=fields['Min Height'], + var=self._vars['Min Height'], + input_args={ + "max_var": max_height_var, "focus_update_var": min_height_var + }, + ).grid(row=1, column=0) + w.LabelInput( + p_info, "Max Height (cm)", + field_spec=fields['Max Height'], + var=self._vars['Max Height'], + input_args={ + "min_var": min_height_var, "focus_update_var": max_height_var + }, + ).grid(row=1, column=1) + w.LabelInput( + p_info, "Median Height (cm)", + field_spec=fields['Med Height'], + var=self._vars['Med Height'], + input_args={ + "min_var": min_height_var, "max_var": max_height_var + }, + ).grid(row=1, column=2) + + + # Notes section + w.LabelInput( + self, "Notes", field_spec=fields['Notes'], + var=self._vars['Notes'], input_args={"width": 85, "height": 10} + ).grid(sticky="nsew", row=3, column=0, padx=10, pady=10) + + # buttons + buttons = tk.Frame(self) + buttons.grid(sticky=tk.W + tk.E, row=4) + self.savebutton = ttk.Button( + buttons, text="Save", command=self._on_save) + self.savebutton.pack(side=tk.RIGHT) + + self.resetbutton = ttk.Button( + buttons, text="Reset", command=self.reset) + self.resetbutton.pack(side=tk.RIGHT) + + # default the form + self.reset() + + def _on_save(self): + self.event_generate('<>') + + def get(self): + """Retrieve data from form as a dict""" + + # We need to retrieve the data from Tkinter variables + # and place it in regular Python objects + data = dict() + for key, variable in self._vars.items(): + try: + data[key] = variable.get() + except tk.TclError: + message = f'Error in field: {key}. Data was not saved!' + raise ValueError(message) + + return data + + def reset(self): + """Resets the form entries""" + + lab = self._vars['Lab'].get() + time = self._vars['Time'].get() + technician = self._vars['Technician'].get() + try: + plot = self._vars['Plot'].get() + except tk.TclError: + plot = '' + plot_values = self._vars['Plot'].label_widget.input.cget('values') + + # clear all values + for var in self._vars.values(): + if isinstance(var, tk.BooleanVar): + var.set(False) + else: + var.set('') + + # Autofill Date + if self.settings['autofill date'].get(): + current_date = datetime.today().strftime('%Y-%m-%d') + self._vars['Date'].set(current_date) + self._vars['Time'].label_widget.input.focus() + + # check if we need to put our values back, then do it. + if ( + self.settings['autofill sheet data'].get() and + plot not in ('', 0, plot_values[-1]) + ): + self._vars['Lab'].set(lab) + self._vars['Time'].set(time) + self._vars['Technician'].set(technician) + next_plot_index = plot_values.index(plot) + 1 + self._vars['Plot'].set(plot_values[next_plot_index]) + self._vars['Seed Sample'].label_widget.input.focus() + + def get_errors(self): + """Get a list of field errors in the form""" + + errors = dict() + for key, var in self._vars.items(): + inp = var.label_widget.input + error = var.label_widget.error + + if hasattr(inp, 'trigger_focusout_validation'): + inp.trigger_focusout_validation() + if error.get(): + errors[key] = error.get() + + return errors + + +class LoginDialog(Dialog): + """A dialog that asks for username and password""" + + def __init__(self, parent, title, error=''): + + self._pw = tk.StringVar() + self._user = tk.StringVar() + self._error = tk.StringVar(value=error) + super().__init__(parent, title=title) + + def body(self, frame): + """Construct the interface and return the widget for initial focus + + Overridden from Dialog + """ + ttk.Label(frame, text='Login to ABQ').grid(row=0) + + if self._error.get(): + ttk.Label(frame, textvariable=self._error).grid(row=1) + user_inp = w.LabelInput( + frame, 'User name:', input_class=w.RequiredEntry, + var=self._user + ) + user_inp.grid() + w.LabelInput( + frame, 'Password:', input_class=w.RequiredEntry, + input_args={'show': '*'}, var=self._pw + ).grid() + return user_inp.input + + def buttonbox(self): + box = ttk.Frame(self) + ttk.Button( + box, text="Login", command=self.ok, default=tk.ACTIVE + ).grid(padx=5, pady=5) + ttk.Button( + box, text="Cancel", command=self.cancel + ).grid(row=0, column=1, padx=5, pady=5) + self.bind("", self.ok) + self.bind("", self.cancel) + box.pack() + + + def apply(self): + self.result = (self._user.get(), self._pw.get()) diff --git a/Chapter07/ABQ_Data_Entry/abq_data_entry/widgets.py b/Chapter07/ABQ_Data_Entry/abq_data_entry/widgets.py new file mode 100644 index 0000000..14783c1 --- /dev/null +++ b/Chapter07/ABQ_Data_Entry/abq_data_entry/widgets.py @@ -0,0 +1,411 @@ +import tkinter as tk +from tkinter import ttk +from datetime import datetime +from decimal import Decimal, InvalidOperation +from .constants import FieldTypes as FT + + +################## +# Widget Classes # +################## + +class ValidatedMixin: + """Adds a validation functionality to an input widget""" + + def __init__(self, *args, error_var=None, **kwargs): + self.error = error_var or tk.StringVar() + super().__init__(*args, **kwargs) + + vcmd = self.register(self._validate) + invcmd = self.register(self._invalid) + + self.configure( + validate='all', + validatecommand=(vcmd, '%P', '%s', '%S', '%V', '%i', '%d'), + invalidcommand=(invcmd, '%P', '%s', '%S', '%V', '%i', '%d') + ) + + def _toggle_error(self, on=False): + self.configure(foreground=('red' if on else 'black')) + + def _validate(self, proposed, current, char, event, index, action): + """The validation method. + + Don't override this, override _key_validate, and _focus_validate + """ + self.error.set('') + self._toggle_error() + + valid = True + # if the widget is disabled, don't validate + state = str(self.configure('state')[-1]) + if state == tk.DISABLED: + return valid + + if event == 'focusout': + valid = self._focusout_validate(event=event) + elif event == 'key': + valid = self._key_validate( + proposed=proposed, + current=current, + char=char, + event=event, + index=index, + action=action + ) + return valid + + def _focusout_validate(self, **kwargs): + return True + + def _key_validate(self, **kwargs): + return True + + def _invalid(self, proposed, current, char, event, index, action): + if event == 'focusout': + self._focusout_invalid(event=event) + elif event == 'key': + self._key_invalid( + proposed=proposed, + current=current, + char=char, + event=event, + index=index, + action=action + ) + + def _focusout_invalid(self, **kwargs): + """Handle invalid data on a focus event""" + self._toggle_error(True) + + def _key_invalid(self, **kwargs): + """Handle invalid data on a key event. By default we want to do nothing""" + pass + + def trigger_focusout_validation(self): + valid = self._validate('', '', '', 'focusout', '', '') + if not valid: + self._focusout_invalid(event='focusout') + return valid + + +class DateEntry(ValidatedMixin, ttk.Entry): + + def _key_validate(self, action, index, char, **kwargs): + valid = True + + if action == '0': # This is a delete action + valid = True + elif index in ('0', '1', '2', '3', '5', '6', '8', '9'): + valid = char.isdigit() + elif index in ('4', '7'): + valid = char == '-' + else: + valid = False + return valid + + def _focusout_validate(self, event): + valid = True + if not self.get(): + self.error.set('A value is required') + valid = False + try: + datetime.strptime(self.get(), '%Y-%m-%d') + except ValueError: + self.error.set('Invalid date') + valid = False + return valid + + +class RequiredEntry(ValidatedMixin, ttk.Entry): + + def _focusout_validate(self, event): + valid = True + if not self.get(): + valid = False + self.error.set('A value is required') + return valid + + +class ValidatedCombobox(ValidatedMixin, ttk.Combobox): + + def _key_validate(self, proposed, action, **kwargs): + valid = True + # if the user tries to delete, + # just clear the field + if action == '0': + self.set('') + return True + + # get our values list + values = self.cget('values') + # Do a case-insensitve match against the entered text + matching = [ + x for x in values + if x.lower().startswith(proposed.lower()) + ] + if len(matching) == 0: + valid = False + elif len(matching) == 1: + self.set(matching[0]) + self.icursor(tk.END) + valid = False + return valid + + def _focusout_validate(self, **kwargs): + valid = True + if not self.get(): + valid = False + self.error.set('A value is required') + return valid + + +class ValidatedSpinbox(ValidatedMixin, ttk.Spinbox): + """A Spinbox that only accepts Numbers""" + + def __init__(self, *args, min_var=None, max_var=None, + focus_update_var=None, from_='-Infinity', to='Infinity', **kwargs + ): + super().__init__(*args, from_=from_, to=to, **kwargs) + increment = Decimal(str(kwargs.get('increment', '1.0'))) + self.precision = increment.normalize().as_tuple().exponent + # there should always be a variable, + # or some of our code will fail + self.variable = kwargs.get('textvariable') + if not self.variable: + self.variable = tk.DoubleVar() + self.configure(textvariable=self.variable) + + if min_var: + self.min_var = min_var + self.min_var.trace_add('write', self._set_minimum) + if max_var: + self.max_var = max_var + self.max_var.trace_add('write', self._set_maximum) + self.focus_update_var = focus_update_var + self.bind('', self._set_focus_update_var) + + def _set_focus_update_var(self, event): + value = self.get() + if self.focus_update_var and not self.error.get(): + self.focus_update_var.set(value) + + def _set_minimum(self, *args): + current = self.get() + try: + new_min = self.min_var.get() + self.config(from_=new_min) + except (tk.TclError, ValueError): + pass + if not current: + self.delete(0, tk.END) + else: + self.variable.set(current) + self.trigger_focusout_validation() + + def _set_maximum(self, *args): + current = self.get() + try: + new_max = self.max_var.get() + self.config(to=new_max) + except (tk.TclError, ValueError): + pass + if not current: + self.delete(0, tk.END) + else: + self.variable.set(current) + self.trigger_focusout_validation() + + def _key_validate( + self, char, index, current, proposed, action, **kwargs + ): + valid = True + min_val = self.cget('from') + max_val = self.cget('to') + no_negative = min_val >= 0 + no_decimal = self.precision >= 0 + if action == '0': + return True + + # First, filter out obviously invalid keystrokes + if any([ + (char not in '-1234567890.'), + (char == '-' and (no_negative or index != '0')), + (char == '.' and (no_decimal or '.' in current)) + ]): + return False + + # At this point, proposed is either '-', '.', '-.', + # or a valid Decimal string + if proposed in '-.': + return True + + # Proposed is a valid Decimal string + # convert to Decimal and check more: + proposed = Decimal(proposed) + proposed_precision = proposed.as_tuple().exponent + + if any([ + (proposed > max_val), + (proposed_precision < self.precision) + ]): + return False + + return valid + + def _focusout_validate(self, **kwargs): + valid = True + value = self.get() + min_val = self.cget('from') + max_val = self.cget('to') + + try: + value = Decimal(value) + except InvalidOperation: + self.error.set('Invalid number string: {}'.format(value)) + return False + + if value < min_val: + self.error.set('Value is too low (min {})'.format(min_val)) + valid = False + if value > max_val: + self.error.set('Value is too high (max {})'.format(max_val)) + + return valid + +class BoundText(tk.Text): + """A Text widget with a bound variable.""" + + def __init__(self, *args, textvariable=None, **kwargs): + super().__init__(*args, **kwargs) + self._variable = textvariable + self._modifying = False + if self._variable: + # insert any default value + self.insert('1.0', self._variable.get()) + self._variable.trace_add('write', self._set_content) + self.bind('<>', self._set_var) + + def _clear_modified_flag(self): + # This also triggers a '<>' Event + self.tk.call(self._w, 'edit', 'modified', 0) + + def _set_var(self, *_): + """Set the variable to the text contents""" + if self._modifying: + return + self._modifying = True + # remove trailing newline from content + content = self.get('1.0', tk.END)[:-1] + self._variable.set(content) + self._clear_modified_flag() + self._modifying = False + + def _set_content(self, *_): + """Set the text contents to the variable""" + if self._modifying: + return + self._modifying = True + self.delete('1.0', tk.END) + self.insert('1.0', self._variable.get()) + self._modifying = False + + +########################### +# Compound Widget Classes # +########################### + + +class LabelInput(ttk.Frame): + """A widget containing a label and input together.""" + + field_types = { + FT.string: RequiredEntry, + FT.string_list: ValidatedCombobox, + FT.short_string_list: ttk.Radiobutton, + FT.iso_date_string: DateEntry, + FT.long_string: BoundText, + FT.decimal: ValidatedSpinbox, + FT.integer: ValidatedSpinbox, + FT.boolean: ttk.Checkbutton + } + + def __init__( + self, parent, label, var, input_class=None, + input_args=None, label_args=None, field_spec=None, + disable_var=None, **kwargs + ): + super().__init__(parent, **kwargs) + input_args = input_args or {} + label_args = label_args or {} + self.variable = var + self.variable.label_widget = self + + # Process the field spec to determine input_class and validation + if field_spec: + field_type = field_spec.get('type', FT.string) + input_class = input_class or self.field_types.get(field_type) + # min, max, increment + if 'min' in field_spec and 'from_' not in input_args: + input_args['from_'] = field_spec.get('min') + if 'max' in field_spec and 'to' not in input_args: + input_args['to'] = field_spec.get('max') + if 'inc' in field_spec and 'increment' not in input_args: + input_args['increment'] = field_spec.get('inc') + # values + if 'values' in field_spec and 'values' not in input_args: + input_args['values'] = field_spec.get('values') + + # setup the label + if input_class in (ttk.Checkbutton, ttk.Button): + # Buttons don't need labels, they're built-in + input_args["text"] = label + else: + self.label = ttk.Label(self, text=label, **label_args) + self.label.grid(row=0, column=0, sticky=(tk.W + tk.E)) + + # setup the variable + if input_class in (ttk.Checkbutton, ttk.Button, ttk.Radiobutton): + input_args["variable"] = self.variable + else: + input_args["textvariable"] = self.variable + + # Setup the input + if input_class == ttk.Radiobutton: + # for Radiobutton, create one input per value + self.input = tk.Frame(self) + for v in input_args.pop('values', []): + button = ttk.Radiobutton( + self.input, value=v, text=v, **input_args) + button.pack(side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x') + else: + self.input = input_class(self, **input_args) + + self.input.grid(row=1, column=0, sticky=(tk.W + tk.E)) + self.columnconfigure(0, weight=1) + + # Set up error handling & display + self.error = getattr(self.input, 'error', tk.StringVar()) + ttk.Label(self, textvariable=self.error).grid( + row=2, column=0, sticky=(tk.W + tk.E) + ) + + # Set up disable variable + if disable_var: + self.disable_var = disable_var + self.disable_var.trace_add('write', self._check_disable) + + def _check_disable(self, *_): + if not hasattr(self, 'disable_var'): + return + + if self.disable_var.get(): + self.input.configure(state=tk.DISABLED) + self.variable.set('') + self.error.set('') + else: + self.input.configure(state=tk.NORMAL) + + def grid(self, sticky=(tk.E + tk.W), **kwargs): + """Override grid to add default sticky values""" + super().grid(sticky=sticky, **kwargs) diff --git a/Chapter07/ABQ_Data_Entry/docs/Application_layout.png b/Chapter07/ABQ_Data_Entry/docs/Application_layout.png new file mode 100644 index 0000000000000000000000000000000000000000..93990f232d19518ca465e7715bbf4f0f10cabce9 GIT binary patch literal 9117 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 mufwd~J*Rr~aor9m$$ITZW{ Date: Wed, 2 Jun 2021 14:31:10 -0500 Subject: [PATCH 06/32] change script name to banana_survey.py --- Chapter01/banana_survey.py | 171 +++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 Chapter01/banana_survey.py diff --git a/Chapter01/banana_survey.py b/Chapter01/banana_survey.py new file mode 100644 index 0000000..b7c186a --- /dev/null +++ b/Chapter01/banana_survey.py @@ -0,0 +1,171 @@ +"""A banana preferences survey written in Python with Tkinter""" + +import tkinter as tk + +# Create the root window +root = tk.Tk() + +# set the title +root.title('Banana interest survey') + +# set the root window size +root.geometry('640x480+300+300') +root.resizable(False, False) + +########### +# Widgets # +########### + +# Use a Label to show the title +# 'font' lets us set a font +title = tk.Label( + root, + text='Please take the survey', + font=('Arial 16 bold'), + bg='brown', + fg='#FF0' +) + +# Use an Entry to get a string +name_label = tk.Label(root, text='What is your name?') +name_inp = tk.Entry(root) + +# Use Checkbutton to get a boolean +eater_inp = tk.Checkbutton(root, text='Check this box if you eat bananas') + +# Spinboxes are good for number entry +num_label = tk.Label(root, text='How many bananas do you eat per day?') +num_inp = tk.Spinbox(root, from_=0, increment=1, value=3) + +# Listbox is good for choices + +color_label = tk.Label(root, text='What is the best color for a banana?') +color_inp = tk.Listbox(root, height=1) # Only show selected item +# add choices +color_choices = ( + 'Any', + 'Green', + 'Green-Yellow', + 'Yellow', + 'Brown spotted', + 'Black' +) +for choice in color_choices: + # END is a tkinter constant that means the end of an input + color_inp.insert(tk.END, choice) + + +# RadioButtons are good for small choices + +plantain_label = tk.Label(root, text='Do you eat plantains?') +# Use a Frame to keep widgets together +plantain_frame = tk.Frame(root) +plantain_yes_inp = tk.Radiobutton(plantain_frame, text='Yes') +plantain_no_inp = tk.Radiobutton(plantain_frame, text='Ewww, no!') + +# The Text widget is good for long pieces of text +banana_haiku_label = tk.Label(root, text='Write a haiku about bananas') +banana_haiku_inp = tk.Text(root, height=3) + +# Buttons are used to trigger actions + +submit_btn = tk.Button(root, text='Submit Survey') + +# Use a label to display a line of output +# 'anchor' sets where the text is stuck if the label is wider than needed. +# 'justify' determines how multiple lines of text are aligned +output_line = tk.Label(root, text='', anchor='w', justify='left') + + +####################### +# Geometry Management # +####################### +# Using Grid instead of pack +# Put our widgets on the root window +#title.grid() +# columnspan allows the widget to span multiple columns +title.grid(columnspan=2) + +# add name label and input +# Column defaults to 0 +name_label.grid(row=1, column=0) + +# The grid automatically expands +# when we add a widget to the next row or column +name_inp.grid(row=1, column=1) + +# 'sticky' attaches the widget to the named sides, +# so it will expand with the grid +eater_inp.grid(row=2, columnspan=2, sticky='we') +# tk constants can be used instead of strings +num_label.grid(row=3, sticky=tk.W) +num_inp.grid(row=3, column=1, sticky=(tk.W + tk.E)) + +#padx and pady can still be used to add horizontal or vertical padding +color_label.grid(row=4, columnspan=2, sticky=tk.W, pady=10) +color_inp.grid(row=5, columnspan=2, sticky=tk.W + tk.E, padx=25) + +# We can still use pack on the plantain frame. +# pack and grid can be mixed in a layout as long as we don't +# use them in the same frame +plantain_yes_inp.pack(side='left', fill='x', ipadx=10, ipady=5) +plantain_no_inp.pack(side='left', fill='x', ipadx=10, ipady=5) +plantain_label.grid(row=6, columnspan=2, sticky=tk.W) +plantain_frame.grid(row=7, columnspan=2, stick=tk.W) + +# Sticky on all sides will allow the widget to fill vertical and horizontal +banana_haiku_label.grid(row=8, sticky=tk.W) +banana_haiku_inp.grid(row=9, columnspan=2, sticky='NSEW') + +# Add the button and output +submit_btn.grid(row=99) +output_line.grid(row=100, columnspan=2, sticky='NSEW') + +# columnconfigure can be used to set options on the columns of the grid +# 'weight' means that column will be preferred for expansion +root.columnconfigure(1, weight=1) + +# rowconfigure works for rows +root.rowconfigure(99, weight=2) +root.rowconfigure(100, weight=1) + +##################### +# Add some behavior # +##################### + +def on_submit(): + """To be run when the user submits the form""" + + # Many widgets use "get" to retrieve contents + name = name_inp.get() + # spinboxes return a str, not a float or int! + number = num_inp.get() + # Listboxes are more involved + selected_idx = color_inp.curselection() + if selected_idx: + color = color_inp.get(selected_idx) + else: + color = '' + # We're going to need some way to get our button values! + # banana_eater = ???? + + # Text widgets require a range + haiku = banana_haiku_inp.get('1.0', tk.END) + + # Update the text in our output + message = ( + f'Thanks for taking the survey, {name}.\n' + f'Enjoy your {number} {color} bananas!' + ) + output_line.configure(text=message) + print(haiku) + + +# configure the button to trigger submission +submit_btn.configure(command=on_submit) + +############### +# Execute App # +############### + +root.mainloop() From 29c41532827d01e0699210910f87f02e1896ea40 Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Wed, 2 Jun 2021 14:32:04 -0500 Subject: [PATCH 07/32] changes following review --- Chapter01/banana_survey_no_vars.py | 169 ------------------------ Chapter01/banana_survey_pack_version.py | 169 ------------------------ Chapter01/banana_survey_variables.py | 4 +- Chapter01/hello_tkinter.py | 8 +- 4 files changed, 6 insertions(+), 344 deletions(-) delete mode 100644 Chapter01/banana_survey_no_vars.py delete mode 100644 Chapter01/banana_survey_pack_version.py diff --git a/Chapter01/banana_survey_no_vars.py b/Chapter01/banana_survey_no_vars.py deleted file mode 100644 index 6987910..0000000 --- a/Chapter01/banana_survey_no_vars.py +++ /dev/null @@ -1,169 +0,0 @@ -"""A banana preferences survey written in Python with Tkinter""" - -import tkinter as tk - -# Create the root window -root = tk.Tk() - -# set the title -root.title('Banana interest survey') - -# set the root window size -root.geometry('640x480+300+300') -root.resizable(False, False) - -########### -# Widgets # -########### - -# Use a Label to show the title -# 'font' lets us set a font -title = tk.Label( - root, - text='Please take the survey', - font=('Arial 16 bold'), - bg='brown', - fg='#FF0' -) - -# Use an Entry to get a string -name_label = tk.Label(root, text='What is your name?') -name_inp = tk.Entry(root) - -# Use Checkbutton to get a boolean -eater_inp = tk.Checkbutton(root, text='Check this box if you eat bananas') - -# Spinboxes are good for number entry -num_label = tk.Label(root, text='How many bananas do you eat per day?') -num_inp = tk.Spinbox(root, from_=0, increment=1, value=3) - -# Listbox is good for choices - -color_label = tk.Label(root, text='What is the best color for a banana?') -color_inp = tk.Listbox(root, height=1) # Only show selected item -# add choices -color_choices = ( - 'Any', - 'Green', - 'Green-Yellow', - 'Yellow', - 'Brown spotted', - 'Black' -) -for choice in color_choices: - # END is a tkinter constant that means the end of an input - color_inp.insert(tk.END, choice) - - -# RadioButtons are good for small choices - -plantain_label = tk.Label(root, text='Do you eat plantains?') -# Use a Frame to keep widgets together -plantain_frame = tk.Frame(root) -plantain_yes_inp = tk.Radiobutton(plantain_frame, text='Yes') -plantain_no_inp = tk.Radiobutton(plantain_frame, text='Ewww, no!') - -# The Text widget is good for long pieces of text -banana_haiku_label = tk.Label(root, text='Write a haiku about bananas') -banana_haiku_inp = tk.Text(root, height=3) - -# Buttons are used to trigger actions - -submit_btn = tk.Button(root, text='Submit Survey') - -# Use a label to display a line of output -# 'anchor' sets where the text is stuck if the label is wider than needed. -# 'justify' determines how multiple lines of text are aligned -output_line = tk.Label(root, text='', anchor='w', justify='left') - - -####################### -# Geometry Management # -####################### -# Using Grid instead of pack -# Put our widgets on the root window -#title.grid() -# columnspan allows the widget to span multiple columns -title.grid(columnspan=2) - -# add name label and input -# Column defaults to 0 -name_label.grid(row=1, column=0) - -# The grid automatically expands -# when we add a widget to the next row or column -name_inp.grid(row=1, column=1) - -# 'sticky' attaches the widget to the named sides, -# so it will expand with the grid -eater_inp.grid(row=2, columnspan=2, sticky='we') -# tk constants can be used instead of strings -num_label.grid(row=3, sticky=tk.W) -num_inp.grid(row=3, column=1, sticky=(tk.W + tk.E)) - -#padx and pady can still be used to add horizontal or vertical padding -color_label.grid(row=4, columnspan=2, sticky=tk.W, pady=10) -color_inp.grid(row=5, columnspan=2, sticky=tk.W + tk.E, padx=25) - -# We can still use pack on the plantain frame. -# pack and grid can be mixed in a layout as long as we don't -# use them in the same frame -plantain_yes_inp.pack(side='left', fill='x', ipadx=10, ipady=5) -plantain_no_inp.pack(side='left', fill='x', ipadx=10, ipady=5) -plantain_label.grid(row=6, columnspan=2, sticky=tk.W) -plantain_frame.grid(row=7, columnspan=2, stick=tk.W) - -# Sticky on all sides will allow the widget to fill vertical and horizontal -banana_haiku_label.grid(row=8, sticky=tk.W) -banana_haiku_inp.grid(row=9, columnspan=2, sticky='NSEW') - -# Add the button and output -submit_btn.grid(row=99) -output_line.grid(row=100, columnspan=2, sticky='NSEW') - -# columnconfigure can be used to set options on the columns of the grid -# 'weight' means that column will be preferred for expansion -root.columnconfigure(1, weight=1) - -# rowconfigure works for rows -root.rowconfigure(99, weight=2) -root.rowconfigure(100, weight=1) - -##################### -# Add some behavior # -##################### - -def on_submit(): - """To be run when the user submits the form""" - - # Many widgets use "get" to retrieve contents - name = name_inp.get() - # spinboxes return a str, not a float or int! - number = num_inp.get() - # Listboxes are more involved - selected_idx = color_inp.curselection() - color = color_inp.get(selected_idx) - - # We're going to need some way to get our button values! - # banana_eater = ???? - - # Text widgets require a range - haiku = banana_haiku_inp.get('1.0', tk.END) - - # Update the text in our output - message = ( - f'Thanks for taking the survey, {name}.\n' - f'Enjoy your {number} {color} bananas!' - ) - output_line.configure(text=message) - print(haiku) - - -# configure the button to trigger submission -submit_btn.configure(command=on_submit) - -############### -# Execute App # -############### - -root.mainloop() diff --git a/Chapter01/banana_survey_pack_version.py b/Chapter01/banana_survey_pack_version.py deleted file mode 100644 index 83128f5..0000000 --- a/Chapter01/banana_survey_pack_version.py +++ /dev/null @@ -1,169 +0,0 @@ -"""A banana preferences survey written in Python with Tkinter""" - -import tkinter as tk - -# Create the root window -root = tk.Tk() - -# set the title -root.title('Banana interest survey') -# set the root window size -root.geometry('640x480+300+300') -root.resizable(False, False) - -########### -# Widgets # -########### - -# Use a Label to show the title -# 'font' lets us set a font -title = tk.Label( - root, - text='Please take the survey', - font=('Arial 16 bold'), - bg='brown', - fg='#FF0' -) - - -# Use an Entry to get a string -name_label = tk.Label(root, text='What is your name?') -name_inp = tk.Entry(root) - -# Use Checkbutton to get a boolean -eater_inp = tk.Checkbutton(root, text='Check this box if you eat bananas') - -# Spinboxes are good for number entry -num_label = tk.Label(root, text='How many bananas do you eat per day?') -num_inp = tk.Spinbox(root, from_=0, to=1000, increment=1, value=3) - -# Listbox is good for choices - -color_label = tk.Label(root, text='What is the best color for a banana?') -color_inp = tk.Listbox(root, height=1) # Only show selected item -# add choices -color_choices = ( - 'Any', - 'Green', - 'Green-Yellow', - 'Yellow', - 'Brown Spotted', - 'Black' - ) -for choice in color_choices: - # END is a tkinter constant that means the end of an input - color_inp.insert(tk.END, choice) - - -# RadioButtons are good for small choices - -plantain_label = tk.Label(root, text='Do you eat plantains?') -# Use a Frame to keep widgets together -plantain_frame = tk.Frame(root) -plantain_yes_inp = tk.Radiobutton(plantain_frame, text='Yes') -plantain_no_inp = tk.Radiobutton(plantain_frame, text='Ewww, no!') - -# The Text widget is good for long pieces of text -banana_haiku_label = tk.Label(root, text='Write a haiku about bananas') -banana_haiku_inp = tk.Text(root, height=3) - -# Buttons are used to trigger actions - -submit_btn = tk.Button(root, text='Submit Survey') - -# Use a label to display a line of output -# 'anchor' sets where the text is stuck if the label is wider than needed. -# 'justify' determines how multiple lines of text are aligned -output_line = tk.Label(root, text='', anchor='w', justify='left') - - -####################### -# Geometry Management # -####################### - -# Put our widgets on the root window -title.pack() - - -# add name label. It shows up underneath -# 'anchor' specifies which side our widget sticks to -# 'fill' allows our widget to expand into extra space in x, y, or both axes -name_label.pack(anchor='w') -name_inp.pack(fill='x') - -# tk constants can be used instead of strings -eater_inp.pack(anchor=tk.W) -num_label.pack(anchor=tk.W) -num_inp.pack(fill=tk.X) - -#padx and pady can be used to add horizontal or vertical padding -color_label.pack(anchor=tk.W, pady=10) -color_inp.pack(fill=tk.X, padx=25) - -# Use side to change the orientation of packing -# Note that we're packing into the plantain frame, not root! -# This keeps our radio buttons side-by-side -# ipad is "internal padding". -# Using this over regular padding not only separates the buttons, -# it increases the space we can click on to select a button -plantain_yes_inp.pack(side='left', fill='x', ipadx=10, ipady=5) -plantain_no_inp.pack(side='left', fill='x', ipadx=10, ipady=5) -plantain_label.pack(fill='x', padx=10, pady=5) -plantain_frame.pack(fill='x') - -# fill both ways -# 'expand' means this widget will get any extra space left in the parent -banana_haiku_label.pack(anchor='w') -banana_haiku_inp.pack(fill='both', expand=True) - -# Add the button and output -# Specifying side='bottom' means the widgets will be packed -# from the bottom up, so specify the last widget first -# Specifying 'expand' on another widget means extra space -# will be divided amongst the expandable widgets -output_line.pack(side='bottom', fill='x', expand=True) -submit_btn.pack(side='bottom') - - -##################### -# Add some behavior # -##################### - -def on_submit(): - """To be run when the user submits the form""" - - # Many widgets use "get" to retrieve contents - name = name_inp.get() - # spinboxes return a str, not a float or int! - number = num_inp.get() - # Listboxes are more involved - selected_idx = color_inp.curselection() - color = color_inp.get(selected_idx) - - # We're going to need some way to get our button values! - # banana_eater = ???? - - # Text widgets require a range - haiku = banana_haiku_inp.get('1.0', tk.END) - - # Update the text in our output - message = ( - f'Thanks for taking the survey, {name}.\n' - f'Enjoy your {number} {color} bananas!' - ) - output_line.configure(text=message) - print(haiku) - - -# configure the button to trigger submission -submit_btn.configure(command=on_submit) - -# an alternate approach -# note that this sends an event object to the functions -#submit_btn.bind('', on_submit) - -############### -# Execute App # -############### - -root.mainloop() diff --git a/Chapter01/banana_survey_variables.py b/Chapter01/banana_survey_variables.py index 26e4261..faad05c 100644 --- a/Chapter01/banana_survey_variables.py +++ b/Chapter01/banana_survey_variables.py @@ -47,8 +47,8 @@ root, textvariable=num_var, from_=0, - increment=1, - value=3 + to=1000, + increment=1 ) # Listboxes don't work well with variables, diff --git a/Chapter01/hello_tkinter.py b/Chapter01/hello_tkinter.py index 1b0f33e..3a18a3d 100644 --- a/Chapter01/hello_tkinter.py +++ b/Chapter01/hello_tkinter.py @@ -1,13 +1,13 @@ """Hello World application for Tkinter""" -# Import all the classes from tkinter -from tkinter import * +# Import tkinter +import tkinter as tk # Create a root window -root = Tk() +root = tk.Tk() # Create a widget -label = Label(root, text="Hello World") +label = tk.Label(root, text="Hello World") # Place the label on the root window label.pack() From 90376a2d2b45ebb86507f970e7a5b70550036f24 Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Wed, 2 Jun 2021 14:32:36 -0500 Subject: [PATCH 08/32] Fix bug in get() method --- Chapter05/data_entry_app.py | 21 +++++++++++++---- .../ABQ_Data_Entry/abq_data_entry/views.py | 23 ++++++++++++++----- .../ABQ_Data_Entry/abq_data_entry/views.py | 23 ++++++++++++++----- 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/Chapter05/data_entry_app.py b/Chapter05/data_entry_app.py index a164e94..47f9562 100644 --- a/Chapter05/data_entry_app.py +++ b/Chapter05/data_entry_app.py @@ -559,19 +559,30 @@ def __init__(self, *args, **kwargs): # default the form self.reset() + @staticmethod + def tclerror_is_blank_value(exception): + blank_value_errors = ( + 'expected integer but got ""', + 'expected floating-point number but got ""', + 'expected boolean value but got ""' + ) + is_bve = str(exception).strip() in blank_value_errors + return is_bve + def get(self): """Retrieve data from form as a dict""" # We need to retrieve the data from Tkinter variables # and place it in regular Python objects data = dict() - for key, variable in self._vars.items(): + for key, var in self._vars.items(): try: - data[key] = variable.get() + data[key] = var.get() except tk.TclError as e: - message = f'Error in field: {key}. Data was not saved!' - raise ValueError(message) from e - + if self.tclerror_is_blank_value(e): + data[key] = None + else: + raise e return data def reset(self): diff --git a/Chapter06/ABQ_Data_Entry/abq_data_entry/views.py b/Chapter06/ABQ_Data_Entry/abq_data_entry/views.py index 83c983a..901e5e9 100644 --- a/Chapter06/ABQ_Data_Entry/abq_data_entry/views.py +++ b/Chapter06/ABQ_Data_Entry/abq_data_entry/views.py @@ -177,19 +177,30 @@ def __init__(self, parent, model, *args, **kwargs): def _on_save(self): self.event_generate('<>') + @staticmethod + def tclerror_is_blank_value(exception): + blank_value_errors = ( + 'expected integer but got ""', + 'expected floating-point number but got ""', + 'expected boolean value but got ""' + ) + is_bve = str(exception).strip() in blank_value_errors + return is_bve + def get(self): """Retrieve data from form as a dict""" # We need to retrieve the data from Tkinter variables # and place it in regular Python objects data = dict() - for key, variable in self._vars.items(): + for key, var in self._vars.items(): try: - data[key] = variable.get() - except tk.TclError: - message = f'Error in field: {key}. Data was not saved!' - raise ValueError(message) - + data[key] = var.get() + except tk.TclError as e: + if self.tclerror_is_blank_value(e): + data[key] = None + else: + raise e return data def reset(self): diff --git a/Chapter07/ABQ_Data_Entry/abq_data_entry/views.py b/Chapter07/ABQ_Data_Entry/abq_data_entry/views.py index d1947fb..1f22abc 100644 --- a/Chapter07/ABQ_Data_Entry/abq_data_entry/views.py +++ b/Chapter07/ABQ_Data_Entry/abq_data_entry/views.py @@ -182,19 +182,30 @@ def __init__(self, parent, model, settings, *args, **kwargs): def _on_save(self): self.event_generate('<>') + @staticmethod + def tclerror_is_blank_value(exception): + blank_value_errors = ( + 'expected integer but got ""', + 'expected floating-point number but got ""', + 'expected boolean value but got ""' + ) + is_bve = str(exception).strip() in blank_value_errors + return is_bve + def get(self): """Retrieve data from form as a dict""" # We need to retrieve the data from Tkinter variables # and place it in regular Python objects data = dict() - for key, variable in self._vars.items(): + for key, var in self._vars.items(): try: - data[key] = variable.get() - except tk.TclError: - message = f'Error in field: {key}. Data was not saved!' - raise ValueError(message) - + data[key] = var.get() + except tk.TclError as e: + if self.tclerror_is_blank_value(e): + data[key] = None + else: + raise e return data def reset(self): From 27bacf13e7b769f95fc089bb4640b606b133937d Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Sun, 6 Jun 2021 17:21:05 -0500 Subject: [PATCH 09/32] Updated spec per feedback from AR --- Chapter02/abq_data_entry_spec.rst | 46 +++++++++++++++---------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/Chapter02/abq_data_entry_spec.rst b/Chapter02/abq_data_entry_spec.rst index 41d3c18..75164db 100644 --- a/Chapter02/abq_data_entry_spec.rst +++ b/Chapter02/abq_data_entry_spec.rst @@ -7,29 +7,29 @@ Description This program facilitates entry of laboratory observations into a CSV file. -Functionality Required ----------------------- +Requirements +------------ -The program must: +Functional Requirements: - * allow all relevant, valid data to be entered, - as per the data dictionary - * append entered data to a CSV file: + * Allow all relevant, valid data to be entered, + as per the data dictionary. + * Append entered data to a CSV file: - The CSV file must have a filename of abq_data_record_CURRENTDATE.csv, where CURRENTDATE is the date - of the laboratory observations in ISO format (Year-month-day) - - The CSV file must include all fields - listed in the data dictionary - - The CSV headers will avoid cryptic abbreviations - * enforce correct datatypes per field + of the laboratory observations in ISO format (Year-month-day). + - The CSV file must include all fields. + listed in the data dictionary. + - The CSV headers will avoid cryptic abbreviations. + * Enforce correct datatypes per field. -The program should try, whenever possible, to: +Non-functional Requirements: - * enforce reasonable limits on data entered, per the data dict - * Auto-fill data to save time - * Suggest likely correct values - * Provide a smooth and efficient workflow - * Store data in a format easily understandable by Python + * Enforce reasonable limits on data entered, per the data dict. + * Auto-fill data to save time. + * Suggest likely correct values. + * Provide a smooth and efficient workflow. + * Store data in a format easily understandable by Python. Functionality Not Required -------------------------- @@ -69,19 +69,19 @@ Data Dictionary |Plot |Int | | 1 - 20 |Plot ID | +------------+--------+----+---------------+--------------------+ |Seed |String | | 6-character |Seed sample ID | -|sample | | | string | | +|Sample | | | string | | +------------+--------+----+---------------+--------------------+ |Fault |Bool | | True, False |Environmental | -| | | | |Sensor Fault | +| | | | |sensor fault | +------------+--------+----+---------------+--------------------+ -|Light |Decimal |klx | 0 - 100 |Light at plot. | -| | | | |blank on fault. | +|Light |Decimal |klx | 0 - 100 |Light at plot | +| | | | |blank on fault | +------------+--------+----+---------------+--------------------+ |Humidity |Decimal |g/m³| 0.5 - 52.0 |Abs humidity at plot| -| | | | |blank on fault. | +| | | | |blank on fault | +------------+--------+----+---------------+--------------------+ |Temperature |Decimal |°C | 4 - 40 |Temperature at plot | -| | | | |blank on fault. | +| | | | |blank on fault | +------------+--------+----+---------------+--------------------+ |Blossoms |Int | | 0 - 1000 |No. blossoms in plot| +------------+--------+----+---------------+--------------------+ From 7dddc061061685189f603e047ede3f504b152a32 Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Tue, 8 Jun 2021 08:50:17 -0500 Subject: [PATCH 10/32] Added Chapter 8 code --- Chapter08/ABQ_Data_Entry/.gitignore | 2 + Chapter08/ABQ_Data_Entry/README.rst | 43 ++ Chapter08/ABQ_Data_Entry/abq_data_entry.py | 4 + .../ABQ_Data_Entry/abq_data_entry/__init__.py | 0 .../abq_data_entry/application.py | 217 +++++++++ .../abq_data_entry/constants.py | 12 + .../ABQ_Data_Entry/abq_data_entry/mainmenu.py | 82 ++++ .../ABQ_Data_Entry/abq_data_entry/models.py | 170 ++++++++ .../ABQ_Data_Entry/abq_data_entry/views.py | 406 +++++++++++++++++ .../ABQ_Data_Entry/abq_data_entry/widgets.py | 411 ++++++++++++++++++ .../docs/Application_layout.png | Bin 0 -> 9117 bytes .../docs/abq_data_entry_spec.rst | 107 +++++ .../docs/lab-tech-paper-form.png | Bin 0 -> 22018 bytes Chapter08/notebook_demo.py | 52 +++ Chapter08/treeview_demo.py | 68 +++ 15 files changed, 1574 insertions(+) create mode 100644 Chapter08/ABQ_Data_Entry/.gitignore create mode 100644 Chapter08/ABQ_Data_Entry/README.rst create mode 100644 Chapter08/ABQ_Data_Entry/abq_data_entry.py create mode 100644 Chapter08/ABQ_Data_Entry/abq_data_entry/__init__.py create mode 100644 Chapter08/ABQ_Data_Entry/abq_data_entry/application.py create mode 100644 Chapter08/ABQ_Data_Entry/abq_data_entry/constants.py create mode 100644 Chapter08/ABQ_Data_Entry/abq_data_entry/mainmenu.py create mode 100644 Chapter08/ABQ_Data_Entry/abq_data_entry/models.py create mode 100644 Chapter08/ABQ_Data_Entry/abq_data_entry/views.py create mode 100644 Chapter08/ABQ_Data_Entry/abq_data_entry/widgets.py create mode 100644 Chapter08/ABQ_Data_Entry/docs/Application_layout.png create mode 100644 Chapter08/ABQ_Data_Entry/docs/abq_data_entry_spec.rst create mode 100644 Chapter08/ABQ_Data_Entry/docs/lab-tech-paper-form.png create mode 100644 Chapter08/notebook_demo.py create mode 100644 Chapter08/treeview_demo.py diff --git a/Chapter08/ABQ_Data_Entry/.gitignore b/Chapter08/ABQ_Data_Entry/.gitignore new file mode 100644 index 0000000..d646835 --- /dev/null +++ b/Chapter08/ABQ_Data_Entry/.gitignore @@ -0,0 +1,2 @@ +*.pyc +__pycache__/ diff --git a/Chapter08/ABQ_Data_Entry/README.rst b/Chapter08/ABQ_Data_Entry/README.rst new file mode 100644 index 0000000..5a47dd7 --- /dev/null +++ b/Chapter08/ABQ_Data_Entry/README.rst @@ -0,0 +1,43 @@ +============================ + ABQ Data Entry Application +============================ + +Description +=========== + +This program provides a data entry form for ABQ Agrilabs laboratory data. + +Features +-------- + + * Provides a validated entry form to ensure correct data + * Stores data to ABQ-format CSV files + * Auto-fills form fields whenever possible + +Authors +======= + +Alan D Moore, 2021 + +Requirements +============ + + * Python 3.7 or higher + * Tkinter + +Usage +===== + +To start the application, run:: + + python3 ABQ_Data_Entry/abq_data_entry.py + + +General Notes +============= + +The CSV file will be saved to your current directory in the format +``abq_data_record_CURRENTDATE.csv``, where CURRENTDATE is today's date in ISO format. + +This program only appends to the CSV file. You should have a spreadsheet program +installed in case you need to edit or check the file. diff --git a/Chapter08/ABQ_Data_Entry/abq_data_entry.py b/Chapter08/ABQ_Data_Entry/abq_data_entry.py new file mode 100644 index 0000000..a3b3a0d --- /dev/null +++ b/Chapter08/ABQ_Data_Entry/abq_data_entry.py @@ -0,0 +1,4 @@ +from abq_data_entry.application import Application + +app = Application() +app.mainloop() diff --git a/Chapter08/ABQ_Data_Entry/abq_data_entry/__init__.py b/Chapter08/ABQ_Data_Entry/abq_data_entry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Chapter08/ABQ_Data_Entry/abq_data_entry/application.py b/Chapter08/ABQ_Data_Entry/abq_data_entry/application.py new file mode 100644 index 0000000..f25eb37 --- /dev/null +++ b/Chapter08/ABQ_Data_Entry/abq_data_entry/application.py @@ -0,0 +1,217 @@ +"""The application/controller class for ABQ Data Entry""" + +import tkinter as tk +from tkinter import ttk +from tkinter import messagebox +from tkinter import filedialog + +from . import views as v +from . import models as m +from .mainmenu import MainMenu + +class Application(tk.Tk): + """Application root window""" + + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Hide window while GUI is built + self.withdraw() + + # Authenticate + if not self._show_login(): + self.destroy() + return + + # show the window + self.deiconify() + + # Create model + self.model = m.CSVModel() + + # Load settings + # self.settings = { + # 'autofill date': tk.BooleanVar(), + # 'autofill sheet data': tk.BoleanVar() + # } + self.settings_model = m.SettingsModel() + self._load_settings() + + # Begin building GUI + self.title("ABQ Data Entry Application") + self.columnconfigure(0, weight=1) + + # Create the menu + menu = MainMenu(self, self.settings) + self.config(menu=menu) + event_callbacks = { + '<>': self._on_file_select, + '<>': lambda _: self.quit(), + # new for ch8 + '<>': self._show_recordlist, + '<>': self._new_record, + + } + for sequence, callback in event_callbacks.items(): + self.bind(sequence, callback) + ttk.Label( + self, + text="ABQ Data Entry Application", + font=("TkDefaultFont", 16) + ).grid(row=0) + + # The notebook + self.notebook = ttk.Notebook(self) + self.notebook.enable_traversal() + self.notebook.grid(row=1, padx=10, sticky='NSEW') + + # The data record form + self.recordform = v.DataRecordForm(self, self.model, self.settings) + self.recordform.bind('<>', self._on_save) + self.notebook.add(self.recordform, text='Entry Form') + + + # The data record list + # new for ch8 + self.recordlist = v.RecordList(self) + self.notebook.insert(0, self.recordlist, text='Records') + self._populate_recordlist() + self.recordlist.bind('<>', self._open_record) + + + self._show_recordlist() + + # status bar + self.status = tk.StringVar() + self.statusbar = ttk.Label(self, textvariable=self.status) + self.statusbar.grid(sticky=(tk.W + tk.E), row=3, padx=10) + + + self.records_saved = 0 + + + def _on_save(self, *_): + """Handles file-save requests""" + + # Check for errors first + + errors = self.recordform.get_errors() + if errors: + self.status.set( + "Cannot save, error in fields: {}" + .format(', '.join(errors.keys())) + ) + message = "Cannot save record" + detail = "The following fields have errors: \n * {}".format( + '\n * '.join(errors.keys()) + ) + messagebox.showerror( + title='Error', + message=message, + detail=detail + ) + return False + + data = self.recordform.get() + rownum = self.recordform.current_record + self.model.save_record(data, rownum) + self.records_saved += 1 + self.status.set( + "{} records saved this session".format(self.records_saved) + ) + self.recordform.reset() + self._populate_recordlist() + + def _on_file_select(self, *_): + """Handle the file->select action""" + + filename = filedialog.asksaveasfilename( + title='Select the target file for saving records', + defaultextension='.csv', + filetypes=[('CSV', '*.csv *.CSV')] + ) + if filename: + self.model = m.CSVModel(filename=filename) + self._populate_recordlist() + + @staticmethod + def _simple_login(username, password): + """A basic authentication backend with a hardcoded user and password""" + return username == 'abq' and password == 'Flowers' + + def _show_login(self): + """Show login dialog and attempt to login""" + error = '' + title = "Login to ABQ Data Entry" + while True: + login = v.LoginDialog(self, title, error) + if not login.result: # User canceled + return False + username, password = login.result + if self._simple_login(username, password): + return True + error = 'Login Failed' # loop and redisplay + + def _load_settings(self): + """Load settings into our self.settings dict.""" + + vartypes = { + 'bool': tk.BooleanVar, + 'str': tk.StringVar, + 'int': tk.IntVar, + 'float': tk.DoubleVar + } + + # create our dict of settings variables from the model's settings. + self.settings = dict() + for key, data in self.settings_model.fields.items(): + vartype = vartypes.get(data['type'], tk.StringVar) + self.settings[key] = vartype(value=data['value']) + + # put a trace on the variables so they get stored when changed. + for var in self.settings.values(): + var.trace_add('write', self._save_settings) + + def _save_settings(self, *_): + """Save the current settings to a preferences file""" + + for key, variable in self.settings.items(): + self.settings_model.set(key, variable.get()) + self.settings_model.save() + + # new for ch8 + def _show_recordlist(self, *_): + """Show the recordform""" + self.notebook.select(self.recordlist) + + def _populate_recordlist(self): + try: + rows = self.model.get_all_records() + except Exception as e: + messagebox.showerror( + title='Error', + message='Problem reading file', + detail=str(e) + ) + else: + self.recordlist.populate(rows) + + def _new_record(self, *_): + """Open the record form with a blank record""" + self.recordform.load_record(None, None) + self.notebook.select(self.recordform) + + + def _open_record(self, *_): + """Open the Record selected recordlist id in the recordform""" + rowkey = self.recordlist.selected_id + try: + record = self.model.get_record(rowkey) + except Exception as e: + messagebox.showerror( + title='Error', message='Problem reading file', detail=str(e) + ) + return + self.recordform.load_record(rowkey, record) + self.notebook.select(self.recordform) diff --git a/Chapter08/ABQ_Data_Entry/abq_data_entry/constants.py b/Chapter08/ABQ_Data_Entry/abq_data_entry/constants.py new file mode 100644 index 0000000..e747dce --- /dev/null +++ b/Chapter08/ABQ_Data_Entry/abq_data_entry/constants.py @@ -0,0 +1,12 @@ +"""Global constants and classes needed by other modules in ABQ Data Entry""" +from enum import Enum, auto + +class FieldTypes(Enum): + string = auto() + string_list = auto() + short_string_list = auto() + iso_date_string = auto() + long_string = auto() + decimal = auto() + integer = auto() + boolean = auto() diff --git a/Chapter08/ABQ_Data_Entry/abq_data_entry/mainmenu.py b/Chapter08/ABQ_Data_Entry/abq_data_entry/mainmenu.py new file mode 100644 index 0000000..2e28fe2 --- /dev/null +++ b/Chapter08/ABQ_Data_Entry/abq_data_entry/mainmenu.py @@ -0,0 +1,82 @@ +"""The Main Menu class for ABQ Data Entry""" + +import tkinter as tk +from tkinter import messagebox + +class MainMenu(tk.Menu): + """The Application's main menu""" + + def _event(self, sequence): + """Return a callback function that generates the sequence""" + def callback(*_): + root = self.master.winfo_toplevel() + root.event_generate(sequence) + + return callback + + def __init__(self, parent, settings, **kwargs): + """Constructor for MainMenu + + arguments: + parent - The parent widget + settings - a dict containing Tkinter variables + """ + super().__init__(parent, **kwargs) + self.settings = settings + + # The help menu + help_menu = tk.Menu(self, tearoff=False) + help_menu.add_command(label='About…', command=self.show_about) + + # The file menu + file_menu = tk.Menu(self, tearoff=False) + file_menu.add_command( + label="Select file…", + command=self._event('<>') + ) + + file_menu.add_separator() + file_menu.add_command( + label="Quit", + command=self._event('<>') + ) + + # The options menu + options_menu = tk.Menu(self, tearoff=False) + options_menu.add_checkbutton( + label='Autofill Date', + variable=self.settings['autofill date'] + ) + options_menu.add_checkbutton( + label='Autofill Sheet data', + variable=self.settings['autofill sheet data'] + ) + + # switch from recordlist to recordform + go_menu = tk.Menu(self, tearoff=False) + go_menu.add_command( + label="Record List", + command=self._event('<>') + ) + go_menu.add_command( + label="New Record", + command=self._event('<>') + ) + + # add the menus in order to the main menu + self.add_cascade(label='File', menu=file_menu) + self.add_cascade(label='Go', menu=go_menu) + self.add_cascade(label='Options', menu=options_menu) + self.add_cascade(label='Help', menu=help_menu) + + + def show_about(self): + """Show the about dialog""" + + about_message = 'ABQ Data Entry' + about_detail = ( + 'by Alan D Moore\n' + 'For assistance please contact the author.' + ) + + messagebox.showinfo(title='About', message=about_message, detail=about_detail) diff --git a/Chapter08/ABQ_Data_Entry/abq_data_entry/models.py b/Chapter08/ABQ_Data_Entry/abq_data_entry/models.py new file mode 100644 index 0000000..8cb1c93 --- /dev/null +++ b/Chapter08/ABQ_Data_Entry/abq_data_entry/models.py @@ -0,0 +1,170 @@ +import csv +from pathlib import Path +import os +import json + +from .constants import FieldTypes as FT +from decimal import Decimal +from datetime import datetime + +class CSVModel: + """CSV file storage""" + + fields = { + "Date": {'req': True, 'type': FT.iso_date_string}, + "Time": {'req': True, 'type': FT.string_list, + 'values': ['8:00', '12:00', '16:00', '20:00']}, + "Technician": {'req': True, 'type': FT.string}, + "Lab": {'req': True, 'type': FT.short_string_list, + 'values': ['A', 'B', 'C']}, + "Plot": {'req': True, 'type': FT.string_list, + 'values': [str(x) for x in range(1, 21)]}, + "Seed Sample": {'req': True, 'type': FT.string}, + "Humidity": {'req': True, 'type': FT.decimal, + 'min': 0.5, 'max': 52.0, 'inc': .01}, + "Light": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 100.0, 'inc': .01}, + "Temperature": {'req': True, 'type': FT.decimal, + 'min': 4, 'max': 40, 'inc': .01}, + "Equipment Fault": {'req': False, 'type': FT.boolean}, + "Plants": {'req': True, 'type': FT.integer, 'min': 0, 'max': 20}, + "Blossoms": {'req': True, 'type': FT.integer, 'min': 0, 'max': 1000}, + "Fruit": {'req': True, 'type': FT.integer, 'min': 0, 'max': 1000}, + "Min Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Max Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Med Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Notes": {'req': False, 'type': FT.long_string} + } + + + def __init__(self, filename=None): + + if not filename: + datestring = datetime.today().strftime("%Y-%m-%d") + filename = "abq_data_record_{}.csv".format(datestring) + self.file = Path(filename) + + # Check for append permissions: + file_exists = os.access(self.file, os.F_OK) + parent_writeable = os.access(self.file.parent, os.W_OK) + file_writeable = os.access(self.file, os.W_OK) + if ( + (not file_exists and not parent_writeable) or + (file_exists and not file_writeable) + ): + msg = f'Permission denied accessing file: {filename}' + raise PermissionError(msg) + + + def save_record(self, data, rownum=None): + """Save a dict of data to the CSV file""" + + print(f"save row: {data}") + if rownum is None: + print('save new record') + # This is a new record + newfile = not self.file.exists() + + with open(self.file, 'a') as fh: + csvwriter = csv.DictWriter(fh, fieldnames=self.fields.keys()) + if newfile: + csvwriter.writeheader() + csvwriter.writerow(data) + else: + print(f'update record {rownum}') + # This is an update + records = self.get_all_records() + records[rownum] = data + with open(self.file, 'w', encoding='utf-8') as fh: + csvwriter = csv.DictWriter(fh, fieldnames=self.fields.keys()) + csvwriter.writeheader() + csvwriter.writerows(records) + + def get_all_records(self): + """Read in all records from the CSV and return a list""" + if not self.file.exists(): + return [] + + with open(self.file, 'r', encoding='utf-8') as fh: + csvreader = csv.DictReader(fh.readlines()) + missing_fields = set(self.fields.keys()) - set(csvreader.fieldnames) + if len(missing_fields) > 0: + fields_string = ', '.join(missing_fields) + raise Exception( + f"File is missing fields: {fields_string}" + ) + records = list(csvreader) + + # Correct issue with boolean fields + trues = ('true', 'yes', '1') + bool_fields = [ + key for key, meta + in self.fields.items() + if meta['type'] == FT.boolean + ] + for record in records: + for key in bool_fields: + record[key] = record[key].lower() in trues + return records + + def get_record(self, rownum): + """Get a single record by row number + + Callling code should catch IndexError + in case of a bad rownum. + """ + + return self.get_all_records()[rownum] + + +class SettingsModel: + """A model for saving settings""" + + fields = { + 'autofill date': {'type': 'bool', 'value': True}, + 'autofill sheet data': {'type': 'bool', 'value': True} + } + + def __init__(self): + # determine the file path + filename = 'abq_settings.json' + self.filepath = Path.home() / filename + + # load in saved values + self.load() + + def set(self, key, value): + """Set a variable value""" + if ( + key in self.fields and + type(value).__name__ == self.fields[key]['type'] + ): + self.fields[key]['value'] = value + else: + raise ValueError("Bad key or wrong variable type") + + def save(self): + """Save the current settings to the file""" + json_string = json.dumps(self.fields) + with open(self.filepath, 'w') as fh: + fh.write(json_string) + + def load(self): + """Load the settings from the file""" + + # if the file doesn't exist, return + if not self.filepath.exists(): + return + + # open the file and read in the raw values + with open(self.filepath, 'r') as fh: + raw_values = json.loads(fh.read()) + + # don't implicitly trust the raw values, but only get known keys + for key in self.fields: + if key in raw_values and 'value' in raw_values[key]: + raw_value = raw_values[key]['value'] + self.fields[key]['value'] = raw_value diff --git a/Chapter08/ABQ_Data_Entry/abq_data_entry/views.py b/Chapter08/ABQ_Data_Entry/abq_data_entry/views.py new file mode 100644 index 0000000..467c379 --- /dev/null +++ b/Chapter08/ABQ_Data_Entry/abq_data_entry/views.py @@ -0,0 +1,406 @@ +import tkinter as tk +from tkinter import ttk +from tkinter.simpledialog import Dialog +from datetime import datetime +from . import widgets as w +from .constants import FieldTypes as FT + +class DataRecordForm(tk.Frame): + """The input form for our widgets""" + + var_types = { + FT.string: tk.StringVar, + FT.string_list: tk.StringVar, + FT.short_string_list: tk.StringVar, + FT.iso_date_string: tk.StringVar, + FT.long_string: tk.StringVar, + FT.decimal: tk.DoubleVar, + FT.integer: tk.IntVar, + FT.boolean: tk.BooleanVar + } + + def _add_frame(self, label, cols=3): + """Add a labelframe to the form""" + + frame = ttk.LabelFrame(self, text=label) + frame.grid(sticky=tk.W + tk.E) + for i in range(cols): + frame.columnconfigure(i, weight=1) + return frame + + def __init__(self, parent, model, settings, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + + self.model= model + self.settings = settings + fields = self.model.fields + + # Create a dict to keep track of input widgets + self._vars = { + key: self.var_types[spec['type']]() + for key, spec in fields.items() + } + + # Build the form + self.columnconfigure(0, weight=1) + + # new chapter 8 + # variable to track current record id + self.current_record = None + + # Label for displaying what record we're editing + self.record_label = ttk.Label(self) + self.record_label.grid(row=0, column=0) + + # Record info section + r_info = self._add_frame("Record Information") + + # line 1 + w.LabelInput( + r_info, "Date", + field_spec=fields['Date'], + var=self._vars['Date'], + ).grid(row=0, column=0) + w.LabelInput( + r_info, "Time", + field_spec=fields['Time'], + var=self._vars['Time'], + ).grid(row=0, column=1) + w.LabelInput( + r_info, "Lab", + field_spec=fields['Lab'], + var=self._vars['Lab'], + ).grid(row=0, column=2) + # line 2 + w.LabelInput( + r_info, "Plot", + field_spec=fields['Plot'], + var=self._vars['Plot'], + ).grid(row=1, column=0) + w.LabelInput( + r_info, "Technician", + field_spec=fields['Technician'], + var=self._vars['Technician'], + ).grid(row=1, column=1) + w.LabelInput( + r_info, "Seed Sample", + field_spec=fields['Seed Sample'], + var=self._vars['Seed Sample'], + ).grid(row=1, column=2) + + + # Environment Data + e_info = self._add_frame("Environment Data") + + w.LabelInput( + e_info, "Humidity (g/m³)", + field_spec=fields['Humidity'], + var=self._vars['Humidity'], + disable_var=self._vars['Equipment Fault'] + ).grid(row=0, column=0) + w.LabelInput( + e_info, "Light (klx)", + field_spec=fields['Light'], + var=self._vars['Light'], + disable_var=self._vars['Equipment Fault'] + ).grid(row=0, column=1) + w.LabelInput( + e_info, "Temperature (°C)", + field_spec=fields['Temperature'], + var=self._vars['Temperature'], + disable_var=self._vars['Equipment Fault'] + ).grid(row=0, column=2) + w.LabelInput( + e_info, "Equipment Fault", + field_spec=fields['Equipment Fault'], + var=self._vars['Equipment Fault'], + ).grid(row=1, column=0, columnspan=3) + + # Plant Data section + p_info = self._add_frame("Plant Data") + + w.LabelInput( + p_info, "Plants", + field_spec=fields['Plants'], + var=self._vars['Plants'], + ).grid(row=0, column=0) + w.LabelInput( + p_info, "Blossoms", + field_spec=fields['Blossoms'], + var=self._vars['Blossoms'], + ).grid(row=0, column=1) + w.LabelInput( + p_info, "Fruit", + field_spec=fields['Fruit'], + var=self._vars['Fruit'], + ).grid(row=0, column=2) + + # Height data + # create variables to be updated for min/max height + # they can be referenced for min/max variables + min_height_var = tk.DoubleVar(value='-infinity') + max_height_var = tk.DoubleVar(value='infinity') + + w.LabelInput( + p_info, "Min Height (cm)", + field_spec=fields['Min Height'], + var=self._vars['Min Height'], + input_args={ + "max_var": max_height_var, "focus_update_var": min_height_var + }, + ).grid(row=1, column=0) + w.LabelInput( + p_info, "Max Height (cm)", + field_spec=fields['Max Height'], + var=self._vars['Max Height'], + input_args={ + "min_var": min_height_var, "focus_update_var": max_height_var + }, + ).grid(row=1, column=1) + w.LabelInput( + p_info, "Median Height (cm)", + field_spec=fields['Med Height'], + var=self._vars['Med Height'], + input_args={ + "min_var": min_height_var, "max_var": max_height_var + }, + ).grid(row=1, column=2) + + + # Notes section -- Update grid row value for ch8 + w.LabelInput( + self, "Notes", field_spec=fields['Notes'], + var=self._vars['Notes'], input_args={"width": 85, "height": 10} + ).grid(sticky="nsew", row=4, column=0, padx=10, pady=10) + + # buttons + buttons = tk.Frame(self) + buttons.grid(sticky=tk.W + tk.E, row=5) + self.savebutton = ttk.Button( + buttons, text="Save", command=self._on_save) + self.savebutton.pack(side=tk.RIGHT) + + self.resetbutton = ttk.Button( + buttons, text="Reset", command=self.reset) + self.resetbutton.pack(side=tk.RIGHT) + + # default the form + self.reset() + + def _on_save(self): + self.event_generate('<>') + + @staticmethod + def tclerror_is_blank_value(exception): + blank_value_errors = ( + 'expected integer but got ""', + 'expected floating-point number but got ""', + 'expected boolean value but got ""' + ) + is_bve = str(exception).strip() in blank_value_errors + return is_bve + + def get(self): + """Retrieve data from form as a dict""" + + # We need to retrieve the data from Tkinter variables + # and place it in regular Python objects + data = dict() + for key, var in self._vars.items(): + try: + data[key] = var.get() + except tk.TclError as e: + if self.tclerror_is_blank_value(e): + data[key] = None + else: + raise e + return data + + def reset(self): + """Resets the form entries""" + + lab = self._vars['Lab'].get() + time = self._vars['Time'].get() + technician = self._vars['Technician'].get() + try: + plot = self._vars['Plot'].get() + except tk.TclError: + plot = '' + plot_values = self._vars['Plot'].label_widget.input.cget('values') + + # clear all values + for var in self._vars.values(): + if isinstance(var, tk.BooleanVar): + var.set(False) + else: + var.set('') + + # Autofill Date + if self.settings['autofill date'].get(): + current_date = datetime.today().strftime('%Y-%m-%d') + self._vars['Date'].set(current_date) + self._vars['Time'].label_widget.input.focus() + + # check if we need to put our values back, then do it. + if ( + self.settings['autofill sheet data'].get() and + plot not in ('', 0, plot_values[-1]) + ): + self._vars['Lab'].set(lab) + self._vars['Time'].set(time) + self._vars['Technician'].set(technician) + next_plot_index = plot_values.index(plot) + 1 + self._vars['Plot'].set(plot_values[next_plot_index]) + self._vars['Seed Sample'].label_widget.input.focus() + + def get_errors(self): + """Get a list of field errors in the form""" + + errors = dict() + for key, var in self._vars.items(): + inp = var.label_widget.input + error = var.label_widget.error + + if hasattr(inp, 'trigger_focusout_validation'): + inp.trigger_focusout_validation() + if error.get(): + errors[key] = error.get() + + return errors + + # new for ch8 + def load_record(self, rownum, data=None): + self.current_record = rownum + if rownum is None: + self.reset() + self.record_label.config(text='New Record') + else: + self.record_label.config(text=f'Record #{rownum}') + for key, var in self._vars.items(): + var.set(data.get(key, '')) + try: + var.label_widget.input.trigger_focusout_validation() + except AttributeError: + pass + + +class LoginDialog(Dialog): + """A dialog that asks for username and password""" + + def __init__(self, parent, title, error=''): + + self._pw = tk.StringVar() + self._user = tk.StringVar() + self._error = tk.StringVar(value=error) + super().__init__(parent, title=title) + + def body(self, frame): + """Construct the interface and return the widget for initial focus + + Overridden from Dialog + """ + ttk.Label(frame, text='Login to ABQ').grid(row=0) + + if self._error.get(): + ttk.Label(frame, textvariable=self._error).grid(row=1) + user_inp = w.LabelInput( + frame, 'User name:', input_class=w.RequiredEntry, + var=self._user + ) + user_inp.grid() + w.LabelInput( + frame, 'Password:', input_class=w.RequiredEntry, + input_args={'show': '*'}, var=self._pw + ).grid() + return user_inp.input + + def buttonbox(self): + box = ttk.Frame(self) + ttk.Button( + box, text="Login", command=self.ok, default=tk.ACTIVE + ).grid(padx=5, pady=5) + ttk.Button( + box, text="Cancel", command=self.cancel + ).grid(row=0, column=1, padx=5, pady=5) + self.bind("", self.ok) + self.bind("", self.cancel) + box.pack() + + + def apply(self): + self.result = (self._user.get(), self._pw.get()) + + +class RecordList(tk.Frame): + """Display for CSV file contents""" + + column_defs = { + '#0': {'label': 'Row', 'anchor': tk.W}, + 'Date': {'label': 'Date', 'width': 150, 'stretch': True}, + 'Time': {'label': 'Time'}, + 'Lab': {'label': 'Lab', 'width': 40}, + 'Plot': {'label': 'Plot', 'width': 80} + } + default_width = 100 + default_minwidth = 10 + default_anchor = tk.CENTER + + def __init__(self, parent, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + self.columnconfigure(0, weight=1) + self.rowconfigure(0, weight=1) + + # create treeview + self.treeview = ttk.Treeview( + self, + columns=list(self.column_defs.keys())[1:], + selectmode='browse' + ) + self.treeview.grid(row=0, column=0, sticky='NSEW') + + # Configure treeview columns + for name, definition in self.column_defs.items(): + label = definition.get('label', '') + anchor = definition.get('anchor', self.default_anchor) + minwidth = definition.get('minwidth', self.default_minwidth) + width = definition.get('width', self.default_width) + stretch = definition.get('stretch', False) + self.treeview.heading(name, text=label, anchor=anchor) + self.treeview.column( + name, anchor=anchor, minwidth=minwidth, + width=width, stretch=stretch + ) + + self.treeview.bind('', self._on_open_record) + self.treeview.bind('', self._on_open_record) + + # configure scrollbar for the treeview + self.scrollbar = ttk.Scrollbar( + self, + orient=tk.VERTICAL, + command=self.treeview.yview + ) + self.treeview.configure(yscrollcommand=self.scrollbar.set) + self.scrollbar.grid(row=0, column=1, sticky='NSW') + + + def populate(self, rows): + """Clear the treeview and write the supplied data rows to it.""" + for row in self.treeview.get_children(): + self.treeview.delete(row) + + cids = list(self.column_defs.keys())[1:] + for rownum, rowdata in enumerate(rows): + values = [rowdata[cid] for cid in cids] + self.treeview.insert('', 'end', iid=str(rownum), + text=str(rownum), values=values) + + if len(rows) > 0: + self.treeview.focus_set() + self.treeview.selection_set('0') + self.treeview.focus('0') + + def _on_open_record(self, *args): + + self.selected_id = int(self.treeview.selection()[0]) + self.event_generate('<>') diff --git a/Chapter08/ABQ_Data_Entry/abq_data_entry/widgets.py b/Chapter08/ABQ_Data_Entry/abq_data_entry/widgets.py new file mode 100644 index 0000000..14783c1 --- /dev/null +++ b/Chapter08/ABQ_Data_Entry/abq_data_entry/widgets.py @@ -0,0 +1,411 @@ +import tkinter as tk +from tkinter import ttk +from datetime import datetime +from decimal import Decimal, InvalidOperation +from .constants import FieldTypes as FT + + +################## +# Widget Classes # +################## + +class ValidatedMixin: + """Adds a validation functionality to an input widget""" + + def __init__(self, *args, error_var=None, **kwargs): + self.error = error_var or tk.StringVar() + super().__init__(*args, **kwargs) + + vcmd = self.register(self._validate) + invcmd = self.register(self._invalid) + + self.configure( + validate='all', + validatecommand=(vcmd, '%P', '%s', '%S', '%V', '%i', '%d'), + invalidcommand=(invcmd, '%P', '%s', '%S', '%V', '%i', '%d') + ) + + def _toggle_error(self, on=False): + self.configure(foreground=('red' if on else 'black')) + + def _validate(self, proposed, current, char, event, index, action): + """The validation method. + + Don't override this, override _key_validate, and _focus_validate + """ + self.error.set('') + self._toggle_error() + + valid = True + # if the widget is disabled, don't validate + state = str(self.configure('state')[-1]) + if state == tk.DISABLED: + return valid + + if event == 'focusout': + valid = self._focusout_validate(event=event) + elif event == 'key': + valid = self._key_validate( + proposed=proposed, + current=current, + char=char, + event=event, + index=index, + action=action + ) + return valid + + def _focusout_validate(self, **kwargs): + return True + + def _key_validate(self, **kwargs): + return True + + def _invalid(self, proposed, current, char, event, index, action): + if event == 'focusout': + self._focusout_invalid(event=event) + elif event == 'key': + self._key_invalid( + proposed=proposed, + current=current, + char=char, + event=event, + index=index, + action=action + ) + + def _focusout_invalid(self, **kwargs): + """Handle invalid data on a focus event""" + self._toggle_error(True) + + def _key_invalid(self, **kwargs): + """Handle invalid data on a key event. By default we want to do nothing""" + pass + + def trigger_focusout_validation(self): + valid = self._validate('', '', '', 'focusout', '', '') + if not valid: + self._focusout_invalid(event='focusout') + return valid + + +class DateEntry(ValidatedMixin, ttk.Entry): + + def _key_validate(self, action, index, char, **kwargs): + valid = True + + if action == '0': # This is a delete action + valid = True + elif index in ('0', '1', '2', '3', '5', '6', '8', '9'): + valid = char.isdigit() + elif index in ('4', '7'): + valid = char == '-' + else: + valid = False + return valid + + def _focusout_validate(self, event): + valid = True + if not self.get(): + self.error.set('A value is required') + valid = False + try: + datetime.strptime(self.get(), '%Y-%m-%d') + except ValueError: + self.error.set('Invalid date') + valid = False + return valid + + +class RequiredEntry(ValidatedMixin, ttk.Entry): + + def _focusout_validate(self, event): + valid = True + if not self.get(): + valid = False + self.error.set('A value is required') + return valid + + +class ValidatedCombobox(ValidatedMixin, ttk.Combobox): + + def _key_validate(self, proposed, action, **kwargs): + valid = True + # if the user tries to delete, + # just clear the field + if action == '0': + self.set('') + return True + + # get our values list + values = self.cget('values') + # Do a case-insensitve match against the entered text + matching = [ + x for x in values + if x.lower().startswith(proposed.lower()) + ] + if len(matching) == 0: + valid = False + elif len(matching) == 1: + self.set(matching[0]) + self.icursor(tk.END) + valid = False + return valid + + def _focusout_validate(self, **kwargs): + valid = True + if not self.get(): + valid = False + self.error.set('A value is required') + return valid + + +class ValidatedSpinbox(ValidatedMixin, ttk.Spinbox): + """A Spinbox that only accepts Numbers""" + + def __init__(self, *args, min_var=None, max_var=None, + focus_update_var=None, from_='-Infinity', to='Infinity', **kwargs + ): + super().__init__(*args, from_=from_, to=to, **kwargs) + increment = Decimal(str(kwargs.get('increment', '1.0'))) + self.precision = increment.normalize().as_tuple().exponent + # there should always be a variable, + # or some of our code will fail + self.variable = kwargs.get('textvariable') + if not self.variable: + self.variable = tk.DoubleVar() + self.configure(textvariable=self.variable) + + if min_var: + self.min_var = min_var + self.min_var.trace_add('write', self._set_minimum) + if max_var: + self.max_var = max_var + self.max_var.trace_add('write', self._set_maximum) + self.focus_update_var = focus_update_var + self.bind('', self._set_focus_update_var) + + def _set_focus_update_var(self, event): + value = self.get() + if self.focus_update_var and not self.error.get(): + self.focus_update_var.set(value) + + def _set_minimum(self, *args): + current = self.get() + try: + new_min = self.min_var.get() + self.config(from_=new_min) + except (tk.TclError, ValueError): + pass + if not current: + self.delete(0, tk.END) + else: + self.variable.set(current) + self.trigger_focusout_validation() + + def _set_maximum(self, *args): + current = self.get() + try: + new_max = self.max_var.get() + self.config(to=new_max) + except (tk.TclError, ValueError): + pass + if not current: + self.delete(0, tk.END) + else: + self.variable.set(current) + self.trigger_focusout_validation() + + def _key_validate( + self, char, index, current, proposed, action, **kwargs + ): + valid = True + min_val = self.cget('from') + max_val = self.cget('to') + no_negative = min_val >= 0 + no_decimal = self.precision >= 0 + if action == '0': + return True + + # First, filter out obviously invalid keystrokes + if any([ + (char not in '-1234567890.'), + (char == '-' and (no_negative or index != '0')), + (char == '.' and (no_decimal or '.' in current)) + ]): + return False + + # At this point, proposed is either '-', '.', '-.', + # or a valid Decimal string + if proposed in '-.': + return True + + # Proposed is a valid Decimal string + # convert to Decimal and check more: + proposed = Decimal(proposed) + proposed_precision = proposed.as_tuple().exponent + + if any([ + (proposed > max_val), + (proposed_precision < self.precision) + ]): + return False + + return valid + + def _focusout_validate(self, **kwargs): + valid = True + value = self.get() + min_val = self.cget('from') + max_val = self.cget('to') + + try: + value = Decimal(value) + except InvalidOperation: + self.error.set('Invalid number string: {}'.format(value)) + return False + + if value < min_val: + self.error.set('Value is too low (min {})'.format(min_val)) + valid = False + if value > max_val: + self.error.set('Value is too high (max {})'.format(max_val)) + + return valid + +class BoundText(tk.Text): + """A Text widget with a bound variable.""" + + def __init__(self, *args, textvariable=None, **kwargs): + super().__init__(*args, **kwargs) + self._variable = textvariable + self._modifying = False + if self._variable: + # insert any default value + self.insert('1.0', self._variable.get()) + self._variable.trace_add('write', self._set_content) + self.bind('<>', self._set_var) + + def _clear_modified_flag(self): + # This also triggers a '<>' Event + self.tk.call(self._w, 'edit', 'modified', 0) + + def _set_var(self, *_): + """Set the variable to the text contents""" + if self._modifying: + return + self._modifying = True + # remove trailing newline from content + content = self.get('1.0', tk.END)[:-1] + self._variable.set(content) + self._clear_modified_flag() + self._modifying = False + + def _set_content(self, *_): + """Set the text contents to the variable""" + if self._modifying: + return + self._modifying = True + self.delete('1.0', tk.END) + self.insert('1.0', self._variable.get()) + self._modifying = False + + +########################### +# Compound Widget Classes # +########################### + + +class LabelInput(ttk.Frame): + """A widget containing a label and input together.""" + + field_types = { + FT.string: RequiredEntry, + FT.string_list: ValidatedCombobox, + FT.short_string_list: ttk.Radiobutton, + FT.iso_date_string: DateEntry, + FT.long_string: BoundText, + FT.decimal: ValidatedSpinbox, + FT.integer: ValidatedSpinbox, + FT.boolean: ttk.Checkbutton + } + + def __init__( + self, parent, label, var, input_class=None, + input_args=None, label_args=None, field_spec=None, + disable_var=None, **kwargs + ): + super().__init__(parent, **kwargs) + input_args = input_args or {} + label_args = label_args or {} + self.variable = var + self.variable.label_widget = self + + # Process the field spec to determine input_class and validation + if field_spec: + field_type = field_spec.get('type', FT.string) + input_class = input_class or self.field_types.get(field_type) + # min, max, increment + if 'min' in field_spec and 'from_' not in input_args: + input_args['from_'] = field_spec.get('min') + if 'max' in field_spec and 'to' not in input_args: + input_args['to'] = field_spec.get('max') + if 'inc' in field_spec and 'increment' not in input_args: + input_args['increment'] = field_spec.get('inc') + # values + if 'values' in field_spec and 'values' not in input_args: + input_args['values'] = field_spec.get('values') + + # setup the label + if input_class in (ttk.Checkbutton, ttk.Button): + # Buttons don't need labels, they're built-in + input_args["text"] = label + else: + self.label = ttk.Label(self, text=label, **label_args) + self.label.grid(row=0, column=0, sticky=(tk.W + tk.E)) + + # setup the variable + if input_class in (ttk.Checkbutton, ttk.Button, ttk.Radiobutton): + input_args["variable"] = self.variable + else: + input_args["textvariable"] = self.variable + + # Setup the input + if input_class == ttk.Radiobutton: + # for Radiobutton, create one input per value + self.input = tk.Frame(self) + for v in input_args.pop('values', []): + button = ttk.Radiobutton( + self.input, value=v, text=v, **input_args) + button.pack(side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x') + else: + self.input = input_class(self, **input_args) + + self.input.grid(row=1, column=0, sticky=(tk.W + tk.E)) + self.columnconfigure(0, weight=1) + + # Set up error handling & display + self.error = getattr(self.input, 'error', tk.StringVar()) + ttk.Label(self, textvariable=self.error).grid( + row=2, column=0, sticky=(tk.W + tk.E) + ) + + # Set up disable variable + if disable_var: + self.disable_var = disable_var + self.disable_var.trace_add('write', self._check_disable) + + def _check_disable(self, *_): + if not hasattr(self, 'disable_var'): + return + + if self.disable_var.get(): + self.input.configure(state=tk.DISABLED) + self.variable.set('') + self.error.set('') + else: + self.input.configure(state=tk.NORMAL) + + def grid(self, sticky=(tk.E + tk.W), **kwargs): + """Override grid to add default sticky values""" + super().grid(sticky=sticky, **kwargs) diff --git a/Chapter08/ABQ_Data_Entry/docs/Application_layout.png b/Chapter08/ABQ_Data_Entry/docs/Application_layout.png new file mode 100644 index 0000000000000000000000000000000000000000..93990f232d19518ca465e7715bbf4f0f10cabce9 GIT binary patch literal 9117 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 mufwd~J*Rr~aor9m$$ITZW{ Date: Wed, 9 Jun 2021 15:03:05 -0500 Subject: [PATCH 11/32] fixed indentation and keyword error --- Chapter01/banana_survey.py | 17 +++++++++++++---- Chapter01/banana_survey_variables.py | 23 +++++++++++++---------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/Chapter01/banana_survey.py b/Chapter01/banana_survey.py index b7c186a..2587caf 100644 --- a/Chapter01/banana_survey.py +++ b/Chapter01/banana_survey.py @@ -31,15 +31,24 @@ name_inp = tk.Entry(root) # Use Checkbutton to get a boolean -eater_inp = tk.Checkbutton(root, text='Check this box if you eat bananas') +eater_inp = tk.Checkbutton( + root, + text='Check this box if you eat bananas' +) # Spinboxes are good for number entry -num_label = tk.Label(root, text='How many bananas do you eat per day?') -num_inp = tk.Spinbox(root, from_=0, increment=1, value=3) +num_label = tk.Label( + root, + text='How many bananas do you eat per day?' +) +num_inp = tk.Spinbox(root, from_=0, to=1000, increment=1) # Listbox is good for choices -color_label = tk.Label(root, text='What is the best color for a banana?') +color_label = tk.Label( + root, + text='What is the best color for a banana?' +) color_inp = tk.Listbox(root, height=1) # Only show selected item # add choices color_choices = ( diff --git a/Chapter01/banana_survey_variables.py b/Chapter01/banana_survey_variables.py index faad05c..013b329 100644 --- a/Chapter01/banana_survey_variables.py +++ b/Chapter01/banana_survey_variables.py @@ -54,12 +54,15 @@ # Listboxes don't work well with variables, # However OptionMenu works great! color_var = tk.StringVar(value='Any') -color_label = tk.Label(root, text='What is the best color for a banana?') +color_label = tk.Label( + root, + text='What is the best color for a banana?' +) color_choices = ( 'Any', 'Green', 'Green Yellow', 'Yellow', 'Brown Spotted', 'Black' ) color_inp = tk.OptionMenu( - root, color_var, *color_choices + root, color_var, *color_choices ) plantain_label = tk.Label(root, text='Do you eat plantains?') @@ -73,16 +76,16 @@ # The radio buttons are connected by using the same variable # The value of the var will be set to the button's 'value' property value plantain_yes_inp = tk.Radiobutton( - plantain_frame, - text='Yes', - value=True, - variable=plantain_var + plantain_frame, + text='Yes', + value=True, + variable=plantain_var ) plantain_no_inp = tk.Radiobutton( - plantain_frame, - text='Ewww, no!', - value=False, - variable=plantain_var + plantain_frame, + text='Ewww, no!', + value=False, + variable=plantain_var ) # The Text widget doesn't support variables, sadly From fde7354b4df5808b7aa198380a706e3a24698e9b Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Mon, 14 Jun 2021 11:41:30 -0500 Subject: [PATCH 12/32] Corrected 4-space indents to 2-space --- Chapter03/data_entry_app.py | 56 ++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/Chapter03/data_entry_app.py b/Chapter03/data_entry_app.py index 93109b3..0779fbf 100644 --- a/Chapter03/data_entry_app.py +++ b/Chapter03/data_entry_app.py @@ -28,9 +28,9 @@ # Application heading ttk.Label( - root, - text="ABQ Data Entry Application", - font=("TkDefaultFont", 16) + root, + text="ABQ Data Entry Application", + font=("TkDefaultFont", 16) ).grid() #################### @@ -65,14 +65,14 @@ variables['Time'] = tk.StringVar() ttk.Label(r_info, text='Time').grid(row=0, column=1) ttk.Combobox( - r_info, textvariable=variables['Time'], values=time_values + r_info, textvariable=variables['Time'], values=time_values ).grid(row=1, column=1, sticky=(tk.W + tk.E)) # Technician variables['Technician'] = tk.StringVar() ttk.Label(r_info, text='Technician').grid(row=0, column=2) ttk.Entry( - r_info, textvariable=variables['Technician'] + r_info, textvariable=variables['Technician'] ).grid(row=1, column=2, sticky=(tk.W + tk.E)) # Lab @@ -89,17 +89,17 @@ variables['Plot'] = tk.IntVar() ttk.Label(r_info, text='Plot').grid(row=2, column=1) ttk.Combobox( - r_info, - textvariable=variables['Plot'], - values=list(range(1, 21)) + r_info, + textvariable=variables['Plot'], + values=list(range(1, 21)) ).grid(row=3, column=1, sticky=(tk.W + tk.E)) # Seed Sample -variables['Seed sample'] = tk.StringVar() +variables['Seed Sample'] = tk.StringVar() ttk.Label(r_info, text='Seed Sample').grid(row=2, column=2) ttk.Entry( - r_info, - textvariable=variables['Seed sample'] + r_info, + textvariable=variables['Seed Sample'] ).grid(row=3, column=2, sticky=(tk.W + tk.E)) ################################# @@ -122,16 +122,16 @@ variables['Light'] = tk.DoubleVar() ttk.Label(e_info, text='Light (klx)').grid(row=0, column=1) ttk.Spinbox( - e_info, textvariable=variables['Light'], - from_=0, to=100, increment=0.01 + e_info, textvariable=variables['Light'], + from_=0, to=100, increment=0.01 ).grid(row=1, column=1, sticky=(tk.W + tk.E)) # Temperature variables['Temperature'] = tk.DoubleVar() ttk.Label(e_info, text='Temperature (°C)').grid(row=0, column=2) ttk.Spinbox( - e_info, textvariable=variables['Temperature'], - from_=4, to=40, increment=.01 + e_info, textvariable=variables['Temperature'], + from_=4, to=40, increment=.01 ).grid(row=1, column=2, sticky=(tk.W + tk.E)) # Equipment Fault @@ -154,48 +154,48 @@ variables['Plants'] = tk.IntVar() ttk.Label(p_info, text='Plants').grid(row=0, column=0) ttk.Spinbox( - p_info, textvariable=variables['Plants'], - from_=0, to=20, increment=1 + p_info, textvariable=variables['Plants'], + from_=0, to=20, increment=1 ).grid(row=1, column=0, sticky=(tk.W + tk.E)) # Blossoms variables['Blossoms'] = tk.IntVar() ttk.Label(p_info, text='Blossoms').grid(row=0, column=1) ttk.Spinbox( - p_info, textvariable=variables['Blossoms'], - from_=0, to=1000, increment=1 + p_info, textvariable=variables['Blossoms'], + from_=0, to=1000, increment=1 ).grid(row=1, column=1, sticky=(tk.W + tk.E)) # Fruit variables['Fruit'] = tk.IntVar() ttk.Label(p_info, text='Fruit').grid(row=0, column=2) ttk.Spinbox( - p_info, textvariable=variables['Fruit'], - from_=0, to=1000, increment=1 + p_info, textvariable=variables['Fruit'], + from_=0, to=1000, increment=1 ).grid(row=1, column=2, sticky=(tk.W + tk.E)) # Min Height variables['Min Height'] = tk.DoubleVar() ttk.Label(p_info, text='Min Height (cm)').grid(row=2, column=0) ttk.Spinbox( - p_info, textvariable=variables['Min Height'], - from_=0, to=1000, increment=0.01 + p_info, textvariable=variables['Min Height'], + from_=0, to=1000, increment=0.01 ).grid(row=3, column=0, sticky=(tk.W + tk.E)) # Max Height variables['Max Height'] = tk.DoubleVar() ttk.Label(p_info, text='Max Height (cm)').grid(row=2, column=1) ttk.Spinbox( - p_info, textvariable=variables['Max Height'], - from_=0, to=1000, increment=0.01 + p_info, textvariable=variables['Max Height'], + from_=0, to=1000, increment=0.01 ).grid(row=3, column=1, sticky=(tk.W + tk.E)) # Med Height variables['Med Height'] = tk.DoubleVar() ttk.Label(p_info, text='Median Height (cm)').grid(row=2, column=2) ttk.Spinbox( - p_info, textvariable=variables['Med Height'], - from_=0, to=1000, increment=0.01 + p_info, textvariable=variables['Med Height'], + from_=0, to=1000, increment=0.01 ).grid(row=3, column=2, sticky=(tk.W + tk.E)) @@ -244,7 +244,7 @@ def on_reset(): else: variable.set('') - # reset notes_inp + # reset notes_inp notes_inp.delete('1.0', tk.END) From c6e2d44524033f59f048f65461070b4400df629e Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Mon, 14 Jun 2021 13:21:42 -0500 Subject: [PATCH 13/32] Small fixes per tech review: - Moved field filtering to loop to avoid errors - Open file using newline arg - Remove sticky from label placement --- Chapter03/data_entry_app.py | 30 +++++++++++++----------------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/Chapter03/data_entry_app.py b/Chapter03/data_entry_app.py index 0779fbf..22f3a64 100644 --- a/Chapter03/data_entry_app.py +++ b/Chapter03/data_entry_app.py @@ -53,10 +53,8 @@ # Date variables['Date'] = tk.StringVar() -ttk.Label(r_info, text='Date').grid( - row=0, column=0, sticky=(tk.W + tk.E) -) -ttk.Entry( +ttk.Label(r_info, text='Date').grid(row=0, column=0) +tk.Entry( r_info, textvariable=variables['Date'] ).grid(row=1, column=0, sticky=(tk.W + tk.E)) @@ -263,24 +261,22 @@ def on_save(): # get the data from the variables data = dict() + fault = variables['Equipment Fault'].get() for key, variable in variables.items(): - try: - data[key] = variable.get() - except tk.TclError: - status_variable.set( - f'Error in field: {key}. Data was not saved!') - return + if fault and key in ('Light', 'Humidity', 'Temperature'): + data[key] = '' + else: + try: + data[key] = variable.get() + except tk.TclError: + status_variable.set( + f'Error in field: {key}. Data was not saved!') + return # get the Text widget contents separately data['Notes'] = notes_inp.get('1.0', tk.END) - # clear the environmental data when there is a sensor fault - if data['Equipment Fault']: - data['Light'] = '' - data['Humidity'] = '' - data['Temperature'] = '' - # Append the record to a CSV - with open(filename, 'a') as fh: + with open(filename, 'a', newline='') as fh: csvwriter = csv.DictWriter(fh, fieldnames=data.keys()) if newfile: csvwriter.writeheader() From 9b9b3ec63e9b390d698786a44e2c4da785fa087e Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Fri, 18 Jun 2021 14:07:30 -0500 Subject: [PATCH 14/32] four spaces to 2 --- Chapter03/ttk_tour.py | 78 +++++++++++++++++++++---------------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/Chapter03/ttk_tour.py b/Chapter03/ttk_tour.py index 30f0a95..1f15fed 100644 --- a/Chapter03/ttk_tour.py +++ b/Chapter03/ttk_tour.py @@ -13,7 +13,7 @@ pack_args = {"padx": 20, "pady": 20} def my_callback(*_): - print("Callback called!") + print("Callback called!") # Entry @@ -23,30 +23,30 @@ def my_callback(*_): # Spinbox myspinbox = ttk.Spinbox( - root, - from_=0, to=100, increment=.01, - textvariable=my_int_var, - command=my_callback + root, + from_=0, to=100, increment=.01, + textvariable=my_int_var, + command=my_callback ) myspinbox.pack(**pack_args) # Checkbutton mycheckbutton = ttk.Checkbutton( - root, - variable=my_bool_var, - textvariable=my_string_var, - command=my_callback + root, + variable=my_bool_var, + textvariable=my_string_var, + command=my_callback ) mycheckbutton.pack(**pack_args) mycheckbutton2 = ttk.Checkbutton( - root, - variable=my_dbl_var, - text='Would you like Pi?', - onvalue=3.14159, - offvalue=0, - underline=15 + root, + variable=my_dbl_var, + text='Would you like Pi?', + onvalue=3.14159, + offvalue=0, + underline=15 ) mycheckbutton2.pack(**pack_args) @@ -55,16 +55,16 @@ def my_callback(*_): buttons.pack() r1 = ttk.Radiobutton( - buttons, - variable=my_int_var, - value=1, - text='One' + buttons, + variable=my_int_var, + value=1, + text='One' ) r2 = ttk.Radiobutton( - buttons, - variable=my_int_var, - value=2, - text='Two' + buttons, + variable=my_int_var, + value=2, + text='Two' ) r1.pack(side='left') r2.pack(side='left') @@ -72,18 +72,18 @@ def my_callback(*_): # Combobox Widget mycombo = ttk.Combobox( - root, textvariable=my_string_var, - values=['This option', 'That option', 'Another option'] + root, textvariable=my_string_var, + values=['This option', 'That option', 'Another option'] ) mycombo.pack(**pack_args) # Text widget mytext = tk.Text( - root, - undo=True, maxundo=100, - spacing1=10, spacing2=2, spacing3=5, - height=5, wrap='char' + root, + undo=True, maxundo=100, + spacing1=10, spacing2=2, spacing3=5, + height=5, wrap='char' ) mytext.pack(**pack_args) @@ -102,26 +102,26 @@ def my_callback(*_): # Button widget mybutton = ttk.Button( - root, - command=my_callback, - text='Click Me!', - default='active' + root, + command=my_callback, + text='Click Me!', + default='active' ) mybutton.pack(**pack_args) # LabelFrame Widget mylabelframe = ttk.LabelFrame( - root, - text='Button frame' + root, + text='Button frame' ) b1 = ttk.Button( - mylabelframe, - text='Button 1' + mylabelframe, + text='Button 1' ) b2 = ttk.Button( - mylabelframe, - text='Button 2' + mylabelframe, + text='Button 2' ) b1.pack() b2.pack() From 85ef1eb4fbafe9be29319d9fc8d2a95e8591728a Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Sun, 27 Jun 2021 13:36:10 -0500 Subject: [PATCH 15/32] Code updates per technical feedback --- Chapter04/data_entry_app.py | 45 +++++++++++++------------------ Chapter04/tkinter_classes_demo.py | 16 ++++++----- 2 files changed, 27 insertions(+), 34 deletions(-) diff --git a/Chapter04/data_entry_app.py b/Chapter04/data_entry_app.py index 31e9e11..1d1105e 100644 --- a/Chapter04/data_entry_app.py +++ b/Chapter04/data_entry_app.py @@ -18,37 +18,23 @@ class BoundText(tk.Text): def __init__(self, *args, textvariable=None, **kwargs): super().__init__(*args, **kwargs) self._variable = textvariable - self._modifying = False if self._variable: # insert any default value self.insert('1.0', self._variable.get()) self._variable.trace_add('write', self._set_content) self.bind('<>', self._set_var) - def _clear_modified_flag(self): - # This also triggers a '<>' Event - self.tk.call(self._w, 'edit', 'modified', 0) - def _set_var(self, *_): """Set the variable to the text contents""" - if self._modifying: - return - self._modifying = True - # remove trailing newline from content - content = self.get('1.0', tk.END)[:-1] - self._variable.set(content) - self._clear_modified_flag() - self._modifying = False + if self.edit_modified(): + content = self.get('1.0', 'end-1chars') + self._variable.set(content) + self.edit_modified(False) def _set_content(self, *_): """Set the text contents to the variable""" - if self._modifying: - return - self._modifying = True self.delete('1.0', tk.END) self.insert('1.0', self._variable.get()) - self._modifying = False - ######################################### # Creating a LabelInput compound widget # @@ -150,7 +136,8 @@ def __init__(self, *args, **kwargs): r_info, "Date", var=self._vars['Date'] ).grid(row=0, column=0) LabelInput( - r_info, "Time", input_class=ttk.Combobox, var=self._vars['Time'], + r_info, "Time", input_class=ttk.Combobox, + var=self._vars['Time'], input_args={"values": ["8:00", "12:00", "16:00", "20:00"]} ).grid(row=0, column=1) LabelInput( @@ -242,7 +229,7 @@ def __init__(self, *args, **kwargs): buttons = ttk.Frame(self) buttons.grid(sticky=tk.W + tk.E, row=4) self.savebutton = ttk.Button( - buttons, text="Save", command=self.master.on_save) + buttons, text="Save", command=self.master._on_save) self.savebutton.pack(side=tk.RIGHT) self.resetbutton = ttk.Button( @@ -258,12 +245,16 @@ def get(self): # We need to retrieve the data from Tkinter variables # and place it in regular Python objects data = dict() + fault = self._vars['Equipment Fault'].get() for key, variable in self._vars.items(): - try: - data[key] = variable.get() - except tk.TclError: - message = f'Error in field: {key}. Data was not saved!' - raise ValueError(message) + if fault and key in ('Light', 'Humidity', 'Temperature'): + data[key] = '' + else: + try: + data[key] = variable.get() + except tk.TclError: + message = f'Error in field: {key}. Data was not saved!' + raise ValueError(message) return data @@ -303,7 +294,7 @@ def __init__(self, *args, **kwargs): self._records_saved = 0 - def on_save(self): + def _on_save(self): """Handles save button clicks""" # For now, we save to a hardcoded filename with a datestring. @@ -319,7 +310,7 @@ def on_save(self): self.status.set(str(e)) return - with open(filename, 'a') as fh: + with open(filename, 'a', newline='') as fh: csvwriter = csv.DictWriter(fh, fieldnames=data.keys()) if newfile: csvwriter.writeheader() diff --git a/Chapter04/tkinter_classes_demo.py b/Chapter04/tkinter_classes_demo.py index ee3d1bf..3763dbb 100644 --- a/Chapter04/tkinter_classes_demo.py +++ b/Chapter04/tkinter_classes_demo.py @@ -13,8 +13,7 @@ class JSONVar(tk.StringVar): """A Tk variable that can hold dicts and lists""" def __init__(self, *args, **kwargs): - if kwargs.get('value'): - kwargs['value'] = json.dumps(kwargs['value']) + kwargs['value'] = json.dumps(kwargs.get('value')) super().__init__(*args, **kwargs) def set(self, value, *args, **kwargs): @@ -45,10 +44,13 @@ def get(self, *args, **kwargs): class LabelInput(tk.Frame): """A label and input combined together""" - def __init__(self, parent, label, inp_class, inp_args, *args, **kwargs): + def __init__( + self, parent, label, inp_cls, + inp_args, *args, **kwargs + ): super().__init__(parent, *args, **kwargs) self.label = tk.Label(self, text=label, anchor='w') - self.input = inp_class(self, **inp_args) + self.input = inp_cls(self, **inp_args) # side-by-side layout self.columnconfigure(1, weight=1) @@ -135,6 +137,6 @@ def _on_data_change(self, *args, **kwargs): self.output_var.set(output) #root.mainloop() - -app = Application() -app.mainloop() +if __name__ == '__main__': + app = Application() + app.mainloop() From c8ffb5d17fe82116f63f3a0879a30f21bc498f23 Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Tue, 29 Jun 2021 11:42:01 -0500 Subject: [PATCH 16/32] Changes per chapter 5 revision 2 --- Chapter05/DateEntry.py | 12 +++++----- Chapter05/data_entry_app.py | 48 +++++++++++++++---------------------- Chapter05/validate_demo.py | 30 +++++++++++------------ 3 files changed, 40 insertions(+), 50 deletions(-) diff --git a/Chapter05/DateEntry.py b/Chapter05/DateEntry.py index a259fac..0168792 100644 --- a/Chapter05/DateEntry.py +++ b/Chapter05/DateEntry.py @@ -9,17 +9,17 @@ def __init__(self, parent, *args, **kwargs): super().__init__(parent, *args, **kwargs) self.configure( validate='all', - validatecommand=(self.register(self._validate), '%S', '%i', '%V', '%d'), + validatecommand=( + self.register(self._validate), + '%S', '%i', '%V', '%d' + ), invalidcommand=(self.register(self._on_invalid), '%V') ) self.error = tk.StringVar() def _toggle_error(self, error=''): self.error.set(error) - if error: - self.config(foreground='red') - else: - self.config(foreground='black') + self.config(foreground='red' if error else 'black') def _validate(self, char, index, event, action): @@ -53,7 +53,7 @@ def _on_invalid(self, event): entry = DateEntry(root) entry.pack() ttk.Label( - textvariable=entry.error, foreground='red' + textvariable=entry.error, foreground='red' ).pack() # add this so we can unfocus the DateEntry diff --git a/Chapter05/data_entry_app.py b/Chapter05/data_entry_app.py index 47f9562..35a1623 100644 --- a/Chapter05/data_entry_app.py +++ b/Chapter05/data_entry_app.py @@ -148,7 +148,7 @@ def _key_validate(self, proposed, action, **kwargs): # get our values list values = self.cget('values') - # Do a case-insensitve match against the entered text + # Do a case-insensitive match against the entered text matching = [ x for x in values if x.lower().startswith(proposed.lower()) @@ -199,7 +199,7 @@ def _set_focus_update_var(self, event): if self.focus_update_var and not self.error.get(): self.focus_update_var.set(value) - def _set_minimum(self, *args): + def _set_minimum(self, *_): current = self.get() try: new_min = self.min_var.get() @@ -212,7 +212,7 @@ def _set_minimum(self, *args): self.variable.set(current) self.trigger_focusout_validation() - def _set_maximum(self, *args): + def _set_maximum(self, *_): current = self.get() try: new_max = self.max_var.get() @@ -228,13 +228,13 @@ def _set_maximum(self, *args): def _key_validate( self, char, index, current, proposed, action, **kwargs ): + if action == '0': + return True valid = True min_val = self.cget('from') max_val = self.cget('to') no_negative = min_val >= 0 no_decimal = self.precision >= 0 - if action == '0': - return True # First, filter out obviously invalid keystrokes if any([ @@ -269,16 +269,17 @@ def _focusout_validate(self, **kwargs): max_val = self.cget('to') try: - value = Decimal(value) + d_value = Decimal(value) except InvalidOperation: - self.error.set('Invalid number string: {}'.format(value)) + self.error.set(f'Invalid number string: {value}') return False - if value < min_val: - self.error.set('Value is too low (min {})'.format(min_val)) + if d_value < min_val: + self.error.set(f'Value is too low (min {min_val})') + valid = False + if d_value > max_val: + self.error.set(f'Value is too high (max {max_val})') valid = False - if value > max_val: - self.error.set('Value is too high (max {})'.format(max_val)) return valid @@ -288,36 +289,23 @@ class BoundText(tk.Text): def __init__(self, *args, textvariable=None, **kwargs): super().__init__(*args, **kwargs) self._variable = textvariable - self._modifying = False if self._variable: # insert any default value self.insert('1.0', self._variable.get()) self._variable.trace_add('write', self._set_content) self.bind('<>', self._set_var) - def _clear_modified_flag(self): - # This also triggers a '<>' Event - self.tk.call(self._w, 'edit', 'modified', 0) - def _set_var(self, *_): """Set the variable to the text contents""" - if self._modifying: - return - self._modifying = True - # remove trailing newline from content - content = self.get('1.0', tk.END)[:-1] - self._variable.set(content) - self._clear_modified_flag() - self._modifying = False + if self.edit_modified(): + content = self.get('1.0', 'end-1chars') + self._variable.set(content) + self.edit_modified(False) def _set_content(self, *_): """Set the text contents to the variable""" - if self._modifying: - return - self._modifying = True self.delete('1.0', tk.END) self.insert('1.0', self._variable.get()) - self._modifying = False ################## # Module Classes # @@ -595,7 +583,9 @@ def reset(self): plot = self._vars['Plot'].get() except tk.TclError: plot = '' - plot_values = self._vars['Plot'].label_widget.input.cget('values') + plot_values = ( + self._vars['Plot'].label_widget.input.cget('values') + ) # clear all values for var in self._vars.values(): diff --git a/Chapter05/validate_demo.py b/Chapter05/validate_demo.py index 8495428..2990cd0 100644 --- a/Chapter05/validate_demo.py +++ b/Chapter05/validate_demo.py @@ -6,28 +6,28 @@ # create a pointless validation command def always_good(): - return True + return True validate_ref = root.register(always_good) entry.configure( - validate='all', - validatecommand=(validate_ref,) + validate='all', + validatecommand=(validate_ref,) ) # a more useful validation command entry2 = tk.Entry(root) -entry2.grid() +entry2.grid(pady=10) def no_t_for_me(proposed): - return 't' not in proposed + return 't' not in proposed validate2_ref = root.register(no_t_for_me) entry2.configure( - validate='all', - validatecommand=(validate2_ref, '%P') + validate='all', + validatecommand=(validate2_ref, '%P') ) # An Entry that displays an error @@ -35,22 +35,22 @@ def no_t_for_me(proposed): entry3 = tk.Entry(root) entry3.grid() entry3_error = tk.Label(root, fg='red') -entry3_error.grid() +entry3_error.grid(pady=10) def only_five_chars(proposed): - return len(proposed) < 6 + return len(proposed) < 6 def only_five_chars_error(proposed): - entry3_error.configure( - text=f'{proposed} is too long, only 5 chars allowed.' - ) + entry3_error.configure( + text=f'{proposed} is too long, only 5 chars allowed.' + ) validate3_ref = root.register(only_five_chars) invalid3_ref = root.register(only_five_chars_error) entry3.configure( - validate='all', - validatecommand=(validate3_ref, '%P'), - invalidcommand=(invalid3_ref, '%P') + validate='all', + validatecommand=(validate3_ref, '%P'), + invalidcommand=(invalid3_ref, '%P') ) root.mainloop() From 8d650af6dc2394799c2636394175cb7eef60b181 Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Tue, 6 Jul 2021 08:51:58 -0500 Subject: [PATCH 17/32] Chapter 6 revisions --- .../ABQ_Data_Entry/abq_data_entry/__main__.py | 7 + .../abq_data_entry/application.py | 4 +- .../ABQ_Data_Entry/abq_data_entry/models.py | 56 ++++--- .../ABQ_Data_Entry/abq_data_entry/views.py | 14 +- .../ABQ_Data_Entry/abq_data_entry/widgets.py | 42 ++--- .../docs/abq_data_entry_spec.rst | 145 ++++++++++-------- 6 files changed, 146 insertions(+), 122 deletions(-) create mode 100644 Chapter06/ABQ_Data_Entry/abq_data_entry/__main__.py diff --git a/Chapter06/ABQ_Data_Entry/abq_data_entry/__main__.py b/Chapter06/ABQ_Data_Entry/abq_data_entry/__main__.py new file mode 100644 index 0000000..0ee2c5d --- /dev/null +++ b/Chapter06/ABQ_Data_Entry/abq_data_entry/__main__.py @@ -0,0 +1,7 @@ +from abq_data_entry.application import Application +import os + +print(os.getcwd()) + +app = Application() +app.mainloop() diff --git a/Chapter06/ABQ_Data_Entry/abq_data_entry/application.py b/Chapter06/ABQ_Data_Entry/abq_data_entry/application.py index 4a385e5..1b99be6 100644 --- a/Chapter06/ABQ_Data_Entry/abq_data_entry/application.py +++ b/Chapter06/ABQ_Data_Entry/abq_data_entry/application.py @@ -46,12 +46,12 @@ def _on_save(self, *_): "Cannot save, error in fields: {}" .format(', '.join(errors.keys())) ) - return False + return data = self.recordform.get() self.model.save_record(data) self.records_saved += 1 self.status.set( - "{} records saved this session".format(self.records_saved) + f"{self.records_saved} records saved this session" ) self.recordform.reset() diff --git a/Chapter06/ABQ_Data_Entry/abq_data_entry/models.py b/Chapter06/ABQ_Data_Entry/abq_data_entry/models.py index e60aa27..793e0c4 100644 --- a/Chapter06/ABQ_Data_Entry/abq_data_entry/models.py +++ b/Chapter06/ABQ_Data_Entry/abq_data_entry/models.py @@ -10,30 +10,48 @@ class CSVModel: fields = { "Date": {'req': True, 'type': FT.iso_date_string}, - "Time": {'req': True, 'type': FT.string_list, - 'values': ['8:00', '12:00', '16:00', '20:00']}, + "Time": { + 'req': True, 'type': FT.string_list, + 'values': ['8:00', '12:00', '16:00', '20:00'] + }, "Technician": {'req': True, 'type': FT.string}, - "Lab": {'req': True, 'type': FT.short_string_list, - 'values': ['A', 'B', 'C']}, - "Plot": {'req': True, 'type': FT.string_list, - 'values': [str(x) for x in range(1, 21)]}, + "Lab": { + 'req': True, 'type': FT.short_string_list, + 'values': ['A', 'B', 'C'] + }, + "Plot": { + 'req': True, 'type': FT.string_list, + 'values': [str(x) for x in range(1, 21)] + }, "Seed Sample": {'req': True, 'type': FT.string}, - "Humidity": {'req': True, 'type': FT.decimal, - 'min': 0.5, 'max': 52.0, 'inc': .01}, - "Light": {'req': True, 'type': FT.decimal, - 'min': 0, 'max': 100.0, 'inc': .01}, - "Temperature": {'req': True, 'type': FT.decimal, - 'min': 4, 'max': 40, 'inc': .01}, + "Humidity": { + 'req': True, 'type': FT.decimal, + 'min': 0.5, 'max': 52.0, 'inc': .01 + }, + "Light": { + 'req': True, 'type': FT.decimal, + 'min': 0, 'max': 100.0, 'inc': .01 + }, + "Temperature": { + 'req': True, 'type': FT.decimal, + 'min': 4, 'max': 40, 'inc': .01 + }, "Equipment Fault": {'req': False, 'type': FT.boolean}, "Plants": {'req': True, 'type': FT.integer, 'min': 0, 'max': 20}, "Blossoms": {'req': True, 'type': FT.integer, 'min': 0, 'max': 1000}, "Fruit": {'req': True, 'type': FT.integer, 'min': 0, 'max': 1000}, - "Min Height": {'req': True, 'type': FT.decimal, - 'min': 0, 'max': 1000, 'inc': .01}, - "Max Height": {'req': True, 'type': FT.decimal, - 'min': 0, 'max': 1000, 'inc': .01}, - "Med Height": {'req': True, 'type': FT.decimal, - 'min': 0, 'max': 1000, 'inc': .01}, + "Min Height": { + 'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01 + }, + "Max Height": { + 'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01 + }, + "Med Height": { + 'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01 + }, "Notes": {'req': False, 'type': FT.long_string} } @@ -60,7 +78,7 @@ def save_record(self, data): """Save a dict of data to the CSV file""" newfile = not self.file.exists() - with open(self.file, 'a') as fh: + with open(self.file, 'a', newline='') as fh: csvwriter = csv.DictWriter(fh, fieldnames=self.fields.keys()) if newfile: csvwriter.writeheader() diff --git a/Chapter06/ABQ_Data_Entry/abq_data_entry/views.py b/Chapter06/ABQ_Data_Entry/abq_data_entry/views.py index 901e5e9..c623b5d 100644 --- a/Chapter06/ABQ_Data_Entry/abq_data_entry/views.py +++ b/Chapter06/ABQ_Data_Entry/abq_data_entry/views.py @@ -56,21 +56,21 @@ def __init__(self, parent, model, *args, **kwargs): field_spec=fields['Time'], var=self._vars['Time'], ).grid(row=0, column=1) + w.LabelInput( + r_info, "Technician", + field_spec=fields['Technician'], + var=self._vars['Technician'], + ).grid(row=0, column=2) + # line 2 w.LabelInput( r_info, "Lab", field_spec=fields['Lab'], var=self._vars['Lab'], - ).grid(row=0, column=2) - # line 2 + ).grid(row=1, column=0) w.LabelInput( r_info, "Plot", field_spec=fields['Plot'], var=self._vars['Plot'], - ).grid(row=1, column=0) - w.LabelInput( - r_info, "Technician", - field_spec=fields['Technician'], - var=self._vars['Technician'], ).grid(row=1, column=1) w.LabelInput( r_info, "Seed Sample", diff --git a/Chapter06/ABQ_Data_Entry/abq_data_entry/widgets.py b/Chapter06/ABQ_Data_Entry/abq_data_entry/widgets.py index 14783c1..954a656 100644 --- a/Chapter06/ABQ_Data_Entry/abq_data_entry/widgets.py +++ b/Chapter06/ABQ_Data_Entry/abq_data_entry/widgets.py @@ -190,7 +190,7 @@ def _set_focus_update_var(self, event): if self.focus_update_var and not self.error.get(): self.focus_update_var.set(value) - def _set_minimum(self, *args): + def _set_minimum(self, *_): current = self.get() try: new_min = self.min_var.get() @@ -203,7 +203,7 @@ def _set_minimum(self, *args): self.variable.set(current) self.trigger_focusout_validation() - def _set_maximum(self, *args): + def _set_maximum(self, *_): current = self.get() try: new_max = self.max_var.get() @@ -219,13 +219,13 @@ def _set_maximum(self, *args): def _key_validate( self, char, index, current, proposed, action, **kwargs ): + if action == '0': + return True valid = True min_val = self.cget('from') max_val = self.cget('to') no_negative = min_val >= 0 no_decimal = self.precision >= 0 - if action == '0': - return True # First, filter out obviously invalid keystrokes if any([ @@ -260,16 +260,17 @@ def _focusout_validate(self, **kwargs): max_val = self.cget('to') try: - value = Decimal(value) + d_value = Decimal(value) except InvalidOperation: - self.error.set('Invalid number string: {}'.format(value)) + self.error.set(f'Invalid number string: {value}') return False - if value < min_val: - self.error.set('Value is too low (min {})'.format(min_val)) + if d_value < min_val: + self.error.set(f'Value is too low (min {min_val})') + valid = False + if d_value > max_val: + self.error.set(f'Value is too high (max {max_val})') valid = False - if value > max_val: - self.error.set('Value is too high (max {})'.format(max_val)) return valid @@ -279,36 +280,23 @@ class BoundText(tk.Text): def __init__(self, *args, textvariable=None, **kwargs): super().__init__(*args, **kwargs) self._variable = textvariable - self._modifying = False if self._variable: # insert any default value self.insert('1.0', self._variable.get()) self._variable.trace_add('write', self._set_content) self.bind('<>', self._set_var) - def _clear_modified_flag(self): - # This also triggers a '<>' Event - self.tk.call(self._w, 'edit', 'modified', 0) - def _set_var(self, *_): """Set the variable to the text contents""" - if self._modifying: - return - self._modifying = True - # remove trailing newline from content - content = self.get('1.0', tk.END)[:-1] - self._variable.set(content) - self._clear_modified_flag() - self._modifying = False + if self.edit_modified(): + content = self.get('1.0', 'end-1chars') + self._variable.set(content) + self.edit_modified(False) def _set_content(self, *_): """Set the text contents to the variable""" - if self._modifying: - return - self._modifying = True self.delete('1.0', tk.END) self.insert('1.0', self._variable.get()) - self._modifying = False ########################### diff --git a/Chapter06/ABQ_Data_Entry/docs/abq_data_entry_spec.rst b/Chapter06/ABQ_Data_Entry/docs/abq_data_entry_spec.rst index ffd762e..349be50 100644 --- a/Chapter06/ABQ_Data_Entry/docs/abq_data_entry_spec.rst +++ b/Chapter06/ABQ_Data_Entry/docs/abq_data_entry_spec.rst @@ -2,95 +2,106 @@ ABQ Data Entry Program specification ====================================== - Description ----------- -The program is being created to minimize data entry errors for laboratory measurements. +This program facilitates entry of laboratory observations +into a CSV file. -Functionality Required ----------------------- +Requirements +------------ -The program must: +Functional Requirements: -* allow all relevant, valid data to be entered, as per the field chart -* append entered data to a CSV file - - The CSV file must have a filename of abq_data_record_CURRENTDATE.csv, - where CURRENTDATE is the date of the checks in ISO format (Year-month-day) - - The CSV file must have all the fields as per the chart -* enforce correct datatypes per field -* have inputs that: + * Allow all relevant, valid data to be entered, + as per the data dictionary. + * Append entered data to a CSV file: + - The CSV file must have a filename of + abq_data_record_CURRENTDATE.csv, where CURRENTDATE is the date + of the laboratory observations in ISO format (Year-month-day). + - The CSV file must include all fields. + listed in the data dictionary. + - The CSV headers will avoid cryptic abbreviations. + * Enforce correct datatypes per field. + * have inputs that: - ignore meaningless keystrokes - display an error if the value is invalid on focusout - display an error if a required field is empty on focusout -* prevent saving the record when errors are present + * prevent saving the record when errors are present -The program should try, whenever possible, to: +Non-functional Requirements: -* enforce reasonable limits on data entered -* Auto-fill data -* Suggest likely correct values -* Provide a smooth and efficient workflow + * Enforce reasonable limits on data entered, per the data dict. + * Auto-fill data to save time. + * Suggest likely correct values. + * Provide a smooth and efficient workflow. + * Store data in a format easily understandable by Python. Functionality Not Required -------------------------- The program does not need to: -* Allow editing of data. This can be done in LibreOffice if necessary. -* Allow deletion of data. + * Allow editing of data. + * Allow deletion of data. + +Users can perform both actions in LibreOffice if needed. + Limitations ----------- The program must: -* Be efficiently operable by keyboard-only users. -* Be accessible to color blind users. -* Run on Debian Linux. -* Run acceptably on a low-end PC. + * Be efficiently operable by keyboard-only users. + * Be accessible to color blind users. + * Run on Debian GNU/Linux. + * Run acceptably on a low-end PC. Data Dictionary --------------- -+------------+----------+------+------------------+--------------------------+ -|Field | Datatype | Units| Range |Descripton | -+============+==========+======+==================+==========================+ -|Date |Date | | |Date of record | -+------------+----------+------+------------------+--------------------------+ -|Time |Time | |8:00, 12:00, |Time period | -| | | |16:00, or 20:00 | | -+------------+----------+------+------------------+--------------------------+ -|Lab |String | | A - C |Lab ID | -+------------+----------+------+------------------+--------------------------+ -|Technician |String | | |Technician name | -+------------+----------+------+------------------+--------------------------+ -|Plot |Int | | 1 - 20 |Plot ID | -+------------+----------+------+------------------+--------------------------+ -|Seed |String | | |Seed sample ID | -|sample | | | | | -+------------+----------+------+------------------+--------------------------+ -|Fault |Bool | | |Fault on environmental | -| | | | |sensor | -+------------+----------+------+------------------+--------------------------+ -|Light |Decimal |klx | 0 - 100 |Light at plot | -+------------+----------+------+------------------+--------------------------+ -|Humidity |Decimal |g/m³ | 0.5 - 52.0 |Abs humidity at plot | -+------------+----------+------+------------------+--------------------------+ -|Temperature |Decimal |°C | 4 - 40 |Temperature at plot | -+------------+----------+------+------------------+--------------------------+ -|Blossoms |Int | | 0 - 1000 |Number of blossoms in plot| -+------------+----------+------+------------------+--------------------------+ -|Fruit |Int | | 0 - 1000 |Number of fruits in plot | -+------------+----------+------+------------------+--------------------------+ -|Plants |Int | | 0 - 20 |Number of plants in plot | -+------------+----------+------+------------------+--------------------------+ -|Max height |Decimal |cm | 0 - 1000 |Height of tallest plant in| -| | | | |plot | -+------------+----------+------+------------------+--------------------------+ -|Min height |Decimal |cm | 0 - 1000 |Height of shortest plant | -| | | | |in plot | -+------------+----------+------+------------------+--------------------------+ -|Median |Decimal |cm | 0 - 1000 |Median height of plants in| -|height | | | |plot | -+------------+----------+------+------------------+--------------------------+ -|Notes |String | | |Miscellaneous notes | -+------------+----------+------+------------------+--------------------------+ ++------------+--------+----+---------------+--------------------+ +|Field | Type |Unit| Valid Values |Description | ++============+========+====+===============+====================+ +|Date |Date | | |Date of record | ++------------+--------+----+---------------+--------------------+ +|Time |Time | |8:00, 12:00, |Time period | +| | | |16:00, or 20:00| | ++------------+--------+----+---------------+--------------------+ +|Lab |String | | A - C |Lab ID | ++------------+--------+----+---------------+--------------------+ +|Technician |String | | |Technician name | ++------------+--------+----+---------------+--------------------+ +|Plot |Int | | 1 - 20 |Plot ID | ++------------+--------+----+---------------+--------------------+ +|Seed |String | | 6-character |Seed sample ID | +|Sample | | | string | | ++------------+--------+----+---------------+--------------------+ +|Fault |Bool | | True, False |Environmental | +| | | | |sensor fault | ++------------+--------+----+---------------+--------------------+ +|Light |Decimal |klx | 0 - 100 |Light at plot | +| | | | |blank on fault | ++------------+--------+----+---------------+--------------------+ +|Humidity |Decimal |g/m³| 0.5 - 52.0 |Abs humidity at plot| +| | | | |blank on fault | ++------------+--------+----+---------------+--------------------+ +|Temperature |Decimal |°C | 4 - 40 |Temperature at plot | +| | | | |blank on fault | ++------------+--------+----+---------------+--------------------+ +|Blossoms |Int | | 0 - 1000 |No. blossoms in plot| ++------------+--------+----+---------------+--------------------+ +|Fruit |Int | | 0 - 1000 |No. fruits in plot | ++------------+--------+----+---------------+--------------------+ +|Plants |Int | | 0 - 20 |No. plants in plot | ++------------+--------+----+---------------+--------------------+ +|Max height |Decimal |cm | 0 - 1000 |Height of tallest | +| | | | |plant in plot | ++------------+--------+----+---------------+--------------------+ +|Min height |Decimal |cm | 0 - 1000 |Height of shortest | +| | | | |plant in plot | ++------------+--------+----+---------------+--------------------+ +|Median |Decimal |cm | 0 - 1000 |Median height of | +|height | | | |plants in plot | ++------------+--------+----+---------------+--------------------+ +|Notes |String | | |Miscellaneous notes | ++------------+--------+----+---------------+--------------------+ From 16ed0daf2edfacff930b9f54eb4d66e273e67f0b Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Fri, 9 Jul 2021 11:06:07 -0500 Subject: [PATCH 18/32] Add validated radio button --- Chapter05/data_entry_app.py | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/Chapter05/data_entry_app.py b/Chapter05/data_entry_app.py index 35a1623..f2098a3 100644 --- a/Chapter05/data_entry_app.py +++ b/Chapter05/data_entry_app.py @@ -283,6 +283,23 @@ def _focusout_validate(self, **kwargs): return valid +class ValidatedRadio(ttk.Radiobutton): + """A validated radio button""" + + def __init__(self, *args, error_var=None, **kwargs): + super().__init__(*args, **kwargs) + self.error = error_var or tk.StringVar() + self.variable = kwargs.get("variable") + self.bind('', self._focusout_validate) + + def _focusout_validate(self, *_): + self.error.set('') + if not self.variable.get(): + self.error.set('A value is required') + + def trigger_focusout_validation(self): + self._focusout_validate() + class BoundText(tk.Text): """A Text widget with a bound variable.""" @@ -335,19 +352,24 @@ def __init__( self.label.grid(row=0, column=0, sticky=(tk.W + tk.E)) # setup the variable - if input_class in (ttk.Checkbutton, ttk.Button, ttk.Radiobutton): + if input_class in ( + ttk.Checkbutton, ttk.Button, ttk.Radiobutton, ValidatedRadio + ): input_args["variable"] = self.variable else: input_args["textvariable"] = self.variable # Setup the input - if input_class == ttk.Radiobutton: + if input_class in (ttk.Radiobutton, ValidatedRadio): # for Radiobutton, create one input per value self.input = tk.Frame(self) for v in input_args.pop('values', []): - button = ttk.Radiobutton( - self.input, value=v, text=v, **input_args) + button = input_class( + self.input, value=v, text=v, **input_args + ) button.pack(side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x') + self.input.error = getattr(button, 'error') + self.input.trigger_focusout_validation = button.trigger_focusout_validation else: self.input = input_class(self, **input_args) self.input.grid(row=1, column=0, sticky=(tk.W + tk.E)) @@ -438,7 +460,7 @@ def __init__(self, *args, **kwargs): # line 2 LabelInput( - r_info, "Lab", input_class=ttk.Radiobutton, + r_info, "Lab", input_class=ValidatedRadio, var=self._vars['Lab'], input_args={"values": ["A", "B", "C"]} ).grid(row=1, column=0) LabelInput( @@ -668,7 +690,7 @@ def on_save(self): # If it doesnt' exist, create it, # otherwise just append to the existing file datestring = datetime.today().strftime("%Y-%m-%d") - filename = "abq_data_record_{}.csv".format(datestring) + filename = f"abq_data_record_{datestring}.csv" newfile = not Path(filename).exists() data = self.recordform.get() @@ -681,7 +703,7 @@ def on_save(self): self._records_saved += 1 self.status.set( - "{} records saved this session".format(self._records_saved) + f"{self._records_saved} records saved this session" ) self.recordform.reset() From 970247b3b43f9553eab56b3f0eb415f9c9e9624d Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Fri, 9 Jul 2021 11:06:19 -0500 Subject: [PATCH 19/32] Add chapter 9 code --- Chapter09/ABQ_Data_Entry/.gitignore | 2 + Chapter09/ABQ_Data_Entry/README.rst | 43 ++ Chapter09/ABQ_Data_Entry/abq_data_entry.py | 4 + .../ABQ_Data_Entry/abq_data_entry/__init__.py | 0 .../abq_data_entry/application.py | 266 ++++++++++ .../abq_data_entry/constants.py | 12 + .../abq_data_entry/images/__init__.py | 20 + .../abq_data_entry/images/abq_logo-16x10.png | Bin 0 -> 1346 bytes .../abq_data_entry/images/abq_logo-32x20.png | Bin 0 -> 2637 bytes .../abq_data_entry/images/abq_logo-64x40.png | Bin 0 -> 5421 bytes .../abq_data_entry/images/browser-2x.png | Bin 0 -> 174 bytes .../abq_data_entry/images/file-2x.png | Bin 0 -> 167 bytes .../abq_data_entry/images/list-2x.png | Bin 0 -> 160 bytes .../images/question-mark-2x.xbm | 6 + .../abq_data_entry/images/reload-2x.png | Bin 0 -> 336 bytes .../abq_data_entry/images/x-2x.xbm | 6 + .../ABQ_Data_Entry/abq_data_entry/mainmenu.py | 166 ++++++ .../ABQ_Data_Entry/abq_data_entry/models.py | 170 ++++++ .../ABQ_Data_Entry/abq_data_entry/views.py | 492 ++++++++++++++++++ .../ABQ_Data_Entry/abq_data_entry/widgets.py | 433 +++++++++++++++ .../docs/Application_layout.png | Bin 0 -> 9117 bytes .../docs/abq_data_entry_spec.rst | 97 ++++ .../docs/lab-tech-paper-form.png | Bin 0 -> 22018 bytes Chapter09/font_demo.py | 33 ++ Chapter09/image_scope_demo.py | 12 + Chapter09/image_viewer_demo.py | 60 +++ Chapter09/monalisa.jpg | Bin 0 -> 56059 bytes Chapter09/named_font_demo.py | 23 + Chapter09/smile.gif | Bin 0 -> 201 bytes Chapter09/tags_demo.py | 24 + Chapter09/tk_default_font_example.py | 13 + Chapter09/tkinter_color_demo.py | 7 + Chapter09/ttk_combobox_info.py | 54 ++ 33 files changed, 1943 insertions(+) create mode 100644 Chapter09/ABQ_Data_Entry/.gitignore create mode 100644 Chapter09/ABQ_Data_Entry/README.rst create mode 100644 Chapter09/ABQ_Data_Entry/abq_data_entry.py create mode 100644 Chapter09/ABQ_Data_Entry/abq_data_entry/__init__.py create mode 100644 Chapter09/ABQ_Data_Entry/abq_data_entry/application.py create mode 100644 Chapter09/ABQ_Data_Entry/abq_data_entry/constants.py create mode 100644 Chapter09/ABQ_Data_Entry/abq_data_entry/images/__init__.py create mode 100644 Chapter09/ABQ_Data_Entry/abq_data_entry/images/abq_logo-16x10.png create mode 100644 Chapter09/ABQ_Data_Entry/abq_data_entry/images/abq_logo-32x20.png create mode 100644 Chapter09/ABQ_Data_Entry/abq_data_entry/images/abq_logo-64x40.png create mode 100644 Chapter09/ABQ_Data_Entry/abq_data_entry/images/browser-2x.png create mode 100644 Chapter09/ABQ_Data_Entry/abq_data_entry/images/file-2x.png create mode 100644 Chapter09/ABQ_Data_Entry/abq_data_entry/images/list-2x.png create mode 100644 Chapter09/ABQ_Data_Entry/abq_data_entry/images/question-mark-2x.xbm create mode 100644 Chapter09/ABQ_Data_Entry/abq_data_entry/images/reload-2x.png create mode 100644 Chapter09/ABQ_Data_Entry/abq_data_entry/images/x-2x.xbm create mode 100644 Chapter09/ABQ_Data_Entry/abq_data_entry/mainmenu.py create mode 100644 Chapter09/ABQ_Data_Entry/abq_data_entry/models.py create mode 100644 Chapter09/ABQ_Data_Entry/abq_data_entry/views.py create mode 100644 Chapter09/ABQ_Data_Entry/abq_data_entry/widgets.py create mode 100644 Chapter09/ABQ_Data_Entry/docs/Application_layout.png create mode 100644 Chapter09/ABQ_Data_Entry/docs/abq_data_entry_spec.rst create mode 100644 Chapter09/ABQ_Data_Entry/docs/lab-tech-paper-form.png create mode 100644 Chapter09/font_demo.py create mode 100644 Chapter09/image_scope_demo.py create mode 100644 Chapter09/image_viewer_demo.py create mode 100644 Chapter09/monalisa.jpg create mode 100644 Chapter09/named_font_demo.py create mode 100644 Chapter09/smile.gif create mode 100644 Chapter09/tags_demo.py create mode 100644 Chapter09/tk_default_font_example.py create mode 100644 Chapter09/tkinter_color_demo.py create mode 100644 Chapter09/ttk_combobox_info.py diff --git a/Chapter09/ABQ_Data_Entry/.gitignore b/Chapter09/ABQ_Data_Entry/.gitignore new file mode 100644 index 0000000..d646835 --- /dev/null +++ b/Chapter09/ABQ_Data_Entry/.gitignore @@ -0,0 +1,2 @@ +*.pyc +__pycache__/ diff --git a/Chapter09/ABQ_Data_Entry/README.rst b/Chapter09/ABQ_Data_Entry/README.rst new file mode 100644 index 0000000..5a47dd7 --- /dev/null +++ b/Chapter09/ABQ_Data_Entry/README.rst @@ -0,0 +1,43 @@ +============================ + ABQ Data Entry Application +============================ + +Description +=========== + +This program provides a data entry form for ABQ Agrilabs laboratory data. + +Features +-------- + + * Provides a validated entry form to ensure correct data + * Stores data to ABQ-format CSV files + * Auto-fills form fields whenever possible + +Authors +======= + +Alan D Moore, 2021 + +Requirements +============ + + * Python 3.7 or higher + * Tkinter + +Usage +===== + +To start the application, run:: + + python3 ABQ_Data_Entry/abq_data_entry.py + + +General Notes +============= + +The CSV file will be saved to your current directory in the format +``abq_data_record_CURRENTDATE.csv``, where CURRENTDATE is today's date in ISO format. + +This program only appends to the CSV file. You should have a spreadsheet program +installed in case you need to edit or check the file. diff --git a/Chapter09/ABQ_Data_Entry/abq_data_entry.py b/Chapter09/ABQ_Data_Entry/abq_data_entry.py new file mode 100644 index 0000000..a3b3a0d --- /dev/null +++ b/Chapter09/ABQ_Data_Entry/abq_data_entry.py @@ -0,0 +1,4 @@ +from abq_data_entry.application import Application + +app = Application() +app.mainloop() diff --git a/Chapter09/ABQ_Data_Entry/abq_data_entry/__init__.py b/Chapter09/ABQ_Data_Entry/abq_data_entry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Chapter09/ABQ_Data_Entry/abq_data_entry/application.py b/Chapter09/ABQ_Data_Entry/abq_data_entry/application.py new file mode 100644 index 0000000..0bdb0b7 --- /dev/null +++ b/Chapter09/ABQ_Data_Entry/abq_data_entry/application.py @@ -0,0 +1,266 @@ +"""The application/controller class for ABQ Data Entry""" + +import tkinter as tk +from tkinter import ttk +from tkinter import messagebox +from tkinter import filedialog +from tkinter import font + +from . import views as v +from . import models as m +from .mainmenu import MainMenu +from . import images + +class Application(tk.Tk): + """Application root window""" + + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Hide window while GUI is built + self.withdraw() + + # Authenticate + if not self._show_login(): + self.destroy() + return + + # show the window + self.deiconify() + + # Create model + self.model = m.CSVModel() + + # Load settings + # self.settings = { + # 'autofill date': tk.BooleanVar(), + # 'autofill sheet data': tk.BoleanVar() + # } + self.settings_model = m.SettingsModel() + self._load_settings() + + self.inserted_rows = [] + self.updated_rows = [] + + # Begin building GUI + self.title("ABQ Data Entry Application") + self.columnconfigure(0, weight=1) + + # Set taskbar icon + self.taskbar_icon = tk.PhotoImage(file=images.ABQ_LOGO_64) + self.iconphoto(True, self.taskbar_icon) + + # Create the menu + menu = MainMenu(self, self.settings) + self.config(menu=menu) + event_callbacks = { + '<>': self._on_file_select, + '<>': lambda _: self.quit(), + '<>': self._show_recordlist, + '<>': self._new_record, + + } + for sequence, callback in event_callbacks.items(): + self.bind(sequence, callback) + + # new for ch9 + self.logo = tk.PhotoImage(file=images.ABQ_LOGO_32) + ttk.Label( + self, + text="ABQ Data Entry Application", + font=("TkDefaultFont", 16), + image=self.logo, + compound=tk.LEFT + ).grid(row=0) + + # The notebook + self.notebook = ttk.Notebook(self) + self.notebook.enable_traversal() + self.notebook.grid(row=1, padx=10, sticky='NSEW') + + # The data record form + self.recordform_icon = tk.PhotoImage(file=images.FORM_ICON) + self.recordform = v.DataRecordForm(self, self.model, self.settings) + self.notebook.add( + self.recordform, text='Entry Form', + image=self.recordform_icon, compound=tk.LEFT + ) + self.recordform.bind('<>', self._on_save) + + + # The data record list + self.recordlist_icon = tk.PhotoImage(file=images.LIST_ICON) + self.recordlist = v.RecordList( + self, self.inserted_rows, self.updated_rows + ) + self.notebook.insert( + 0, self.recordlist, text='Records', + image=self.recordlist_icon, compound=tk.LEFT + ) + self._populate_recordlist() + self.recordlist.bind('<>', self._open_record) + + + self._show_recordlist() + + # status bar + self.status = tk.StringVar() + self.statusbar = ttk.Label(self, textvariable=self.status) + self.statusbar.grid(sticky=(tk.W + tk.E), row=3, padx=10) + + + self.records_saved = 0 + + + def _on_save(self, *_): + """Handles file-save requests""" + + # Check for errors first + + errors = self.recordform.get_errors() + if errors: + self.status.set( + "Cannot save, error in fields: {}" + .format(', '.join(errors.keys())) + ) + message = "Cannot save record" + detail = "The following fields have errors: \n * {}".format( + '\n * '.join(errors.keys()) + ) + messagebox.showerror( + title='Error', + message=message, + detail=detail + ) + return False + + data = self.recordform.get() + rownum = self.recordform.current_record + self.model.save_record(data, rownum) + if rownum is not None: + self.updated_rows.append(rownum) + else: + rownum = len(self.model.get_all_records()) -1 + self.inserted_rows.append(rownum) + self.records_saved += 1 + self.status.set( + "{} records saved this session".format(self.records_saved) + ) + self.recordform.reset() + self._populate_recordlist() + + def _on_file_select(self, *_): + """Handle the file->select action""" + + filename = filedialog.asksaveasfilename( + title='Select the target file for saving records', + defaultextension='.csv', + filetypes=[('CSV', '*.csv *.CSV')] + ) + if filename: + self.model = m.CSVModel(filename=filename) + self.inserted_rows.clear() + self.updated_rows.clear() + self._populate_recordlist() + + @staticmethod + def _simple_login(username, password): + """A basic authentication backend with a hardcoded user and password""" + return username == 'abq' and password == 'Flowers' + + def _show_login(self): + """Show login dialog and attempt to login""" + error = '' + title = "Login to ABQ Data Entry" + while True: + login = v.LoginDialog(self, title, error) + if not login.result: # User canceled + return False + username, password = login.result + if self._simple_login(username, password): + return True + error = 'Login Failed' # loop and redisplay + + def _load_settings(self): + """Load settings into our self.settings dict.""" + + vartypes = { + 'bool': tk.BooleanVar, + 'str': tk.StringVar, + 'int': tk.IntVar, + 'float': tk.DoubleVar + } + + # create our dict of settings variables from the model's settings. + self.settings = dict() + for key, data in self.settings_model.fields.items(): + vartype = vartypes.get(data['type'], tk.StringVar) + self.settings[key] = vartype(value=data['value']) + + # put a trace on the variables so they get stored when changed. + for var in self.settings.values(): + var.trace_add('write', self._save_settings) + + # update font settings after loading them + self._set_font() + self.settings['font size'].trace_add('write', self._set_font) + self.settings['font family'].trace_add('write', self._set_font) + + # process theme + style = ttk.Style() + theme = self.settings.get('theme').get() + if theme in style.theme_names(): + style.theme_use(theme) + + def _save_settings(self, *_): + """Save the current settings to a preferences file""" + + for key, variable in self.settings.items(): + self.settings_model.set(key, variable.get()) + self.settings_model.save() + + def _show_recordlist(self, *_): + """Show the recordform""" + self.notebook.select(self.recordlist) + + def _populate_recordlist(self): + try: + rows = self.model.get_all_records() + except Exception as e: + messagebox.showerror( + title='Error', + message='Problem reading file', + detail=str(e) + ) + else: + self.recordlist.populate(rows) + + def _new_record(self, *_): + """Open the record form with a blank record""" + self.recordform.load_record(None, None) + self.notebook.select(self.recordform) + + + def _open_record(self, *_): + """Open the Record selected recordlist id in the recordform""" + rowkey = self.recordlist.selected_id + try: + record = self.model.get_record(rowkey) + except Exception as e: + messagebox.showerror( + title='Error', message='Problem reading file', detail=str(e) + ) + return + self.recordform.load_record(rowkey, record) + self.notebook.select(self.recordform) + + # new chapter 9 + def _set_font(self, *_): + """Set the application's font""" + font_size = self.settings['font size'].get() + font_family = self.settings['font family'].get() + font_names = ('TkDefaultFont', 'TkMenuFont', 'TkTextFont') + for font_name in font_names: + tk_font = font.nametofont(font_name) + tk_font.config(size=font_size, family=font_family) diff --git a/Chapter09/ABQ_Data_Entry/abq_data_entry/constants.py b/Chapter09/ABQ_Data_Entry/abq_data_entry/constants.py new file mode 100644 index 0000000..e747dce --- /dev/null +++ b/Chapter09/ABQ_Data_Entry/abq_data_entry/constants.py @@ -0,0 +1,12 @@ +"""Global constants and classes needed by other modules in ABQ Data Entry""" +from enum import Enum, auto + +class FieldTypes(Enum): + string = auto() + string_list = auto() + short_string_list = auto() + iso_date_string = auto() + long_string = auto() + decimal = auto() + integer = auto() + boolean = auto() diff --git a/Chapter09/ABQ_Data_Entry/abq_data_entry/images/__init__.py b/Chapter09/ABQ_Data_Entry/abq_data_entry/images/__init__.py new file mode 100644 index 0000000..57538d9 --- /dev/null +++ b/Chapter09/ABQ_Data_Entry/abq_data_entry/images/__init__.py @@ -0,0 +1,20 @@ +from pathlib import Path + +# This gives us the parent directory of this file (__init__.py) +IMAGE_DIRECTORY = Path(__file__).parent + +ABQ_LOGO_16 = IMAGE_DIRECTORY / 'abq_logo-16x10.png' +ABQ_LOGO_32 = IMAGE_DIRECTORY / 'abq_logo-32x20.png' +ABQ_LOGO_64 = IMAGE_DIRECTORY / 'abq_logo-64x40.png' + +# PNG icons + +SAVE_ICON = IMAGE_DIRECTORY / 'file-2x.png' +RESET_ICON = IMAGE_DIRECTORY / 'reload-2x.png' +LIST_ICON = IMAGE_DIRECTORY / 'list-2x.png' +FORM_ICON = IMAGE_DIRECTORY / 'browser-2x.png' + + +# BMP icons +QUIT_BMP = IMAGE_DIRECTORY / 'x-2x.xbm' +ABOUT_BMP = IMAGE_DIRECTORY / 'question-mark-2x.xbm' diff --git a/Chapter09/ABQ_Data_Entry/abq_data_entry/images/abq_logo-16x10.png b/Chapter09/ABQ_Data_Entry/abq_data_entry/images/abq_logo-16x10.png new file mode 100644 index 0000000000000000000000000000000000000000..255f2739e10827885fe11f4fece3f7e42bf73064 GIT binary patch literal 1346 zcmV-I1-<%-P) zaB^>EX>4U6ba`-PAZ2)IW&i+q+O3sqlI%7JM*nLSS%LuZ&~k(xRoOw7pHJ?dnLCx6 zls-|pyCOH&W)W)-9L)_LG2>T9&;O3zC5{ZKz{zR61+Z#hFG zKWM&JxgRhd?ftx8tG=96+jeXj6%!Y(ycqFZfw?K}ryW-);pu+;{JuM~PltRXD<3cX z?FnN3F=Rw6^@nlJigWgB$DY+x91|8bZI%y)T#+w~0^JIBsAk;L*(mi04aiRMKB~FP>n>%s5-L~A&&t-1Cg^d zP7okfUI>y~5i!6CzP|B|)1%AEFELsMAXIMM1_%wnYE4l;-U2l=RJ5t86?F~mI!vsY znxU3&?+q7ku5Rug-hG5b3k?g8h#sSJ7qq5!>)xaH(#L?)0n-Ct4`_^$oRTdyEj=T9 zj*0S_ZR)h?GiIM-@sib+E?d50^|HpMjZ)fe>$dGXcHiTm){dNZ^q}cZoPNe9HF|g6 zw^_cMK9 zTUyDFvfH6;> z;~-~iW{-1^-pYHSk<)4AKw}QH$br5kDg*Fx4^(_zJxyt>Eg4`sM^@G#t*8sef1fXB zOYMt6ZbS!*k_>;?0AjQ*a*)zq{shn6wOaaUK zNlSf$=-Pryrn)FpkWy%ilEl9#)q*v3Dt34jBAQSPmiLmpd=4fmDD=$to^#K+M*y{8 ztiWGiXGa}1wUVR5B2scKgn%E(Bfz@{qd@^VdQWK zGQcgs1=ye_6672W-4nB2VCfZZg|Fiq%IO1jreqjN=0BQn1Jq;!5CD8P_xXKzKx=&i zVC(X1!%a480TP5H^(W2fo0rUvf1!69ge!!VvpCM55P1KB*2b2SAw|ilglAh!Cq6T` z7GvP`6QUm`$aZ^)1z`DSl=epvlBP-g@ms{;{qsyp1Vxw)@cdzfr@>wt*WrQh4t1F} z0D63PXiV;m4}np?aK zy}C`Te~OJ?i);c(b02{~MVHD?MZlP4+hN_U6)yxe5A!phm>e+hNBnGk-DO*?g5#Wz z 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 literal 0 HcmV?d00001 diff --git a/Chapter09/ABQ_Data_Entry/abq_data_entry/images/abq_logo-64x40.png b/Chapter09/ABQ_Data_Entry/abq_data_entry/images/abq_logo-64x40.png new file mode 100644 index 0000000000000000000000000000000000000000..be9458ff1799dcb20cdab36eb100511f1b213606 GIT binary patch literal 5421 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 z zqn~~&0hgPPx9?`d^Dpxszx;%WRdC6mPGj$W8rA|+a3PD*Pyjc%HSQRD8x6o^<#5ZY z#mu~FDw^T$T@gZn=4SERt1)bilx9N`W{jE8Z$o{KL|vC4!_fPrbRyCm;jY2oB|Qk- z0T|{&*&~UB}N}KMDmVXh{yI8mZr(VYC2R z3ZSsm&27f3utX@o$mHPD>K}x+QTEVK$()N^SV?O(NSIUm9VQIB2&Cwl7WV-GPh_~T z+4|mizA^ng0NT@Wf{$JW#p1=hKW9Bew`Vut6^-_kExX6??{7Q9Wivm{G2G`2@VUKezzoBZOrLDj?le%>g0IL6GLfP4!pVeE zTA72^LGO2|6c!+eho~>9K6C(<2U1BzZ^6{c3vmYvC^HJtg-+5=aojx32d57~7zhEy z@W|Fx-1qy{q!w&OmwhjEc@>}uH#{>JN(HoQ*h$Ij?_A8p32I7sbOVIIrD+(tjsy$~ zrUtHNj9W4Msg+<)Yzq{Gc=pLd%zyB+-1zHX8P3hO)gYc8ul8_&Xj>Bbcs3RGGDm!k zXrbaV*#ygXF5tq+Bd63V!u{0NaJ@hwJNXz2G8(u=15(?qK;l{l5XH@}z4G_Ti%8|;g1nfF-0JOdryK_zf z0J`SSxmGydOupjfoK!$_>{PDg9~rzLZ(4lrDL~WN{K+o0B-$`x$f&$Y(Yd1lAwVx+ z^TB#3W99f@%K z)EREr`h-fL{6Gvg-}NMhaOJHi&$sY$1cnR0NGgY<#}_<*?xo z(CTf=U>O$(Q0_;BzbWmjSI?Ai#`M8RFqIwejdJ109*xNCuu!(hKH}em!{(~kj_M`Ndi01WuaY6#q}C@ogK zSj`XT4gc~=0MI>PB{;nIQzES~T3g#Wee49WwszW^S||tv@D&9qDm#xs=a;c}=N{hp z=U4C#b1|r@if7+kjnH-FE?+|UmH*bj-S_^H&co66HSpBknNe$js}F&Bp?bG?;Qk8! zW!X4fZa&}l2Ld5L>%O%lRWP${&^`S67aRE5txr*2?dG#jO}0edrXb)S`2W%bsU$qI zXfG?C3F9~(KL~G)h5BqXa0?hK;f8%+)~c-+ei-*%NS6TRKp}tK*W_A(FzC&&|G(g~XJ9*hU6cEN XhPBd*{i(!N00000NkvXXu0mjfWYLOi literal 0 HcmV?d00001 diff --git a/Chapter09/ABQ_Data_Entry/abq_data_entry/images/browser-2x.png b/Chapter09/ABQ_Data_Entry/abq_data_entry/images/browser-2x.png new file mode 100644 index 0000000000000000000000000000000000000000..2a6efb0d3ad404d3e6df2c4f7e2c9441945b9377 GIT binary patch literal 174 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`EX7WqAsj$Z!;#Vf4nJ za0`PlBg3pY50TY@YDj113tz;u?-H7A2U5ODE0AZ z@(JiUbDVWXB2U-ehVx>J7`TOIPjuP)f90d5KxVdP#t_fW4nJ za0`PlBg3pY54nJ za0`PlBg3pY5<39QJlxHc{CLc#vuuf5Bd*}mNt{%q1HX$~}v!PC{xWt~$(695?x BFjxQp literal 0 HcmV?d00001 diff --git a/Chapter09/ABQ_Data_Entry/abq_data_entry/images/question-mark-2x.xbm b/Chapter09/ABQ_Data_Entry/abq_data_entry/images/question-mark-2x.xbm new file mode 100644 index 0000000..8981c9b --- /dev/null +++ b/Chapter09/ABQ_Data_Entry/abq_data_entry/images/question-mark-2x.xbm @@ -0,0 +1,6 @@ +#define question_mark_2x_width 16 +#define question_mark_2x_height 16 +static unsigned char question_mark_2x_bits[] = { + 0xc0, 0x0f, 0xe0, 0x1f, 0x70, 0x38, 0x30, 0x30, 0x00, 0x30, 0x00, 0x30, + 0x00, 0x18, 0x00, 0x1c, 0x00, 0x0e, 0x00, 0x07, 0x00, 0x03, 0x00, 0x03, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x03 }; diff --git a/Chapter09/ABQ_Data_Entry/abq_data_entry/images/reload-2x.png b/Chapter09/ABQ_Data_Entry/abq_data_entry/images/reload-2x.png new file mode 100644 index 0000000000000000000000000000000000000000..2a79f1665d1156d2e140a10f7902bc69f8bb26bb GIT binary patch literal 336 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`EX7WqAsj$Z!;#Vf4nJ za0`PlBg3pY5>Y*U9P$-MRL!(@9^X8fhq)(E zFJr#NVI#l5GfnbJnH`@4HZ}2mE9hQ-qOz;b%b=q*ea-b7vyRl=n5ChmHt%@F;V9;a z6Z5u)Owe0%kz&bqH;`{=)pW(4FHy7Od@(eJR=g%=_#4 zi__yCxo)1S*Z0cIYSL7p_d6W?wLbFf-tj58wY^j&QX})1kLtS(w^GujuU`_^dvqmc e)r~mQpVIIC_*-03aXkd|J%gvKpUXO@geCw(qlWDO literal 0 HcmV?d00001 diff --git a/Chapter09/ABQ_Data_Entry/abq_data_entry/images/x-2x.xbm b/Chapter09/ABQ_Data_Entry/abq_data_entry/images/x-2x.xbm new file mode 100644 index 0000000..940af96 --- /dev/null +++ b/Chapter09/ABQ_Data_Entry/abq_data_entry/images/x-2x.xbm @@ -0,0 +1,6 @@ +#define x_2x_width 16 +#define x_2x_height 16 +static unsigned char x_2x_bits[] = { + 0x04, 0x10, 0x0e, 0x38, 0x1f, 0x7c, 0x3e, 0x7e, 0x7c, 0x3f, 0xf8, 0x1f, + 0xf0, 0x0f, 0xe0, 0x07, 0xf0, 0x07, 0xf8, 0x0f, 0xfc, 0x1f, 0x7e, 0x3e, + 0x3f, 0x7c, 0x1e, 0x78, 0x0c, 0x30, 0x00, 0x00 }; diff --git a/Chapter09/ABQ_Data_Entry/abq_data_entry/mainmenu.py b/Chapter09/ABQ_Data_Entry/abq_data_entry/mainmenu.py new file mode 100644 index 0000000..b6ff55f --- /dev/null +++ b/Chapter09/ABQ_Data_Entry/abq_data_entry/mainmenu.py @@ -0,0 +1,166 @@ +"""The Main Menu class for ABQ Data Entry""" + +import tkinter as tk +from tkinter import ttk +from tkinter import messagebox +from tkinter import font + +from . import images + +class MainMenu(tk.Menu): + """The Application's main menu""" + + def _event(self, sequence): + """Return a callback function that generates the sequence""" + def callback(*_): + root = self.master.winfo_toplevel() + root.event_generate(sequence) + + return callback + + def _create_icons(self): + + # must be done in a method because PhotoImage can't be created + # until there is a Tk instance. + # There isn't one when the class is defined, but there is when + # the instance is created. + self.icons = { + 'file_open': tk.PhotoImage(file=images.SAVE_ICON), + 'record_list': tk.PhotoImage(file=images.LIST_ICON), + 'new_record': tk.PhotoImage(file=images.FORM_ICON), + 'quit': tk.BitmapImage(file=images.QUIT_BMP, foreground='red'), + 'about': tk.BitmapImage( + file=images.ABOUT_BMP, foreground='#CC0', background='#A09' + ), + } + + def __init__(self, parent, settings, **kwargs): + """Constructor for MainMenu + + arguments: + parent - The parent widget + settings - a dict containing Tkinter variables + """ + super().__init__(parent, **kwargs) + self.settings = settings + self._create_icons() + + # Styles + self.styles = { + 'background': '#333', + 'foreground': 'white', + 'activebackground': '#777', + 'activeforeground': 'white', + 'relief': tk.GROOVE + } + self.configure(**self.styles) + + # The help menu + help_menu = tk.Menu(self, tearoff=False, **self.styles) + help_menu.add_command( + label='About…', + command=self.show_about, + image=self.icons['about'], + compound=tk.LEFT + ) + + # The file menu + file_menu = tk.Menu(self, tearoff=False, **self.styles) + file_menu.add_command( + label="Select file…", + command=self._event('<>'), + image=self.icons['file_open'], + compound=tk.LEFT + ) + + file_menu.add_separator() + file_menu.add_command( + label="Quit", + command=self._event('<>'), + image=self.icons['quit'], + compound=tk.LEFT + ) + + # The options menu + options_menu = tk.Menu(self, tearoff=False, **self.styles) + options_menu.add_checkbutton( + label='Autofill Date', + variable=self.settings['autofill date'] + ) + options_menu.add_checkbutton( + label='Autofill Sheet data', + variable=self.settings['autofill sheet data'] + ) + + size_menu = tk.Menu(options_menu, tearoff=False, **self.styles) + options_menu.add_cascade(label='Font Size', menu=size_menu) + for size in range(6, 17, 1): + size_menu.add_radiobutton( + label=size, value=size, + variable=self.settings['font size'] + ) + family_menu = tk.Menu(options_menu, tearoff=False, **self.styles) + options_menu.add_cascade(label='Font Family', menu=family_menu) + for family in font.families(): + family_menu.add_radiobutton( + label=family, value=family, + variable=self.settings['font family'] + ) + + style = ttk.Style() + themes_menu = tk.Menu(self, tearoff=False, **self.styles) + for theme in style.theme_names(): + themes_menu.add_radiobutton( + label=theme, value=theme, + variable=self.settings['theme'] + ) + options_menu.add_cascade(label='Theme', menu=themes_menu) + self.settings['theme'].trace_add('write', self._on_theme_change) + + + # switch from recordlist to recordform + go_menu = tk.Menu(self, tearoff=False, **self.styles) + go_menu.add_command( + label="Record List", + command=self._event('<>'), + image=self.icons['record_list'], + compound=tk.LEFT + ) + go_menu.add_command( + label="New Record", + command=self._event('<>'), + image=self.icons['new_record'], + compound=tk.LEFT + ) + + # add the menus in order to the main menu + self.add_cascade(label='File', menu=file_menu) + self.add_cascade(label='Go', menu=go_menu) + self.add_cascade(label='Options', menu=options_menu) + self.add_cascade(label='Help', menu=help_menu) + + def show_about(self): + """Show the about dialog""" + + about_message = 'ABQ Data Entry' + about_detail = ( + 'by Alan D Moore\n' + 'For assistance please contact the author.' + ) + + messagebox.showinfo( + title='About', message=about_message, detail=about_detail + ) + @staticmethod + def _on_theme_change(*_): + """Popup a message about theme changes""" + message = "Change requires restart" + detail = ( + "Theme changes do not take effect" + " until application restart" + ) + messagebox.showwarning( + title='Warning', + message=message, + detail=detail + ) diff --git a/Chapter09/ABQ_Data_Entry/abq_data_entry/models.py b/Chapter09/ABQ_Data_Entry/abq_data_entry/models.py new file mode 100644 index 0000000..099d79c --- /dev/null +++ b/Chapter09/ABQ_Data_Entry/abq_data_entry/models.py @@ -0,0 +1,170 @@ +import csv +from pathlib import Path +import os +import json + +from .constants import FieldTypes as FT +from decimal import Decimal +from datetime import datetime + +class CSVModel: + """CSV file storage""" + + fields = { + "Date": {'req': True, 'type': FT.iso_date_string}, + "Time": {'req': True, 'type': FT.string_list, + 'values': ['8:00', '12:00', '16:00', '20:00']}, + "Technician": {'req': True, 'type': FT.string}, + "Lab": {'req': True, 'type': FT.short_string_list, + 'values': ['A', 'B', 'C']}, + "Plot": {'req': True, 'type': FT.string_list, + 'values': [str(x) for x in range(1, 21)]}, + "Seed Sample": {'req': True, 'type': FT.string}, + "Humidity": {'req': True, 'type': FT.decimal, + 'min': 0.5, 'max': 52.0, 'inc': .01}, + "Light": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 100.0, 'inc': .01}, + "Temperature": {'req': True, 'type': FT.decimal, + 'min': 4, 'max': 40, 'inc': .01}, + "Equipment Fault": {'req': False, 'type': FT.boolean}, + "Plants": {'req': True, 'type': FT.integer, 'min': 0, 'max': 20}, + "Blossoms": {'req': True, 'type': FT.integer, 'min': 0, 'max': 1000}, + "Fruit": {'req': True, 'type': FT.integer, 'min': 0, 'max': 1000}, + "Min Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Max Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Med Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Notes": {'req': False, 'type': FT.long_string} + } + + + def __init__(self, filename=None): + + if not filename: + datestring = datetime.today().strftime("%Y-%m-%d") + filename = "abq_data_record_{}.csv".format(datestring) + self.file = Path(filename) + + # Check for append permissions: + file_exists = os.access(self.file, os.F_OK) + parent_writeable = os.access(self.file.parent, os.W_OK) + file_writeable = os.access(self.file, os.W_OK) + if ( + (not file_exists and not parent_writeable) or + (file_exists and not file_writeable) + ): + msg = f'Permission denied accessing file: {filename}' + raise PermissionError(msg) + + + def save_record(self, data, rownum=None): + """Save a dict of data to the CSV file""" + + if rownum is None: + # This is a new record + newfile = not self.file.exists() + + with open(self.file, 'a', newline='') as fh: + csvwriter = csv.DictWriter(fh, fieldnames=self.fields.keys()) + if newfile: + csvwriter.writeheader() + csvwriter.writerow(data) + else: + # This is an update + records = self.get_all_records() + records[rownum] = data + with open(self.file, 'w', encoding='utf-8', newline='') as fh: + csvwriter = csv.DictWriter(fh, fieldnames=self.fields.keys()) + csvwriter.writeheader() + csvwriter.writerows(records) + + def get_all_records(self): + """Read in all records from the CSV and return a list""" + if not self.file.exists(): + return [] + + with open(self.file, 'r', encoding='utf-8') as fh: + csvreader = csv.DictReader(fh.readlines()) + missing_fields = set(self.fields.keys()) - set(csvreader.fieldnames) + if len(missing_fields) > 0: + fields_string = ', '.join(missing_fields) + raise Exception( + f"File is missing fields: {fields_string}" + ) + records = list(csvreader) + + # Correct issue with boolean fields + trues = ('true', 'yes', '1') + bool_fields = [ + key for key, meta + in self.fields.items() + if meta['type'] == FT.boolean + ] + for record in records: + for key in bool_fields: + record[key] = record[key].lower() in trues + return records + + def get_record(self, rownum): + """Get a single record by row number + + Callling code should catch IndexError + in case of a bad rownum. + """ + + return self.get_all_records()[rownum] + + +class SettingsModel: + """A model for saving settings""" + + fields = { + 'autofill date': {'type': 'bool', 'value': True}, + 'autofill sheet data': {'type': 'bool', 'value': True}, + 'font size': {'type': 'int', 'value': 9}, + 'font family': {'type': 'str', 'value': ''}, + 'theme': {'type': 'str', 'value': 'default'} + } + + def __init__(self): + # determine the file path + filename = 'abq_settings.json' + self.filepath = Path.home() / filename + + # load in saved values + self.load() + + def set(self, key, value): + """Set a variable value""" + if ( + key in self.fields and + type(value).__name__ == self.fields[key]['type'] + ): + self.fields[key]['value'] = value + else: + raise ValueError("Bad key or wrong variable type") + + def save(self): + """Save the current settings to the file""" + json_string = json.dumps(self.fields) + with open(self.filepath, 'w') as fh: + fh.write(json_string) + + def load(self): + """Load the settings from the file""" + + # if the file doesn't exist, return + if not self.filepath.exists(): + return + + # open the file and read in the raw values + with open(self.filepath, 'r') as fh: + raw_values = json.loads(fh.read()) + + # don't implicitly trust the raw values, but only get known keys + for key in self.fields: + if key in raw_values and 'value' in raw_values[key]: + raw_value = raw_values[key]['value'] + self.fields[key]['value'] = raw_value diff --git a/Chapter09/ABQ_Data_Entry/abq_data_entry/views.py b/Chapter09/ABQ_Data_Entry/abq_data_entry/views.py new file mode 100644 index 0000000..24b0c40 --- /dev/null +++ b/Chapter09/ABQ_Data_Entry/abq_data_entry/views.py @@ -0,0 +1,492 @@ +import tkinter as tk +from tkinter import ttk +from tkinter.simpledialog import Dialog +from datetime import datetime +from . import widgets as w +from .constants import FieldTypes as FT +from . import images + +class DataRecordForm(tk.Frame): + """The input form for our widgets""" + + var_types = { + FT.string: tk.StringVar, + FT.string_list: tk.StringVar, + FT.short_string_list: tk.StringVar, + FT.iso_date_string: tk.StringVar, + FT.long_string: tk.StringVar, + FT.decimal: tk.DoubleVar, + FT.integer: tk.IntVar, + FT.boolean: tk.BooleanVar + } + + def _add_frame(self, label, style='', cols=3): + """Add a labelframe to the form""" + + frame = ttk.LabelFrame(self, text=label) + if style: + frame.configure(style=style) + frame.grid(sticky=tk.W + tk.E) + for i in range(cols): + frame.columnconfigure(i, weight=1) + return frame + + def __init__(self, parent, model, settings, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + + self.model= model + self.settings = settings + fields = self.model.fields + + # new for ch9 + style = ttk.Style() + + # Frame styles + style.configure( + 'RecordInfo.TLabelframe', + background='khaki', padx=10, pady=10 + ) + style.configure( + 'EnvironmentInfo.TLabelframe', background='lightblue', + padx=10, pady=10 + ) + style.configure( + 'PlantInfo.TLabelframe', + background='lightgreen', padx=10, pady=10 + ) + # Style the label Element as well + style.configure( + 'RecordInfo.TLabelframe.Label', background='khaki', + padx=10, pady=10 + ) + style.configure( + 'EnvironmentInfo.TLabelframe.Label', + background='lightblue', padx=10, pady=10 + ) + style.configure( + 'PlantInfo.TLabelframe.Label', + background='lightgreen', padx=10, pady=10 + ) + + # Style for the form labels and buttons + style.configure('RecordInfo.TLabel', background='khaki') + style.configure('RecordInfo.TRadiobutton', background='khaki') + style.configure('EnvironmentInfo.TLabel', background='lightblue') + style.configure( + 'EnvironmentInfo.TCheckbutton', + background='lightblue' + ) + style.configure('PlantInfo.TLabel', background='lightgreen') + + + # Create a dict to keep track of input widgets + self._vars = { + key: self.var_types[spec['type']]() + for key, spec in fields.items() + } + + # Build the form + self.columnconfigure(0, weight=1) + + # new chapter 8 + # variable to track current record id + self.current_record = None + + # Label for displaying what record we're editing + self.record_label = ttk.Label(self) + self.record_label.grid(row=0, column=0) + + # Record info section + r_info = self._add_frame( + "Record Information", 'RecordInfo.TLabelframe' + ) + + # line 1 + w.LabelInput( + r_info, "Date", + field_spec=fields['Date'], + var=self._vars['Date'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=0, column=0) + w.LabelInput( + r_info, "Time", + field_spec=fields['Time'], + var=self._vars['Time'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=0, column=1) + w.LabelInput( + r_info, "Technician", + field_spec=fields['Technician'], + var=self._vars['Technician'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=0, column=2) + # line 2 + w.LabelInput( + r_info, "Lab", + field_spec=fields['Lab'], + var=self._vars['Lab'], + label_args={'style': 'RecordInfo.TLabel'}, + input_args={'style': 'RecordInfo.TRadiobutton'} + ).grid(row=1, column=0) + w.LabelInput( + r_info, "Plot", + field_spec=fields['Plot'], + var=self._vars['Plot'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=1, column=0) + w.LabelInput( + r_info, "Seed Sample", + field_spec=fields['Seed Sample'], + var=self._vars['Seed Sample'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=1, column=2) + + + # Environment Data + e_info = self._add_frame( + "Environment Data", 'EnvironmentInfo.TLabelframe' + ) + + e_info = ttk.LabelFrame( + self, + text="Environment Data", + style='EnvironmentInfo.TLabelframe' + ) + e_info.grid(row=2, column=0, sticky="we") + w.LabelInput( + e_info, "Humidity (g/m³)", + field_spec=fields['Humidity'], + var=self._vars['Humidity'], + disable_var=self._vars['Equipment Fault'], + label_args={'style': 'EnvironmentInfo.TLabel'} + ).grid(row=0, column=0) + w.LabelInput( + e_info, "Light (klx)", + field_spec=fields['Light'], + var=self._vars['Light'], + disable_var=self._vars['Equipment Fault'], + label_args={'style': 'EnvironmentInfo.TLabel'} + ).grid(row=0, column=1) + w.LabelInput( + e_info, "Temperature (°C)", + field_spec=fields['Temperature'], + disable_var=self._vars['Equipment Fault'], + var=self._vars['Temperature'], + label_args={'style': 'EnvironmentInfo.TLabel'} + ).grid(row=0, column=2) + w.LabelInput( + e_info, "Equipment Fault", + field_spec=fields['Equipment Fault'], + var=self._vars['Equipment Fault'], + label_args={'style': 'EnvironmentInfo.TLabel'}, + input_args={'style': 'EnvironmentInfo.TCheckbutton'} + ).grid(row=1, column=0, columnspan=3) + + # Plant Data section + p_info = self._add_frame("Plant Data", 'PlantInfo.TLabelframe') + + w.LabelInput( + p_info, "Plants", + field_spec=fields['Plants'], + var=self._vars['Plants'], + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=0, column=0) + w.LabelInput( + p_info, "Blossoms", + field_spec=fields['Blossoms'], + var=self._vars['Blossoms'], + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=0, column=1) + w.LabelInput( + p_info, "Fruit", + field_spec=fields['Fruit'], + var=self._vars['Fruit'], + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=0, column=2) + # Height data + # create variables to be updated for min/max height + # they can be referenced for min/max variables + min_height_var = tk.DoubleVar(value='-infinity') + max_height_var = tk.DoubleVar(value='infinity') + + w.LabelInput( + p_info, "Min Height (cm)", + field_spec=fields['Min Height'], + var=self._vars['Min Height'], + input_args={"max_var": max_height_var, + "focus_update_var": min_height_var}, + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=1, column=0) + w.LabelInput( + p_info, "Max Height (cm)", + field_spec=fields['Max Height'], + var=self._vars['Max Height'], + input_args={"min_var": min_height_var, + "focus_update_var": max_height_var}, + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=1, column=1) + w.LabelInput( + p_info, "Median Height (cm)", + field_spec=fields['Med Height'], + var=self._vars['Med Height'], + input_args={"min_var": min_height_var, + "max_var": max_height_var}, + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=1, column=2) + + + # Notes section -- Update grid row value for ch8 + w.LabelInput( + self, "Notes", field_spec=fields['Notes'], + var=self._vars['Notes'], input_args={"width": 85, "height": 10} + ).grid(sticky="nsew", row=4, column=0, padx=10, pady=10) + + # buttons + buttons = tk.Frame(self) + buttons.grid(sticky=tk.W + tk.E, row=5) + self.save_button_logo = tk.PhotoImage(file=images.SAVE_ICON) + self.savebutton = ttk.Button( + buttons, text="Save", command=self._on_save, + image=self.save_button_logo, compound=tk.LEFT + ) + self.savebutton.pack(side=tk.RIGHT) + + self.reset_button_logo = tk.PhotoImage(file=images.RESET_ICON) + self.resetbutton = ttk.Button( + buttons, text="Reset", command=self.reset, + image=self.reset_button_logo, compound=tk.LEFT + ) + self.resetbutton.pack(side=tk.RIGHT) + + # default the form + self.reset() + + def _on_save(self): + self.event_generate('<>') + + @staticmethod + def tclerror_is_blank_value(exception): + blank_value_errors = ( + 'expected integer but got ""', + 'expected floating-point number but got ""', + 'expected boolean value but got ""' + ) + is_bve = str(exception).strip() in blank_value_errors + return is_bve + + def get(self): + """Retrieve data from form as a dict""" + + # We need to retrieve the data from Tkinter variables + # and place it in regular Python objects + data = dict() + for key, var in self._vars.items(): + try: + data[key] = var.get() + except tk.TclError as e: + if self.tclerror_is_blank_value(e): + data[key] = None + else: + raise e + return data + + def reset(self): + """Resets the form entries""" + + lab = self._vars['Lab'].get() + time = self._vars['Time'].get() + technician = self._vars['Technician'].get() + try: + plot = self._vars['Plot'].get() + except tk.TclError: + plot = '' + plot_values = self._vars['Plot'].label_widget.input.cget('values') + + # clear all values + for var in self._vars.values(): + if isinstance(var, tk.BooleanVar): + var.set(False) + else: + var.set('') + + # Autofill Date + if self.settings['autofill date'].get(): + current_date = datetime.today().strftime('%Y-%m-%d') + self._vars['Date'].set(current_date) + self._vars['Time'].label_widget.input.focus() + + # check if we need to put our values back, then do it. + if ( + self.settings['autofill sheet data'].get() and + plot not in ('', 0, plot_values[-1]) + ): + self._vars['Lab'].set(lab) + self._vars['Time'].set(time) + self._vars['Technician'].set(technician) + next_plot_index = plot_values.index(plot) + 1 + self._vars['Plot'].set(plot_values[next_plot_index]) + self._vars['Seed Sample'].label_widget.input.focus() + + def get_errors(self): + """Get a list of field errors in the form""" + + errors = dict() + for key, var in self._vars.items(): + inp = var.label_widget.input + error = var.label_widget.error + + if hasattr(inp, 'trigger_focusout_validation'): + inp.trigger_focusout_validation() + if error.get(): + errors[key] = error.get() + + return errors + + # new for ch8 + def load_record(self, rownum, data=None): + self.current_record = rownum + if rownum is None: + self.reset() + self.record_label.config(text='New Record') + else: + self.record_label.config(text=f'Record #{rownum}') + for key, var in self._vars.items(): + var.set(data.get(key, '')) + try: + var.label_widget.input.trigger_focusout_validation() + except AttributeError: + pass + + +class LoginDialog(Dialog): + """A dialog that asks for username and password""" + + def __init__(self, parent, title, error=''): + + self._pw = tk.StringVar() + self._user = tk.StringVar() + self._error = tk.StringVar(value=error) + super().__init__(parent, title=title) + + def body(self, frame): + """Construct the interface and return the widget for initial focus + + Overridden from Dialog + """ + ttk.Label(frame, text='Login to ABQ').grid(row=0) + + if self._error.get(): + ttk.Label(frame, textvariable=self._error).grid(row=1) + user_inp = w.LabelInput( + frame, 'User name:', input_class=w.RequiredEntry, + var=self._user + ) + user_inp.grid() + w.LabelInput( + frame, 'Password:', input_class=w.RequiredEntry, + input_args={'show': '*'}, var=self._pw + ).grid() + return user_inp.input + + def buttonbox(self): + box = ttk.Frame(self) + ttk.Button( + box, text="Login", command=self.ok, default=tk.ACTIVE + ).grid(padx=5, pady=5) + ttk.Button( + box, text="Cancel", command=self.cancel + ).grid(row=0, column=1, padx=5, pady=5) + self.bind("", self.ok) + self.bind("", self.cancel) + box.pack() + + + def apply(self): + self.result = (self._user.get(), self._pw.get()) + + +class RecordList(tk.Frame): + """Display for CSV file contents""" + + column_defs = { + '#0': {'label': 'Row', 'anchor': tk.W}, + 'Date': {'label': 'Date', 'width': 150, 'stretch': True}, + 'Time': {'label': 'Time'}, + 'Lab': {'label': 'Lab', 'width': 40}, + 'Plot': {'label': 'Plot', 'width': 80} + } + default_width = 100 + default_minwidth = 10 + default_anchor = tk.CENTER + + def __init__(self, parent, inserted, updated, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + self.inserted = inserted + self.updated = updated + self.columnconfigure(0, weight=1) + self.rowconfigure(0, weight=1) + + # create treeview + self.treeview = ttk.Treeview( + self, + columns=list(self.column_defs.keys())[1:], + selectmode='browse' + ) + self.treeview.grid(row=0, column=0, sticky='NSEW') + + # Configure treeview columns + for name, definition in self.column_defs.items(): + label = definition.get('label', '') + anchor = definition.get('anchor', self.default_anchor) + minwidth = definition.get('minwidth', self.default_minwidth) + width = definition.get('width', self.default_width) + stretch = definition.get('stretch', False) + self.treeview.heading(name, text=label, anchor=anchor) + self.treeview.column( + name, anchor=anchor, minwidth=minwidth, + width=width, stretch=stretch + ) + + self.treeview.bind('', self._on_open_record) + self.treeview.bind('', self._on_open_record) + + # configure scrollbar for the treeview + self.scrollbar = ttk.Scrollbar( + self, + orient=tk.VERTICAL, + command=self.treeview.yview + ) + self.treeview.configure(yscrollcommand=self.scrollbar.set) + self.scrollbar.grid(row=0, column=1, sticky='NSW') + + # configure tagging + self.treeview.tag_configure('inserted', background='lightgreen') + self.treeview.tag_configure('updated', background='lightblue') + + def populate(self, rows): + """Clear the treeview and write the supplied data rows to it.""" + for row in self.treeview.get_children(): + self.treeview.delete(row) + + cids = list(self.column_defs.keys())[1:] + for rownum, rowdata in enumerate(rows): + values = [rowdata[cid] for cid in cids] + if rownum in self.inserted: + tag = 'inserted' + elif rownum in self.updated: + tag = 'updated' + else: + tag = '' + self.treeview.insert( + '', 'end', iid=str(rownum), + text=str(rownum), values=values, tag=tag) + + if len(rows) > 0: + self.treeview.focus_set() + self.treeview.selection_set('0') + self.treeview.focus('0') + + def _on_open_record(self, *args): + + self.selected_id = int(self.treeview.selection()[0]) + self.event_generate('<>') diff --git a/Chapter09/ABQ_Data_Entry/abq_data_entry/widgets.py b/Chapter09/ABQ_Data_Entry/abq_data_entry/widgets.py new file mode 100644 index 0000000..1a6413e --- /dev/null +++ b/Chapter09/ABQ_Data_Entry/abq_data_entry/widgets.py @@ -0,0 +1,433 @@ +import tkinter as tk +from tkinter import ttk +from datetime import datetime +from decimal import Decimal, InvalidOperation +from .constants import FieldTypes as FT + + +################## +# Widget Classes # +################## + +class ValidatedMixin: + """Adds a validation functionality to an input widget""" + + def __init__(self, *args, error_var=None, **kwargs): + self.error = error_var or tk.StringVar() + super().__init__(*args, **kwargs) + + vcmd = self.register(self._validate) + invcmd = self.register(self._invalid) + + style = ttk.Style() + widget_class = self.winfo_class() + validated_style = 'ValidatedInput.' + widget_class + style.map( + validated_style, + foreground=[('invalid', 'white'), ('!invalid', 'black')], + fieldbackground=[('invalid', 'darkred'), ('!invalid', 'white')] + ) + self.configure(style=validated_style) + + self.configure( + validate='all', + validatecommand=(vcmd, '%P', '%s', '%S', '%V', '%i', '%d'), + invalidcommand=(invcmd, '%P', '%s', '%S', '%V', '%i', '%d') + ) + + def _toggle_error(self, on=False): + self.configure(foreground=('red' if on else 'black')) + + def _validate(self, proposed, current, char, event, index, action): + """The validation method. + + Don't override this, override _key_validate, and _focus_validate + """ + self.error.set('') + + valid = True + # if the widget is disabled, don't validate + state = str(self.configure('state')[-1]) + if state == tk.DISABLED: + return valid + + if event == 'focusout': + valid = self._focusout_validate(event=event) + elif event == 'key': + valid = self._key_validate( + proposed=proposed, + current=current, + char=char, + event=event, + index=index, + action=action + ) + return valid + + def _focusout_validate(self, **kwargs): + return True + + def _key_validate(self, **kwargs): + return True + + def _invalid(self, proposed, current, char, event, index, action): + if event == 'focusout': + self._focusout_invalid(event=event) + elif event == 'key': + self._key_invalid( + proposed=proposed, + current=current, + char=char, + event=event, + index=index, + action=action + ) + + def _focusout_invalid(self, **kwargs): + """Handle invalid data on a focus event""" + pass + + def _key_invalid(self, **kwargs): + """Handle invalid data on a key event. By default we want to do nothing""" + pass + + def trigger_focusout_validation(self): + valid = self._validate('', '', '', 'focusout', '', '') + if not valid: + self._focusout_invalid(event='focusout') + return valid + + +class DateEntry(ValidatedMixin, ttk.Entry): + + def _key_validate(self, action, index, char, **kwargs): + valid = True + + if action == '0': # This is a delete action + valid = True + elif index in ('0', '1', '2', '3', '5', '6', '8', '9'): + valid = char.isdigit() + elif index in ('4', '7'): + valid = char == '-' + else: + valid = False + return valid + + def _focusout_validate(self, event): + valid = True + if not self.get(): + self.error.set('A value is required') + valid = False + try: + datetime.strptime(self.get(), '%Y-%m-%d') + except ValueError: + self.error.set('Invalid date') + valid = False + return valid + + +class RequiredEntry(ValidatedMixin, ttk.Entry): + + def _focusout_validate(self, event): + valid = True + if not self.get(): + valid = False + self.error.set('A value is required') + return valid + + +class ValidatedCombobox(ValidatedMixin, ttk.Combobox): + + def _key_validate(self, proposed, action, **kwargs): + valid = True + # if the user tries to delete, + # just clear the field + if action == '0': + self.set('') + return True + + # get our values list + values = self.cget('values') + # Do a case-insensitve match against the entered text + matching = [ + x for x in values + if x.lower().startswith(proposed.lower()) + ] + if len(matching) == 0: + valid = False + elif len(matching) == 1: + self.set(matching[0]) + self.icursor(tk.END) + valid = False + return valid + + def _focusout_validate(self, **kwargs): + valid = True + if not self.get(): + valid = False + self.error.set('A value is required') + return valid + + +class ValidatedSpinbox(ValidatedMixin, ttk.Spinbox): + """A Spinbox that only accepts Numbers""" + + def __init__(self, *args, min_var=None, max_var=None, + focus_update_var=None, from_='-Infinity', to='Infinity', **kwargs + ): + super().__init__(*args, from_=from_, to=to, **kwargs) + increment = Decimal(str(kwargs.get('increment', '1.0'))) + self.precision = increment.normalize().as_tuple().exponent + # there should always be a variable, + # or some of our code will fail + self.variable = kwargs.get('textvariable') + if not self.variable: + self.variable = tk.DoubleVar() + self.configure(textvariable=self.variable) + + if min_var: + self.min_var = min_var + self.min_var.trace_add('write', self._set_minimum) + if max_var: + self.max_var = max_var + self.max_var.trace_add('write', self._set_maximum) + self.focus_update_var = focus_update_var + self.bind('', self._set_focus_update_var) + + def _set_focus_update_var(self, event): + value = self.get() + if self.focus_update_var and not self.error.get(): + self.focus_update_var.set(value) + + def _set_minimum(self, *_): + current = self.get() + try: + new_min = self.min_var.get() + self.config(from_=new_min) + except (tk.TclError, ValueError): + pass + if not current: + self.delete(0, tk.END) + else: + self.variable.set(current) + self.trigger_focusout_validation() + + def _set_maximum(self, *_): + current = self.get() + try: + new_max = self.max_var.get() + self.config(to=new_max) + except (tk.TclError, ValueError): + pass + if not current: + self.delete(0, tk.END) + else: + self.variable.set(current) + self.trigger_focusout_validation() + + def _key_validate( + self, char, index, current, proposed, action, **kwargs + ): + if action == '0': + return True + valid = True + min_val = self.cget('from') + max_val = self.cget('to') + no_negative = min_val >= 0 + no_decimal = self.precision >= 0 + + # First, filter out obviously invalid keystrokes + if any([ + (char not in '-1234567890.'), + (char == '-' and (no_negative or index != '0')), + (char == '.' and (no_decimal or '.' in current)) + ]): + return False + + # At this point, proposed is either '-', '.', '-.', + # or a valid Decimal string + if proposed in '-.': + return True + + # Proposed is a valid Decimal string + # convert to Decimal and check more: + proposed = Decimal(proposed) + proposed_precision = proposed.as_tuple().exponent + + if any([ + (proposed > max_val), + (proposed_precision < self.precision) + ]): + return False + + return valid + + def _focusout_validate(self, **kwargs): + valid = True + value = self.get() + min_val = self.cget('from') + max_val = self.cget('to') + + try: + d_value = Decimal(value) + except InvalidOperation: + self.error.set(f'Invalid number string: {value}') + return False + + if d_value < min_val: + self.error.set(f'Value is too low (min {min_val})') + valid = False + if d_value > max_val: + self.error.set(f'Value is too high (max {max_val})') + valid = False + + return valid + +class ValidatedRadio(ttk.Radiobutton): + """A validated radio button""" + + def __init__(self, *args, error_var=None, **kwargs): + super().__init__(*args, **kwargs) + self.error = error_var or tk.StringVar() + self.variable = kwargs.get("variable") + self.bind('', self._focusout_validate) + + def _focusout_validate(self, *_): + self.error.set('') + if not self.variable.get(): + self.error.set('A value is required') + + def trigger_focusout_validation(self): + self._focusout_validate() + + +class BoundText(tk.Text): + """A Text widget with a bound variable.""" + + def __init__(self, *args, textvariable=None, **kwargs): + super().__init__(*args, **kwargs) + self._variable = textvariable + if self._variable: + # insert any default value + self.insert('1.0', self._variable.get()) + self._variable.trace_add('write', self._set_content) + self.bind('<>', self._set_var) + + def _set_var(self, *_): + """Set the variable to the text contents""" + if self.edit_modified(): + content = self.get('1.0', 'end-1chars') + self._variable.set(content) + self.edit_modified(False) + + def _set_content(self, *_): + """Set the text contents to the variable""" + self.delete('1.0', tk.END) + self.insert('1.0', self._variable.get()) + + +########################### +# Compound Widget Classes # +########################### + + +class LabelInput(ttk.Frame): + """A widget containing a label and input together.""" + + field_types = { + FT.string: RequiredEntry, + FT.string_list: ValidatedCombobox, + FT.short_string_list: ValidatedRadio, + FT.iso_date_string: DateEntry, + FT.long_string: BoundText, + FT.decimal: ValidatedSpinbox, + FT.integer: ValidatedSpinbox, + FT.boolean: ttk.Checkbutton + } + + def __init__( + self, parent, label, var, input_class=None, + input_args=None, label_args=None, field_spec=None, + disable_var=None, **kwargs + ): + super().__init__(parent, **kwargs) + input_args = input_args or {} + label_args = label_args or {} + self.variable = var + self.variable.label_widget = self + + # Process the field spec to determine input_class and validation + if field_spec: + field_type = field_spec.get('type', FT.string) + input_class = input_class or self.field_types.get(field_type) + # min, max, increment + if 'min' in field_spec and 'from_' not in input_args: + input_args['from_'] = field_spec.get('min') + if 'max' in field_spec and 'to' not in input_args: + input_args['to'] = field_spec.get('max') + if 'inc' in field_spec and 'increment' not in input_args: + input_args['increment'] = field_spec.get('inc') + # values + if 'values' in field_spec and 'values' not in input_args: + input_args['values'] = field_spec.get('values') + + # setup the label + if input_class in (ttk.Checkbutton, ttk.Button): + # Buttons don't need labels, they're built-in + input_args["text"] = label + else: + self.label = ttk.Label(self, text=label, **label_args) + self.label.grid(row=0, column=0, sticky=(tk.W + tk.E)) + + # setup the variable + if input_class in ( + ttk.Checkbutton, ttk.Button, ttk.Radiobutton, ValidatedRadio + ): + input_args["variable"] = self.variable + else: + input_args["textvariable"] = self.variable + + # Setup the input + if input_class in (ttk.Radiobutton, ValidatedRadio): + # for Radiobutton, create one input per value + self.input = tk.Frame(self) + for v in input_args.pop('values', []): + button = input_class( + self.input, value=v, text=v, **input_args) + button.pack(side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x') + self.input.error = getattr(button, 'error', None) + self.input.trigger_focusout_validation = \ + button._focusout_validate + else: + self.input = input_class(self, **input_args) + + self.input.grid(row=1, column=0, sticky=(tk.W + tk.E)) + self.columnconfigure(0, weight=1) + + # Set up error handling & display + error_style = 'Error.' + label_args.get('style', 'TLabel') + ttk.Style().configure(error_style, foreground='darkred') + self.error = getattr(self.input, 'error', tk.StringVar()) + ttk.Label(self, textvariable=self.error, style=error_style).grid( + row=2, column=0, sticky=(tk.W + tk.E) + ) + + # Set up disable variable + if disable_var: + self.disable_var = disable_var + self.disable_var.trace_add('write', self._check_disable) + + def _check_disable(self, *_): + if not hasattr(self, 'disable_var'): + return + + if self.disable_var.get(): + self.input.configure(state=tk.DISABLED) + self.variable.set('') + self.error.set('') + else: + self.input.configure(state=tk.NORMAL) + + def grid(self, sticky=(tk.E + tk.W), **kwargs): + """Override grid to add default sticky values""" + super().grid(sticky=sticky, **kwargs) diff --git a/Chapter09/ABQ_Data_Entry/docs/Application_layout.png b/Chapter09/ABQ_Data_Entry/docs/Application_layout.png new file mode 100644 index 0000000000000000000000000000000000000000..93990f232d19518ca465e7715bbf4f0f10cabce9 GIT binary patch literal 9117 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 mufwd~J*Rr~aor9m$$ITZW{@;L-?% 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 MUZF@17kHol*>qlj!vFvP literal 0 HcmV?d00001 diff --git a/Chapter09/named_font_demo.py b/Chapter09/named_font_demo.py new file mode 100644 index 0000000..487217e --- /dev/null +++ b/Chapter09/named_font_demo.py @@ -0,0 +1,23 @@ +import tkinter as tk +from tkinter import font +root = tk.Tk() + +for name in font.names(): + font_obj = font.nametofont(name) + tk.Label(root, text=name, font=font_obj).pack() + +namedfont = tk.StringVar() +family = tk.StringVar() +size = tk.IntVar() + +tk.OptionMenu(root, namedfont, *font.names()).pack() +tk.OptionMenu(root, family, *font.families()).pack() +tk.Spinbox(root, textvariable=size, from_=6, to=128).pack() + +def setFont(): + font_obj = font.nametofont(namedfont.get()) + font_obj.configure(family=family.get(), size=size.get()) + +tk.Button(root, text='Change', command=setFont).pack() + +root.mainloop() diff --git a/Chapter09/smile.gif b/Chapter09/smile.gif new file mode 100644 index 0000000000000000000000000000000000000000..34b3a0972fbc489af30a4a0202fcf34450d97a95 GIT binary patch literal 201 zcmZ?wbhEHbRA5kGSjfZx1h4)x{09TYe}c|Msfi`2DGKG8B^e6tp1uJLia%Kxxfqxj zbU;c$ni!Z`r}Q(Op2P1u+r){h>DrBXJ6j(Rb zM#G-gCvI~r>pZU*Jw7wH$3yUPRITQMV9m1IXE--Md^sc9EdS!m>of0$O^iSFe&Sx^ z;@WVY)XNP8q7ROT6hCj7sPtumyT}eB@gow9i&!E*ZSQzl7+!Hh^M!a61A{dHvTj!K literal 0 HcmV?d00001 diff --git a/Chapter09/tags_demo.py b/Chapter09/tags_demo.py new file mode 100644 index 0000000..530b8ce --- /dev/null +++ b/Chapter09/tags_demo.py @@ -0,0 +1,24 @@ +"""A simple Python shell used to demonstrate tags""" + +import tkinter as tk + +text = tk.Text(width=50, height=20, bg='black', fg='lightgreen') +text.pack() +text.tag_configure('prompt', foreground='magenta') +text.tag_configure('output', foreground='yellow') +text.insert('end', '>>> ', ('prompt',)) + +def on_return(*args): + cmd = text.get('prompt.last', 'end').strip() + if cmd: + try: + output = str(eval(cmd)) + except Exception as e: + output = str(e) + text.insert('end', '\n' + output, ('output',)) + text.insert('end', '\n>>> ', ('prompt',)) + return 'break' + + +text.bind('', on_return) +text.mainloop() diff --git a/Chapter09/tk_default_font_example.py b/Chapter09/tk_default_font_example.py new file mode 100644 index 0000000..9b7f929 --- /dev/null +++ b/Chapter09/tk_default_font_example.py @@ -0,0 +1,13 @@ +import tkinter as tk +from tkinter.font import nametofont + +root = tk.Tk() + +# Get and adjust default font +default_font = nametofont('TkDefaultFont') +default_font.config(family='Helvetica', size=32) + +# Display a label +tk.Label(text='Feeling Groovy').pack() + +root.mainloop() diff --git a/Chapter09/tkinter_color_demo.py b/Chapter09/tkinter_color_demo.py new file mode 100644 index 0000000..0549a9b --- /dev/null +++ b/Chapter09/tkinter_color_demo.py @@ -0,0 +1,7 @@ +import tkinter as tk + +l = tk.Label(text='Hot Dog!', fg='yellow', bg='red') +l.pack(expand=1, fill='both') + +l2 = tk.Label(text='Also Hot Dog!', foreground='#FFFF00', background='#FF0000') +l2.pack(expand=1, fill='both') diff --git a/Chapter09/ttk_combobox_info.py b/Chapter09/ttk_combobox_info.py new file mode 100644 index 0000000..5f7a8fd --- /dev/null +++ b/Chapter09/ttk_combobox_info.py @@ -0,0 +1,54 @@ +import tkinter as tk +from tkinter import ttk +from pprint import pprint + +root = tk.Tk() +style = ttk.Style() + +print('TTK Combobox\n') + +cb = ttk.Combobox(root) +cb_stylename = cb.winfo_class() +print("Style name: ", cb_stylename) +print("Starting state:", cb.state()) +cb.state(['active', 'invalid']) +print("New state:", cb.state()) +cb.state(['!invalid']) +print("Newer state: ", cb.state()) + +cb_layout = style.layout(cb_stylename) +print("\nLayout: ") +pprint(cb_layout) + +def walk_layout(layout): + for element, subelements in layout: + print("\nOptions for {}:".format(element)) + pprint(style.element_options(element)) + if subelements.get("children"): + walk_layout(subelements.get("children")) +walk_layout(cb_layout) + +cb_map = style.map(cb_stylename) +print("\nDefault Map:") +pprint(cb_map) + +style.map(cb_stylename, + fieldbackground=[ + ('!invalid', 'blue'), + ('invalid', 'red') + ], + font=[ + ('!invalid', 'Helvetica 20 normal'), + ('invalid', 'Helvetica 20 bold') + ]) + +cb_map = style.map(cb_stylename) +print("\nNew Map:") +pprint(cb_map) + +print('\nAvailable Themes:') +pprint(style.theme_names()) + +print('\nCurrent Theme:', style.theme_use()) + +pprint(style.element_names()) From f9c2388963e4a63b1e354f32978aae84dae14107 Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Fri, 9 Jul 2021 11:06:33 -0500 Subject: [PATCH 20/32] Add chapter 10 code --- Chapter10/ABQ_Data_Entry/.gitignore | 2 + Chapter10/ABQ_Data_Entry/README.rst | 43 ++ Chapter10/ABQ_Data_Entry/abq_data_entry.py | 4 + .../ABQ_Data_Entry/abq_data_entry/__init__.py | 0 .../abq_data_entry/application.py | 270 ++++++++++ .../abq_data_entry/constants.py | 12 + .../abq_data_entry/images/__init__.py | 20 + .../abq_data_entry/images/abq_logo-16x10.png | Bin 0 -> 1346 bytes .../abq_data_entry/images/abq_logo-32x20.png | Bin 0 -> 2637 bytes .../abq_data_entry/images/abq_logo-64x40.png | Bin 0 -> 5421 bytes .../abq_data_entry/images/browser-2x.png | Bin 0 -> 174 bytes .../abq_data_entry/images/file-2x.png | Bin 0 -> 167 bytes .../abq_data_entry/images/list-2x.png | Bin 0 -> 160 bytes .../images/question-mark-2x.xbm | 6 + .../abq_data_entry/images/reload-2x.png | Bin 0 -> 336 bytes .../abq_data_entry/images/x-2x.xbm | 6 + .../ABQ_Data_Entry/abq_data_entry/mainmenu.py | 362 +++++++++++++ .../ABQ_Data_Entry/abq_data_entry/models.py | 179 +++++++ .../ABQ_Data_Entry/abq_data_entry/views.py | 492 ++++++++++++++++++ .../ABQ_Data_Entry/abq_data_entry/widgets.py | 434 +++++++++++++++ .../docs/Application_layout.png | Bin 0 -> 9117 bytes .../docs/abq_data_entry_spec.rst | 97 ++++ .../docs/lab-tech-paper-form.png | Bin 0 -> 22018 bytes .../complex_cross_platform_demo/backend.py | 37 ++ Chapter10/complex_cross_platform_demo/main.py | 7 + Chapter10/non_cross_platform_menu.py | 42 ++ Chapter10/simple_cross_platform_demo.py | 18 + Chapter10/smile.gif | Bin 0 -> 201 bytes 28 files changed, 2031 insertions(+) create mode 100644 Chapter10/ABQ_Data_Entry/.gitignore create mode 100644 Chapter10/ABQ_Data_Entry/README.rst create mode 100644 Chapter10/ABQ_Data_Entry/abq_data_entry.py create mode 100644 Chapter10/ABQ_Data_Entry/abq_data_entry/__init__.py create mode 100644 Chapter10/ABQ_Data_Entry/abq_data_entry/application.py create mode 100644 Chapter10/ABQ_Data_Entry/abq_data_entry/constants.py create mode 100644 Chapter10/ABQ_Data_Entry/abq_data_entry/images/__init__.py create mode 100644 Chapter10/ABQ_Data_Entry/abq_data_entry/images/abq_logo-16x10.png create mode 100644 Chapter10/ABQ_Data_Entry/abq_data_entry/images/abq_logo-32x20.png create mode 100644 Chapter10/ABQ_Data_Entry/abq_data_entry/images/abq_logo-64x40.png create mode 100644 Chapter10/ABQ_Data_Entry/abq_data_entry/images/browser-2x.png create mode 100644 Chapter10/ABQ_Data_Entry/abq_data_entry/images/file-2x.png create mode 100644 Chapter10/ABQ_Data_Entry/abq_data_entry/images/list-2x.png create mode 100644 Chapter10/ABQ_Data_Entry/abq_data_entry/images/question-mark-2x.xbm create mode 100644 Chapter10/ABQ_Data_Entry/abq_data_entry/images/reload-2x.png create mode 100644 Chapter10/ABQ_Data_Entry/abq_data_entry/images/x-2x.xbm create mode 100644 Chapter10/ABQ_Data_Entry/abq_data_entry/mainmenu.py create mode 100644 Chapter10/ABQ_Data_Entry/abq_data_entry/models.py create mode 100644 Chapter10/ABQ_Data_Entry/abq_data_entry/views.py create mode 100644 Chapter10/ABQ_Data_Entry/abq_data_entry/widgets.py create mode 100644 Chapter10/ABQ_Data_Entry/docs/Application_layout.png create mode 100644 Chapter10/ABQ_Data_Entry/docs/abq_data_entry_spec.rst create mode 100644 Chapter10/ABQ_Data_Entry/docs/lab-tech-paper-form.png create mode 100644 Chapter10/complex_cross_platform_demo/backend.py create mode 100644 Chapter10/complex_cross_platform_demo/main.py create mode 100644 Chapter10/non_cross_platform_menu.py create mode 100644 Chapter10/simple_cross_platform_demo.py create mode 100644 Chapter10/smile.gif diff --git a/Chapter10/ABQ_Data_Entry/.gitignore b/Chapter10/ABQ_Data_Entry/.gitignore new file mode 100644 index 0000000..d646835 --- /dev/null +++ b/Chapter10/ABQ_Data_Entry/.gitignore @@ -0,0 +1,2 @@ +*.pyc +__pycache__/ diff --git a/Chapter10/ABQ_Data_Entry/README.rst b/Chapter10/ABQ_Data_Entry/README.rst new file mode 100644 index 0000000..5a47dd7 --- /dev/null +++ b/Chapter10/ABQ_Data_Entry/README.rst @@ -0,0 +1,43 @@ +============================ + ABQ Data Entry Application +============================ + +Description +=========== + +This program provides a data entry form for ABQ Agrilabs laboratory data. + +Features +-------- + + * Provides a validated entry form to ensure correct data + * Stores data to ABQ-format CSV files + * Auto-fills form fields whenever possible + +Authors +======= + +Alan D Moore, 2021 + +Requirements +============ + + * Python 3.7 or higher + * Tkinter + +Usage +===== + +To start the application, run:: + + python3 ABQ_Data_Entry/abq_data_entry.py + + +General Notes +============= + +The CSV file will be saved to your current directory in the format +``abq_data_record_CURRENTDATE.csv``, where CURRENTDATE is today's date in ISO format. + +This program only appends to the CSV file. You should have a spreadsheet program +installed in case you need to edit or check the file. diff --git a/Chapter10/ABQ_Data_Entry/abq_data_entry.py b/Chapter10/ABQ_Data_Entry/abq_data_entry.py new file mode 100644 index 0000000..a3b3a0d --- /dev/null +++ b/Chapter10/ABQ_Data_Entry/abq_data_entry.py @@ -0,0 +1,4 @@ +from abq_data_entry.application import Application + +app = Application() +app.mainloop() diff --git a/Chapter10/ABQ_Data_Entry/abq_data_entry/__init__.py b/Chapter10/ABQ_Data_Entry/abq_data_entry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Chapter10/ABQ_Data_Entry/abq_data_entry/application.py b/Chapter10/ABQ_Data_Entry/abq_data_entry/application.py new file mode 100644 index 0000000..66e19d0 --- /dev/null +++ b/Chapter10/ABQ_Data_Entry/abq_data_entry/application.py @@ -0,0 +1,270 @@ +"""The application/controller class for ABQ Data Entry""" + +import tkinter as tk +from tkinter import ttk +from tkinter import messagebox +from tkinter import filedialog +from tkinter import font +import platform + +from . import views as v +from . import models as m +from .mainmenu import get_main_menu_for_os +from . import images + +class Application(tk.Tk): + """Application root window""" + + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Hide window while GUI is built + self.withdraw() + + # Authenticate + if not self._show_login(): + self.destroy() + return + + # show the window + self.deiconify() + + # Create model + self.model = m.CSVModel() + + # Load settings + # self.settings = { + # 'autofill date': tk.BooleanVar(), + # 'autofill sheet data': tk.BoleanVar() + # } + self.settings_model = m.SettingsModel() + self._load_settings() + + self.inserted_rows = [] + self.updated_rows = [] + + # Begin building GUI + self.title("ABQ Data Entry Application") + self.columnconfigure(0, weight=1) + + # Set taskbar icon + self.taskbar_icon = tk.PhotoImage(file=images.ABQ_LOGO_64) + self.iconphoto(True, self.taskbar_icon) + + # Create the menu + #menu = MainMenu(self, self.settings) + menu_class = get_main_menu_for_os(platform.system()) + menu = menu_class(self, self.settings) + + self.config(menu=menu) + event_callbacks = { + '<>': self._on_file_select, + '<>': lambda _: self.quit(), + '<>': self._show_recordlist, + '<>': self._new_record, + + } + for sequence, callback in event_callbacks.items(): + self.bind(sequence, callback) + + # new for ch9 + self.logo = tk.PhotoImage(file=images.ABQ_LOGO_32) + ttk.Label( + self, + text="ABQ Data Entry Application", + font=("TkDefaultFont", 16), + image=self.logo, + compound=tk.LEFT + ).grid(row=0) + + # The notebook + self.notebook = ttk.Notebook(self) + self.notebook.enable_traversal() + self.notebook.grid(row=1, padx=10, sticky='NSEW') + + # The data record form + self.recordform_icon = tk.PhotoImage(file=images.FORM_ICON) + self.recordform = v.DataRecordForm(self, self.model, self.settings) + self.notebook.add( + self.recordform, text='Entry Form', + image=self.recordform_icon, compound=tk.LEFT + ) + self.recordform.bind('<>', self._on_save) + + + # The data record list + self.recordlist_icon = tk.PhotoImage(file=images.LIST_ICON) + self.recordlist = v.RecordList( + self, self.inserted_rows, self.updated_rows + ) + self.notebook.insert( + 0, self.recordlist, text='Records', + image=self.recordlist_icon, compound=tk.LEFT + ) + self._populate_recordlist() + self.recordlist.bind('<>', self._open_record) + + + self._show_recordlist() + + # status bar + self.status = tk.StringVar() + self.statusbar = ttk.Label(self, textvariable=self.status) + self.statusbar.grid(sticky=(tk.W + tk.E), row=3, padx=10) + + + self.records_saved = 0 + + + def _on_save(self, *_): + """Handles file-save requests""" + + # Check for errors first + + errors = self.recordform.get_errors() + if errors: + self.status.set( + "Cannot save, error in fields: {}" + .format(', '.join(errors.keys())) + ) + message = "Cannot save record" + detail = "The following fields have errors: \n * {}".format( + '\n * '.join(errors.keys()) + ) + messagebox.showerror( + title='Error', + message=message, + detail=detail + ) + return False + + data = self.recordform.get() + rownum = self.recordform.current_record + self.model.save_record(data, rownum) + if rownum is not None: + self.updated_rows.append(rownum) + else: + rownum = len(self.model.get_all_records()) -1 + self.inserted_rows.append(rownum) + self.records_saved += 1 + self.status.set( + "{} records saved this session".format(self.records_saved) + ) + self.recordform.reset() + self._populate_recordlist() + + def _on_file_select(self, *_): + """Handle the file->select action""" + + filename = filedialog.asksaveasfilename( + title='Select the target file for saving records', + defaultextension='.csv', + filetypes=[('CSV', '*.csv *.CSV')] + ) + if filename: + self.model = m.CSVModel(filename=filename) + self.inserted_rows.clear() + self.updated_rows.clear() + self._populate_recordlist() + + @staticmethod + def _simple_login(username, password): + """A basic authentication backend with a hardcoded user and password""" + return username == 'abq' and password == 'Flowers' + + def _show_login(self): + """Show login dialog and attempt to login""" + error = '' + title = "Login to ABQ Data Entry" + while True: + login = v.LoginDialog(self, title, error) + if not login.result: # User canceled + return False + username, password = login.result + if self._simple_login(username, password): + return True + error = 'Login Failed' # loop and redisplay + + def _load_settings(self): + """Load settings into our self.settings dict.""" + + vartypes = { + 'bool': tk.BooleanVar, + 'str': tk.StringVar, + 'int': tk.IntVar, + 'float': tk.DoubleVar + } + + # create our dict of settings variables from the model's settings. + self.settings = dict() + for key, data in self.settings_model.fields.items(): + vartype = vartypes.get(data['type'], tk.StringVar) + self.settings[key] = vartype(value=data['value']) + + # put a trace on the variables so they get stored when changed. + for var in self.settings.values(): + var.trace_add('write', self._save_settings) + + # update font settings after loading them + self._set_font() + self.settings['font size'].trace_add('write', self._set_font) + self.settings['font family'].trace_add('write', self._set_font) + + # process theme + style = ttk.Style() + theme = self.settings.get('theme').get() + if theme in style.theme_names(): + style.theme_use(theme) + + def _save_settings(self, *_): + """Save the current settings to a preferences file""" + + for key, variable in self.settings.items(): + self.settings_model.set(key, variable.get()) + self.settings_model.save() + + def _show_recordlist(self, *_): + """Show the recordform""" + self.notebook.select(self.recordlist) + + def _populate_recordlist(self): + try: + rows = self.model.get_all_records() + except Exception as e: + messagebox.showerror( + title='Error', + message='Problem reading file', + detail=str(e) + ) + else: + self.recordlist.populate(rows) + + def _new_record(self, *_): + """Open the record form with a blank record""" + self.recordform.load_record(None, None) + self.notebook.select(self.recordform) + + + def _open_record(self, *_): + """Open the Record selected recordlist id in the recordform""" + rowkey = self.recordlist.selected_id + try: + record = self.model.get_record(rowkey) + except Exception as e: + messagebox.showerror( + title='Error', message='Problem reading file', detail=str(e) + ) + return + self.recordform.load_record(rowkey, record) + self.notebook.select(self.recordform) + + # new chapter 9 + def _set_font(self, *_): + """Set the application's font""" + font_size = self.settings['font size'].get() + font_family = self.settings['font family'].get() + font_names = ('TkDefaultFont', 'TkMenuFont', 'TkTextFont') + for font_name in font_names: + tk_font = font.nametofont(font_name) + tk_font.config(size=font_size, family=font_family) diff --git a/Chapter10/ABQ_Data_Entry/abq_data_entry/constants.py b/Chapter10/ABQ_Data_Entry/abq_data_entry/constants.py new file mode 100644 index 0000000..e747dce --- /dev/null +++ b/Chapter10/ABQ_Data_Entry/abq_data_entry/constants.py @@ -0,0 +1,12 @@ +"""Global constants and classes needed by other modules in ABQ Data Entry""" +from enum import Enum, auto + +class FieldTypes(Enum): + string = auto() + string_list = auto() + short_string_list = auto() + iso_date_string = auto() + long_string = auto() + decimal = auto() + integer = auto() + boolean = auto() diff --git a/Chapter10/ABQ_Data_Entry/abq_data_entry/images/__init__.py b/Chapter10/ABQ_Data_Entry/abq_data_entry/images/__init__.py new file mode 100644 index 0000000..57538d9 --- /dev/null +++ b/Chapter10/ABQ_Data_Entry/abq_data_entry/images/__init__.py @@ -0,0 +1,20 @@ +from pathlib import Path + +# This gives us the parent directory of this file (__init__.py) +IMAGE_DIRECTORY = Path(__file__).parent + +ABQ_LOGO_16 = IMAGE_DIRECTORY / 'abq_logo-16x10.png' +ABQ_LOGO_32 = IMAGE_DIRECTORY / 'abq_logo-32x20.png' +ABQ_LOGO_64 = IMAGE_DIRECTORY / 'abq_logo-64x40.png' + +# PNG icons + +SAVE_ICON = IMAGE_DIRECTORY / 'file-2x.png' +RESET_ICON = IMAGE_DIRECTORY / 'reload-2x.png' +LIST_ICON = IMAGE_DIRECTORY / 'list-2x.png' +FORM_ICON = IMAGE_DIRECTORY / 'browser-2x.png' + + +# BMP icons +QUIT_BMP = IMAGE_DIRECTORY / 'x-2x.xbm' +ABOUT_BMP = IMAGE_DIRECTORY / 'question-mark-2x.xbm' diff --git a/Chapter10/ABQ_Data_Entry/abq_data_entry/images/abq_logo-16x10.png b/Chapter10/ABQ_Data_Entry/abq_data_entry/images/abq_logo-16x10.png new file mode 100644 index 0000000000000000000000000000000000000000..255f2739e10827885fe11f4fece3f7e42bf73064 GIT binary patch literal 1346 zcmV-I1-<%-P) zaB^>EX>4U6ba`-PAZ2)IW&i+q+O3sqlI%7JM*nLSS%LuZ&~k(xRoOw7pHJ?dnLCx6 zls-|pyCOH&W)W)-9L)_LG2>T9&;O3zC5{ZKz{zR61+Z#hFG zKWM&JxgRhd?ftx8tG=96+jeXj6%!Y(ycqFZfw?K}ryW-);pu+;{JuM~PltRXD<3cX z?FnN3F=Rw6^@nlJigWgB$DY+x91|8bZI%y)T#+w~0^JIBsAk;L*(mi04aiRMKB~FP>n>%s5-L~A&&t-1Cg^d zP7okfUI>y~5i!6CzP|B|)1%AEFELsMAXIMM1_%wnYE4l;-U2l=RJ5t86?F~mI!vsY znxU3&?+q7ku5Rug-hG5b3k?g8h#sSJ7qq5!>)xaH(#L?)0n-Ct4`_^$oRTdyEj=T9 zj*0S_ZR)h?GiIM-@sib+E?d50^|HpMjZ)fe>$dGXcHiTm){dNZ^q}cZoPNe9HF|g6 zw^_cMK9 zTUyDFvfH6;> z;~-~iW{-1^-pYHSk<)4AKw}QH$br5kDg*Fx4^(_zJxyt>Eg4`sM^@G#t*8sef1fXB zOYMt6ZbS!*k_>;?0AjQ*a*)zq{shn6wOaaUK zNlSf$=-Pryrn)FpkWy%ilEl9#)q*v3Dt34jBAQSPmiLmpd=4fmDD=$to^#K+M*y{8 ztiWGiXGa}1wUVR5B2scKgn%E(Bfz@{qd@^VdQWK zGQcgs1=ye_6672W-4nB2VCfZZg|Fiq%IO1jreqjN=0BQn1Jq;!5CD8P_xXKzKx=&i zVC(X1!%a480TP5H^(W2fo0rUvf1!69ge!!VvpCM55P1KB*2b2SAw|ilglAh!Cq6T` z7GvP`6QUm`$aZ^)1z`DSl=epvlBP-g@ms{;{qsyp1Vxw)@cdzfr@>wt*WrQh4t1F} z0D63PXiV;m4}np?aK zy}C`Te~OJ?i);c(b02{~MVHD?MZlP4+hN_U6)yxe5A!phm>e+hNBnGk-DO*?g5#Wz z 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 literal 0 HcmV?d00001 diff --git a/Chapter10/ABQ_Data_Entry/abq_data_entry/images/abq_logo-64x40.png b/Chapter10/ABQ_Data_Entry/abq_data_entry/images/abq_logo-64x40.png new file mode 100644 index 0000000000000000000000000000000000000000..be9458ff1799dcb20cdab36eb100511f1b213606 GIT binary patch literal 5421 zcmV+|71HX7P) zaB^>EX>4U6ba`-PAZ2)IW&i+q+O3ya&f_``h2Oo3UV}ApM;G{r5iq;gv<&Q`Nln))KGUYtMr(o<3gn{gn4CKVN!(|8o7wpEpV7 zQu*=6*SW+EnV;?R_xU*M=Zx*N+jf(u6)QRAorxzdG;7ND)vhUn_!W1*?_U>c-wWo5 z?D_h`K3C#${yF4%lck?t_in%UeC&ACMml0jdEULg)3>jIlT4I1Q;oxTE8p!sI)|r` zmejP`+OMwwvoMpsX?8X^0PdY)s(hdtG$=2&g>lNceoQ2`KPMm<)>eX%0s^T? zQE8GaXA>ch4nTv*bE$cPfT-q8khwmkG{Es3YjcmuJ2q?nxQt`~LQC-0L1+M0tqOmv zIvg5Ww5n=*)YP@>XrQ)I@`4Av(K@h&#FsTTef`lHFn-**R8v4+rIm=$B_e-PCa_svE!$o zapBr6w_d%7?)vR_e4{3x%KPQ`*4p1fO+Hb}FH$kiexSx>v#%*6JVpaE5X)6S+yVgz zoddJvQfm(60<+XJqR>o``UE&z0z2gW3*1UoT=GDvX?_g8FWGLWH);o#)Cr!@04YGn3Ahhr|l90mGetQ`?8-(n%(p zn|1d#%R$bd<~I4ZtIk@mJHiC(Nz_v+x!Ob17~_;NnrbPX`2c%6m{o+etrC&t`{=FX zy3Uiwu0!BYo7y_Jt&1Us1D9Jyk5!uy>a;RA7ltpheO50%?LLLQ0Y%iEC16A)>C)U$5i~7&kKgLUX$5sjnEp_bWXaXndq4|HtdRm};NzK_>T$-)z ze7jL?m({}tdY0}LwWL1kUaX;8`|6|#oAO70d|IV+Gx2L*pRE_ z{m@d3+s8%+Slo({k^WK2Zu80IHm`~UKpuFITE#ljtLR|GJ)&fbpz~~*yyKSrmW*Ub z&Rm-Y1PY#qWe;{Rw-FHRpZFZ@-6Hb>c?B#X6$Gc@MA&#!VeYvDa(rXYn5>=sti9G{ zQJhjR($!rzxfQ@?hEgGoV=8S|?k^XiLvGbXXK3ZvQX!H=H?r6Q3a_`WevrMhM+^ZB zxsm!1O7CY94$TtYii|Y{j*@RhH|BP3 zrW|>{q-Wy_iJjr_a>u45F%>z|NSedIO1+!m2159k5{h~tO<^@`0Y`gKghvWT=97|B z$n*vw@jk;h=;v~2f>9%ZS4=e0t<)BB8y#up$yVmW9y$i4V>+|1E{$SOt-#O0e#=39 ziR7~kMs`SJ?S|=Y>fzXWZ6-8xc^09fzD;zv^X#YNvp03rO95$-UcKv%vHQSnCVm*I zzox|awaf)MC5JJPP92%z=mUC{ybJ}Di+TjyJjA<@Kl}`rVg5wD!n?nz4*pOXf$X@ z-f$N7jjxDoYB831hdq`ks<_*x*4@C^fn$feVHHC zusz2=-7j1Mg3k~4qvbfS`>_vtApGGRdafI#6SoUb>5M0U$htNsNAQN~u!v8E0x>TJ zK+I8~+gMlz1fpa_M@4r9%Mlp)Hm8Q>S&eq=)cHg+21+tV?&EDhcr^1U)p8nM=NK3q zLp@v2Ji*_8W53X-c8e&>h;pqHA!Y^HE7_i=x=A><;`=W_kqaUqxxbl0|XIV7Z zIFmqPVB>MMqYoZTsStu=*nJB#1#Yfdp-~}8m)wT6zL$^ZHY^~6Y1dJ>V5o~9onV88OcVoo3KtPQF4K9q1p0G_u z(YTDjC>)Ol&SVan0X>FEFggeg$Y@a37-)K8fMV(H>aOluUe$Yd{q^P#=Ee;4qoe_1&0FMz;s~ZO7PFke$sWY zEE8~3S69d-6TNsmz3J^Q4>Kv*{b7=K-)#X>=(-y#XFmyatb+P^@V{jBkdeeAphm*A z`yn+Jz~_TlEX}&ts+m5mg3953z|IT=paB}GM2e;+lSI-&*96`IowC6ml;*8WpDzpH zXjwRUanb?k{NK@bwrvaZ{aJs&=QD`KvNZ1fuwfkR7m$>h9y5V&{sGKt^=`57ClxGP zG7gtpcFLe@G@UZpULWSs$HMI0*OC!Zb1)$@%z63YoHD+5^I$HUQ4Z4T$*-nUf8=A# zWD03Zqy*j2ke0-@ZKMU*5@|!L*-pC|CoPppOTC2@1p^co2beHqEJz#dZtkiH(k48- zXdf${K7ld{fcp^qW(>TC09^3EOA!1)R`}H`tC=%*INA9lC734sbjbmhE&mfjc#z$N z=@Sx2^Q+g#@sHOG#W5#A_aYvf+|{_!@!Z|$-C?6J|*4Bf59ebymH2 zlzI1j>QF*-Ev%R;;C7%=g23m42NqQD(s-p0mIxC40aU} za``AVLKM0Kw52-ub?b+;Hnf5Vgd2Rg>A)oUPg`({UKS}`=g~#`c;?w~#vLSZQV3YE z2@1cXs8XbpSEB2k=8eZCzTi7(nvQLQ*9(b+%{#yS8|v!HK@*w5vV8=g(R@0|y7i6R zH~$FPRkJVz*Iie_%WEbo1?wKfiYd^Z>DmTz#U3Ex;9U0ctlaLS#=Ts!b~d`M;T0}s zRey^YZ+Zx<6y1?d3k>gn)47UAHfc~eB1^D*=`eJ+Q)?OzJ+cq4R|zi!Bocx|#}(9F zK1lhUWr1abB{R(iD@{p>&X0aPnh$nPMlH|K+6HUZo@UpMGd(So)g4K&oXvE!l%uKd z6NXlEYT-t#s7;47S-EQ?;poX;hj)2k>EcSqFGV9Xpen1~sZ)!T5E-02Zu0yK$4F!h zB4wI``!2=pR8l@zHkVE==IN(~W4Ll!WlHTq&|Ud@_8S5y3l66Cq8jx>2o5wH?Smi5 zv}wgSW>S>~_|>yS)ATO%d-j|}2#@0zKNr;$A_kHMST;A^SVb!B%s--pkH!1!GGNFw zH&)iEXAK&g!XUI>NtKK_1q6Dh0?;+qt#8Ujdm-TEe;>>j$qXxo6rd8VEDR1Gi30lP zNBaIi2wKc&?$xXJcZ#Z5+t;YPwyq=hCZ%N5s9+Zt=@)c2Aoyl&f`+{(InSHM&^mZ; z zqn~~&0hgPPx9?`d^Dpxszx;%WRdC6mPGj$W8rA|+a3PD*Pyjc%HSQRD8x6o^<#5ZY z#mu~FDw^T$T@gZn=4SERt1)bilx9N`W{jE8Z$o{KL|vC4!_fPrbRyCm;jY2oB|Qk- z0T|{&*&~UB}N}KMDmVXh{yI8mZr(VYC2R z3ZSsm&27f3utX@o$mHPD>K}x+QTEVK$()N^SV?O(NSIUm9VQIB2&Cwl7WV-GPh_~T z+4|mizA^ng0NT@Wf{$JW#p1=hKW9Bew`Vut6^-_kExX6??{7Q9Wivm{G2G`2@VUKezzoBZOrLDj?le%>g0IL6GLfP4!pVeE zTA72^LGO2|6c!+eho~>9K6C(<2U1BzZ^6{c3vmYvC^HJtg-+5=aojx32d57~7zhEy z@W|Fx-1qy{q!w&OmwhjEc@>}uH#{>JN(HoQ*h$Ij?_A8p32I7sbOVIIrD+(tjsy$~ zrUtHNj9W4Msg+<)Yzq{Gc=pLd%zyB+-1zHX8P3hO)gYc8ul8_&Xj>Bbcs3RGGDm!k zXrbaV*#ygXF5tq+Bd63V!u{0NaJ@hwJNXz2G8(u=15(?qK;l{l5XH@}z4G_Ti%8|;g1nfF-0JOdryK_zf z0J`SSxmGydOupjfoK!$_>{PDg9~rzLZ(4lrDL~WN{K+o0B-$`x$f&$Y(Yd1lAwVx+ z^TB#3W99f@%K z)EREr`h-fL{6Gvg-}NMhaOJHi&$sY$1cnR0NGgY<#}_<*?xo z(CTf=U>O$(Q0_;BzbWmjSI?Ai#`M8RFqIwejdJ109*xNCuu!(hKH}em!{(~kj_M`Ndi01WuaY6#q}C@ogK zSj`XT4gc~=0MI>PB{;nIQzES~T3g#Wee49WwszW^S||tv@D&9qDm#xs=a;c}=N{hp z=U4C#b1|r@if7+kjnH-FE?+|UmH*bj-S_^H&co66HSpBknNe$js}F&Bp?bG?;Qk8! zW!X4fZa&}l2Ld5L>%O%lRWP${&^`S67aRE5txr*2?dG#jO}0edrXb)S`2W%bsU$qI zXfG?C3F9~(KL~G)h5BqXa0?hK;f8%+)~c-+ei-*%NS6TRKp}tK*W_A(FzC&&|G(g~XJ9*hU6cEN XhPBd*{i(!N00000NkvXXu0mjfWYLOi literal 0 HcmV?d00001 diff --git a/Chapter10/ABQ_Data_Entry/abq_data_entry/images/browser-2x.png b/Chapter10/ABQ_Data_Entry/abq_data_entry/images/browser-2x.png new file mode 100644 index 0000000000000000000000000000000000000000..2a6efb0d3ad404d3e6df2c4f7e2c9441945b9377 GIT binary patch literal 174 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`EX7WqAsj$Z!;#Vf4nJ za0`PlBg3pY50TY@YDj113tz;u?-H7A2U5ODE0AZ z@(JiUbDVWXB2U-ehVx>J7`TOIPjuP)f90d5KxVdP#t_fW4nJ za0`PlBg3pY54nJ za0`PlBg3pY5<39QJlxHc{CLc#vuuf5Bd*}mNt{%q1HX$~}v!PC{xWt~$(695?x BFjxQp literal 0 HcmV?d00001 diff --git a/Chapter10/ABQ_Data_Entry/abq_data_entry/images/question-mark-2x.xbm b/Chapter10/ABQ_Data_Entry/abq_data_entry/images/question-mark-2x.xbm new file mode 100644 index 0000000..8981c9b --- /dev/null +++ b/Chapter10/ABQ_Data_Entry/abq_data_entry/images/question-mark-2x.xbm @@ -0,0 +1,6 @@ +#define question_mark_2x_width 16 +#define question_mark_2x_height 16 +static unsigned char question_mark_2x_bits[] = { + 0xc0, 0x0f, 0xe0, 0x1f, 0x70, 0x38, 0x30, 0x30, 0x00, 0x30, 0x00, 0x30, + 0x00, 0x18, 0x00, 0x1c, 0x00, 0x0e, 0x00, 0x07, 0x00, 0x03, 0x00, 0x03, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x03 }; diff --git a/Chapter10/ABQ_Data_Entry/abq_data_entry/images/reload-2x.png b/Chapter10/ABQ_Data_Entry/abq_data_entry/images/reload-2x.png new file mode 100644 index 0000000000000000000000000000000000000000..2a79f1665d1156d2e140a10f7902bc69f8bb26bb GIT binary patch literal 336 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`EX7WqAsj$Z!;#Vf4nJ za0`PlBg3pY5>Y*U9P$-MRL!(@9^X8fhq)(E zFJr#NVI#l5GfnbJnH`@4HZ}2mE9hQ-qOz;b%b=q*ea-b7vyRl=n5ChmHt%@F;V9;a z6Z5u)Owe0%kz&bqH;`{=)pW(4FHy7Od@(eJR=g%=_#4 zi__yCxo)1S*Z0cIYSL7p_d6W?wLbFf-tj58wY^j&QX})1kLtS(w^GujuU`_^dvqmc e)r~mQpVIIC_*-03aXkd|J%gvKpUXO@geCw(qlWDO literal 0 HcmV?d00001 diff --git a/Chapter10/ABQ_Data_Entry/abq_data_entry/images/x-2x.xbm b/Chapter10/ABQ_Data_Entry/abq_data_entry/images/x-2x.xbm new file mode 100644 index 0000000..940af96 --- /dev/null +++ b/Chapter10/ABQ_Data_Entry/abq_data_entry/images/x-2x.xbm @@ -0,0 +1,6 @@ +#define x_2x_width 16 +#define x_2x_height 16 +static unsigned char x_2x_bits[] = { + 0x04, 0x10, 0x0e, 0x38, 0x1f, 0x7c, 0x3e, 0x7e, 0x7c, 0x3f, 0xf8, 0x1f, + 0xf0, 0x0f, 0xe0, 0x07, 0xf0, 0x07, 0xf8, 0x0f, 0xfc, 0x1f, 0x7e, 0x3e, + 0x3f, 0x7c, 0x1e, 0x78, 0x0c, 0x30, 0x00, 0x00 }; diff --git a/Chapter10/ABQ_Data_Entry/abq_data_entry/mainmenu.py b/Chapter10/ABQ_Data_Entry/abq_data_entry/mainmenu.py new file mode 100644 index 0000000..4d3d72a --- /dev/null +++ b/Chapter10/ABQ_Data_Entry/abq_data_entry/mainmenu.py @@ -0,0 +1,362 @@ +"""The Main Menu class for ABQ Data Entry""" + +import tkinter as tk +from tkinter import ttk +from tkinter import messagebox +from tkinter import font + +from . import images + +class GenericMainMenu(tk.Menu): + """The Application's main menu""" + + accelerators = { + 'file_open': 'Ctrl+O', + 'quit': 'Ctrl+Q', + 'record_list': 'Ctrl+L', + 'new_record': 'Ctrl+R', + } + + keybinds = { + '': '<>', + '': '<>', + '': '<>', + '': '<>' + } + + styles = {} + + def _event(self, sequence): + """Return a callback function that generates the sequence""" + def callback(*_): + root = self.master.winfo_toplevel() + root.event_generate(sequence) + + return callback + + def _create_icons(self): + + # must be done in a method because PhotoImage can't be created + # until there is a Tk instance. + # There isn't one when the class is defined, but there is when + # the instance is created. + self.icons = { + 'file_open': tk.PhotoImage(file=images.SAVE_ICON), + 'record_list': tk.PhotoImage(file=images.LIST_ICON), + 'new_record': tk.PhotoImage(file=images.FORM_ICON), + 'quit': tk.BitmapImage(file=images.QUIT_BMP, foreground='red'), + 'about': tk.BitmapImage( + file=images.ABOUT_BMP, foreground='#CC0', background='#A09' + ), + } + + def _add_file_open(self, menu): + + menu.add_command( + label='Select file…', command=self._event('<>'), + image=self.icons.get('file'), compound=tk.LEFT + ) + + def _add_quit(self, menu): + menu.add_command( + label='Quit', command=self._event('<>'), + image=self.icons.get('quit'), compound=tk.LEFT + ) + + def _add_autofill_date(self, menu): + menu.add_checkbutton( + label='Autofill Date', variable=self.settings['autofill date'] + ) + + def _add_autofill_sheet(self, menu): + menu.add_checkbutton( + label='Autofill Sheet data', + variable=self.settings['autofill sheet data'] + ) + + def _add_font_size_menu(self, menu): + font_size_menu = tk.Menu(self, tearoff=False, **self.styles) + for size in range(6, 17, 1): + font_size_menu.add_radiobutton( + label=size, value=size, + variable=self.settings['font size'] + ) + menu.add_cascade(label='Font size', menu=font_size_menu) + + def _add_font_family_menu(self, menu): + font_family_menu = tk.Menu(self, tearoff=False, **self.styles) + for family in font.families(): + font_family_menu.add_radiobutton( + label=family, value=family, + variable=self.settings['font family'] + ) + menu.add_cascade(label='Font family', menu=font_family_menu) + + def _add_themes_menu(self, menu): + style = ttk.Style() + themes_menu = tk.Menu(self, tearoff=False, **self.styles) + for theme in style.theme_names(): + themes_menu.add_radiobutton( + label=theme, value=theme, + variable=self.settings['theme'] + ) + menu.add_cascade(label='Theme', menu=themes_menu) + self.settings['theme'].trace_add('write', self._on_theme_change) + + def _add_go_record_list(self, menu): + menu.add_command( + label="Record List", command=self._event('<>'), + image=self.icons.get('record_list'), compound=tk.LEFT + ) + + def _add_go_new_record(self, menu): + menu.add_command( + label="New Record", command=self._event('<>'), + image=self.icons.get('new_record'), compound=tk.LEFT + ) + + def _add_about(self, menu): + menu.add_command( + label='About…', command=self.show_about, + image=self.icons.get('about'), compound=tk.LEFT + ) + + def _build_menu(self): + # The file menu + self._menus['File'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_file_open(self._menus['File']) + self._menus['File'].add_separator() + self._add_quit(self._menus['File']) + + # The options menu + self._menus['Options'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_autofill_date(self._menus['Options']) + self._add_autofill_sheet(self._menus['Options']) + self._add_font_size_menu(self._menus['Options']) + self._add_font_family_menu(self._menus['Options']) + self._add_themes_menu(self._menus['Options']) + + # switch from recordlist to recordform + self._menus['Go'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_go_record_list(self._menus['Go']) + self._add_go_new_record(self._menus['Go']) + + # The help menu + self._menus['Help'] = tk.Menu(self, tearoff=False, **self.styles) + self.add_cascade(label='Help', menu=self._menus['Help']) + self._add_about(self._menus['Help']) + + for label, menu in self._menus.items(): + self.add_cascade(label=label, menu=menu) + self.configure(**self.styles) + + def __init__(self, parent, settings, **kwargs): + super().__init__(parent, **kwargs) + self.settings = settings + self._create_icons() + self._menus = dict() + self._build_menu() + self._bind_accelerators() + self.configure(**self.styles) + + def show_about(self): + """Show the about dialog""" + + about_message = 'ABQ Data Entry' + about_detail = ( + 'by Alan D Moore\n' + 'For assistance please contact the author.' + ) + + messagebox.showinfo( + title='About', message=about_message, detail=about_detail + ) + @staticmethod + def _on_theme_change(*_): + """Popup a message about theme changes""" + message = "Change requires restart" + detail = ( + "Theme changes do not take effect" + " until application restart" + ) + messagebox.showwarning( + title='Warning', + message=message, + detail=detail + ) + + def _bind_accelerators(self): + + for key, sequence in self.keybinds.items(): + self.bind_all(key, self._event(sequence)) + +class WindowsMainMenu(GenericMainMenu): + """ + Changes: + - Windows uses file->exit instead of file->quit, + and no accelerator is used. + - Windows can handle commands on the menubar, so + put 'Record List' / 'New Record' on the bar + - Windows can't handle icons on the menu bar, though + - Put 'options' under 'Tools' with separator + """ + + def _create_icons(self): + super()._create_icons() + del(self.icons['new_record']) + del(self.icons['record_list']) + + def __init__(self, *args, **kwargs): + del(self.keybinds['']) + super().__init__(*args, **kwargs) + + def _add_quit(self, menu): + menu.add_command( + label='Exit', + command=self._event('<>'), + image=self.icons.get('quit'), + compound=tk.LEFT + ) + + def _build_menu(self): + # File Menu + self._menus['File'] = tk.Menu(self, tearoff=False) + self._add_file_open(self._menus['File']) + self._menus['File'].add_separator() + self._add_quit(self._menus['File']) + + #Tools menu + self._menus['Tools'] = tk.Menu(self, tearoff=False) + self._add_autofill_date(self._menus['Tools']) + self._add_autofill_sheet(self._menus['Tools']) + self._add_font_size_menu(self._menus['Tools']) + self._add_font_family_menu(self._menus['Tools']) + self._add_themes_menu(self._menus['Tools']) + + # The help menu + self._menus['Help'] = tk.Menu(self, tearoff=False) + self._add_about(self._menus['Help']) + + # Build main menu + self.add_cascade(label='File', menu=self._menus['File']) + self.add_cascade(label='Tools', menu=self._menus['Tools']) + self._add_go_record_list(self) + self._add_go_new_record(self) + self.add_cascade(label='Help', menu=self._menus['Help']) + + +class LinuxMainMenu(GenericMainMenu): + """Differences for Linux: + + - Edit menu for autofill options + - View menu for font & theme options + - Use color theme for menu + """ + styles = { + 'background': '#333', + 'foreground': 'white', + 'activebackground': '#777', + 'activeforeground': 'white', + 'relief': tk.GROOVE + } + + + def _build_menu(self): + self._menus['File'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_file_open(self._menus['File']) + self._menus['File'].add_separator() + self._add_quit(self._menus['File']) + + # The edit menu + self._menus['Edit'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_autofill_date(self._menus['Edit']) + self._add_autofill_sheet(self._menus['Edit']) + + # The View menu + self._menus['View'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_font_size_menu(self._menus['View']) + self._add_font_family_menu(self._menus['View']) + self._add_themes_menu(self._menus['View']) + + # switch from recordlist to recordform + self._menus['Go'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_go_record_list(self._menus['Go']) + self._add_go_new_record(self._menus['Go']) + + # The help menu + self._menus['Help'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_about(self._menus['Help']) + + for label, menu in self._menus.items(): + self.add_cascade(label=label, menu=menu) + + +class MacOsMainMenu(GenericMainMenu): + """ + Differences for MacOS: + + - Create App Menu + - Move about to app menu, remove 'help' + - Remove redundant quit command + - Change accelerators to Command-[] + - Add View menu for font & theme options + - Add Edit menu for autofill options + - Add Window menu for navigation commands + """ + keybinds = { + '': '<>', + '': '<>', + '': '<>' + } + accelerators = { + 'file_open': 'Cmd-O', + 'record_list': 'Cmd-L', + 'new_record': 'Cmd-R', + } + + def _add_about(self, menu): + menu.add_command( + label='About ABQ Data Entry', command=self.show_about, + image=self.icons.get('about'), compound=tk.LEFT + ) + + def _build_menu(self): + self._menus['ABQ Data Entry'] = tk.Menu( + self, tearoff=False, + name='apple' + ) + self._add_about(self._menus['ABQ Data Entry']) + self._menus['ABQ Data Entry'].add_separator() + + self._menus['File'] = tk.Menu(self, tearoff=False) + self._add_file_open(self._menus['File']) + + self._menus['Edit'] = tk.Menu(self, tearoff=False) + self._add_autofill_date(self._menus['Edit']) + self._add_autofill_sheet(self._menus['Edit']) + + # View menu + self._menus['View'] = tk.Menu(self, tearoff=False) + self._add_font_size_menu(self._menus['View']) + self._add_font_family_menu(self._menus['View']) + self._add_themes_menu(self._menus['View']) + + # Window Menu + self._menus['Window'] = tk.Menu(self, name='window', tearoff=False) + self._add_go_record_list(self._menus['Window']) + self._add_go_new_record(self._menus['Window']) + + for label, menu in self._menus.items(): + self.add_cascade(label=label, menu=menu) + + +def get_main_menu_for_os(os_name): + """Return the menu class appropriate to the given OS""" + menus = { + 'Linux': LinuxMainMenu, + 'Darwin': MacOsMainMenu, + 'freebsd7': LinuxMainMenu, + 'Windows': WindowsMainMenu + } + + return menus.get(os_name, GenericMainMenu) diff --git a/Chapter10/ABQ_Data_Entry/abq_data_entry/models.py b/Chapter10/ABQ_Data_Entry/abq_data_entry/models.py new file mode 100644 index 0000000..6197f98 --- /dev/null +++ b/Chapter10/ABQ_Data_Entry/abq_data_entry/models.py @@ -0,0 +1,179 @@ +import csv +from pathlib import Path +import os +import json +import platform + +from .constants import FieldTypes as FT +from decimal import Decimal +from datetime import datetime + +class CSVModel: + """CSV file storage""" + + fields = { + "Date": {'req': True, 'type': FT.iso_date_string}, + "Time": {'req': True, 'type': FT.string_list, + 'values': ['8:00', '12:00', '16:00', '20:00']}, + "Technician": {'req': True, 'type': FT.string}, + "Lab": {'req': True, 'type': FT.short_string_list, + 'values': ['A', 'B', 'C']}, + "Plot": {'req': True, 'type': FT.string_list, + 'values': [str(x) for x in range(1, 21)]}, + "Seed Sample": {'req': True, 'type': FT.string}, + "Humidity": {'req': True, 'type': FT.decimal, + 'min': 0.5, 'max': 52.0, 'inc': .01}, + "Light": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 100.0, 'inc': .01}, + "Temperature": {'req': True, 'type': FT.decimal, + 'min': 4, 'max': 40, 'inc': .01}, + "Equipment Fault": {'req': False, 'type': FT.boolean}, + "Plants": {'req': True, 'type': FT.integer, 'min': 0, 'max': 20}, + "Blossoms": {'req': True, 'type': FT.integer, 'min': 0, 'max': 1000}, + "Fruit": {'req': True, 'type': FT.integer, 'min': 0, 'max': 1000}, + "Min Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Max Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Med Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Notes": {'req': False, 'type': FT.long_string} + } + + + def __init__(self, filename=None): + + if not filename: + datestring = datetime.today().strftime("%Y-%m-%d") + filename = "abq_data_record_{}.csv".format(datestring) + self.file = Path(filename) + + # Check for append permissions: + file_exists = os.access(self.file, os.F_OK) + parent_writeable = os.access(self.file.parent, os.W_OK) + file_writeable = os.access(self.file, os.W_OK) + if ( + (not file_exists and not parent_writeable) or + (file_exists and not file_writeable) + ): + msg = f'Permission denied accessing file: {filename}' + raise PermissionError(msg) + + + def save_record(self, data, rownum=None): + """Save a dict of data to the CSV file""" + + if rownum is None: + # This is a new record + newfile = not self.file.exists() + + with open(self.file, 'a', newline='') as fh: + csvwriter = csv.DictWriter(fh, fieldnames=self.fields.keys()) + if newfile: + csvwriter.writeheader() + csvwriter.writerow(data) + else: + # This is an update + records = self.get_all_records() + records[rownum] = data + with open(self.file, 'w', encoding='utf-8', newline='') as fh: + csvwriter = csv.DictWriter(fh, fieldnames=self.fields.keys()) + csvwriter.writeheader() + csvwriter.writerows(records) + + def get_all_records(self): + """Read in all records from the CSV and return a list""" + if not self.file.exists(): + return [] + + with open(self.file, 'r', encoding='utf-8') as fh: + csvreader = csv.DictReader(fh.readlines()) + missing_fields = set(self.fields.keys()) - set(csvreader.fieldnames) + if len(missing_fields) > 0: + fields_string = ', '.join(missing_fields) + raise Exception( + f"File is missing fields: {fields_string}" + ) + records = list(csvreader) + + # Correct issue with boolean fields + trues = ('true', 'yes', '1') + bool_fields = [ + key for key, meta + in self.fields.items() + if meta['type'] == FT.boolean + ] + for record in records: + for key in bool_fields: + record[key] = record[key].lower() in trues + return records + + def get_record(self, rownum): + """Get a single record by row number + + Callling code should catch IndexError + in case of a bad rownum. + """ + + return self.get_all_records()[rownum] + + +class SettingsModel: + """A model for saving settings""" + + fields = { + 'autofill date': {'type': 'bool', 'value': True}, + 'autofill sheet data': {'type': 'bool', 'value': True}, + 'font size': {'type': 'int', 'value': 9}, + 'font family': {'type': 'str', 'value': ''}, + 'theme': {'type': 'str', 'value': 'default'} + } + + config_dirs = { + "Linux": Path(os.environ.get('$XDG_CONFIG_HOME', Path.home() / '.config')), + "freebsd7": Path(os.environ.get('$XDG_CONFIG_HOME', Path.home() / '.config')), + 'Darwin': Path.home() / 'Library' / 'Application Support', + 'Windows': Path.home() / 'AppData' / 'Local' + } + + def __init__(self): + # determine the file path + filename = 'abq_settings.json' + filedir = self.config_dirs.get(platform.system(), Path.home()) + self.filepath = filedir / filename + + # load in saved values + self.load() + + def set(self, key, value): + """Set a variable value""" + if ( + key in self.fields and + type(value).__name__ == self.fields[key]['type'] + ): + self.fields[key]['value'] = value + else: + raise ValueError("Bad key or wrong variable type") + + def save(self): + """Save the current settings to the file""" + json_string = json.dumps(self.fields) + with open(self.filepath, 'w', encoding='utf-8') as fh: + fh.write(json_string) + + def load(self): + """Load the settings from the file""" + + # if the file doesn't exist, return + if not self.filepath.exists(): + return + + # open the file and read in the raw values + with open(self.filepath, 'r') as fh: + raw_values = json.loads(fh.read()) + + # don't implicitly trust the raw values, but only get known keys + for key in self.fields: + if key in raw_values and 'value' in raw_values[key]: + raw_value = raw_values[key]['value'] + self.fields[key]['value'] = raw_value diff --git a/Chapter10/ABQ_Data_Entry/abq_data_entry/views.py b/Chapter10/ABQ_Data_Entry/abq_data_entry/views.py new file mode 100644 index 0000000..6f484df --- /dev/null +++ b/Chapter10/ABQ_Data_Entry/abq_data_entry/views.py @@ -0,0 +1,492 @@ +import tkinter as tk +from tkinter import ttk +from tkinter.simpledialog import Dialog +from datetime import datetime +from . import widgets as w +from .constants import FieldTypes as FT +from . import images + +class DataRecordForm(tk.Frame): + """The input form for our widgets""" + + var_types = { + FT.string: tk.StringVar, + FT.string_list: tk.StringVar, + FT.short_string_list: tk.StringVar, + FT.iso_date_string: tk.StringVar, + FT.long_string: tk.StringVar, + FT.decimal: tk.DoubleVar, + FT.integer: tk.IntVar, + FT.boolean: tk.BooleanVar + } + + def _add_frame(self, label, style='', cols=3): + """Add a labelframe to the form""" + + frame = ttk.LabelFrame(self, text=label) + if style: + frame.configure(style=style) + frame.grid(sticky=tk.W + tk.E) + for i in range(cols): + frame.columnconfigure(i, weight=1) + return frame + + def __init__(self, parent, model, settings, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + + self.model= model + self.settings = settings + fields = self.model.fields + + # new for ch9 + style = ttk.Style() + + # Frame styles + style.configure( + 'RecordInfo.TLabelframe', + background='khaki', padx=10, pady=10 + ) + style.configure( + 'EnvironmentInfo.TLabelframe', background='lightblue', + padx=10, pady=10 + ) + style.configure( + 'PlantInfo.TLabelframe', + background='lightgreen', padx=10, pady=10 + ) + # Style the label Element as well + style.configure( + 'RecordInfo.TLabelframe.Label', background='khaki', + padx=10, pady=10 + ) + style.configure( + 'EnvironmentInfo.TLabelframe.Label', + background='lightblue', padx=10, pady=10 + ) + style.configure( + 'PlantInfo.TLabelframe.Label', + background='lightgreen', padx=10, pady=10 + ) + + # Style for the form labels and buttons + style.configure('RecordInfo.TLabel', background='khaki') + style.configure('RecordInfo.TRadiobutton', background='khaki') + style.configure('EnvironmentInfo.TLabel', background='lightblue') + style.configure( + 'EnvironmentInfo.TCheckbutton', + background='lightblue' + ) + style.configure('PlantInfo.TLabel', background='lightgreen') + + + # Create a dict to keep track of input widgets + self._vars = { + key: self.var_types[spec['type']]() + for key, spec in fields.items() + } + + # Build the form + self.columnconfigure(0, weight=1) + + # new chapter 8 + # variable to track current record id + self.current_record = None + + # Label for displaying what record we're editing + self.record_label = ttk.Label(self) + self.record_label.grid(row=0, column=0) + + # Record info section + r_info = self._add_frame( + "Record Information", 'RecordInfo.TLabelframe' + ) + + # line 1 + w.LabelInput( + r_info, "Date", + field_spec=fields['Date'], + var=self._vars['Date'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=0, column=0) + w.LabelInput( + r_info, "Time", + field_spec=fields['Time'], + var=self._vars['Time'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=0, column=1) + w.LabelInput( + r_info, "Technician", + field_spec=fields['Technician'], + var=self._vars['Technician'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=0, column=2) + # line 2 + w.LabelInput( + r_info, "Lab", + field_spec=fields['Lab'], + var=self._vars['Lab'], + label_args={'style': 'RecordInfo.TLabel'}, + input_args={'style': 'RecordInfo.TRadiobutton'} + ).grid(row=1, column=0) + w.LabelInput( + r_info, "Plot", + field_spec=fields['Plot'], + var=self._vars['Plot'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=1, column=1) + w.LabelInput( + r_info, "Seed Sample", + field_spec=fields['Seed Sample'], + var=self._vars['Seed Sample'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=1, column=2) + + + # Environment Data + e_info = self._add_frame( + "Environment Data", 'EnvironmentInfo.TLabelframe' + ) + + e_info = ttk.LabelFrame( + self, + text="Environment Data", + style='EnvironmentInfo.TLabelframe' + ) + e_info.grid(row=2, column=0, sticky="we") + w.LabelInput( + e_info, "Humidity (g/m³)", + field_spec=fields['Humidity'], + var=self._vars['Humidity'], + disable_var=self._vars['Equipment Fault'], + label_args={'style': 'EnvironmentInfo.TLabel'} + ).grid(row=0, column=0) + w.LabelInput( + e_info, "Light (klx)", + field_spec=fields['Light'], + var=self._vars['Light'], + disable_var=self._vars['Equipment Fault'], + label_args={'style': 'EnvironmentInfo.TLabel'} + ).grid(row=0, column=1) + w.LabelInput( + e_info, "Temperature (°C)", + field_spec=fields['Temperature'], + disable_var=self._vars['Equipment Fault'], + var=self._vars['Temperature'], + label_args={'style': 'EnvironmentInfo.TLabel'} + ).grid(row=0, column=2) + w.LabelInput( + e_info, "Equipment Fault", + field_spec=fields['Equipment Fault'], + var=self._vars['Equipment Fault'], + label_args={'style': 'EnvironmentInfo.TLabel'}, + input_args={'style': 'EnvironmentInfo.TCheckbutton'} + ).grid(row=1, column=0, columnspan=3) + + # Plant Data section + p_info = self._add_frame("Plant Data", 'PlantInfo.TLabelframe') + + w.LabelInput( + p_info, "Plants", + field_spec=fields['Plants'], + var=self._vars['Plants'], + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=0, column=0) + w.LabelInput( + p_info, "Blossoms", + field_spec=fields['Blossoms'], + var=self._vars['Blossoms'], + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=0, column=1) + w.LabelInput( + p_info, "Fruit", + field_spec=fields['Fruit'], + var=self._vars['Fruit'], + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=0, column=2) + # Height data + # create variables to be updated for min/max height + # they can be referenced for min/max variables + min_height_var = tk.DoubleVar(value='-infinity') + max_height_var = tk.DoubleVar(value='infinity') + + w.LabelInput( + p_info, "Min Height (cm)", + field_spec=fields['Min Height'], + var=self._vars['Min Height'], + input_args={"max_var": max_height_var, + "focus_update_var": min_height_var}, + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=1, column=0) + w.LabelInput( + p_info, "Max Height (cm)", + field_spec=fields['Max Height'], + var=self._vars['Max Height'], + input_args={"min_var": min_height_var, + "focus_update_var": max_height_var}, + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=1, column=1) + w.LabelInput( + p_info, "Median Height (cm)", + field_spec=fields['Med Height'], + var=self._vars['Med Height'], + input_args={"min_var": min_height_var, + "max_var": max_height_var}, + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=1, column=2) + + + # Notes section -- Update grid row value for ch8 + w.LabelInput( + self, "Notes", field_spec=fields['Notes'], + var=self._vars['Notes'], input_args={"width": 85, "height": 10} + ).grid(sticky="nsew", row=4, column=0, padx=10, pady=10) + + # buttons + buttons = tk.Frame(self) + buttons.grid(sticky=tk.W + tk.E, row=5) + self.save_button_logo = tk.PhotoImage(file=images.SAVE_ICON) + self.savebutton = ttk.Button( + buttons, text="Save", command=self._on_save, + image=self.save_button_logo, compound=tk.LEFT + ) + self.savebutton.pack(side=tk.RIGHT) + + self.reset_button_logo = tk.PhotoImage(file=images.RESET_ICON) + self.resetbutton = ttk.Button( + buttons, text="Reset", command=self.reset, + image=self.reset_button_logo, compound=tk.LEFT + ) + self.resetbutton.pack(side=tk.RIGHT) + + # default the form + self.reset() + + def _on_save(self): + self.event_generate('<>') + + @staticmethod + def tclerror_is_blank_value(exception): + blank_value_errors = ( + 'expected integer but got ""', + 'expected floating-point number but got ""', + 'expected boolean value but got ""' + ) + is_bve = str(exception).strip() in blank_value_errors + return is_bve + + def get(self): + """Retrieve data from form as a dict""" + + # We need to retrieve the data from Tkinter variables + # and place it in regular Python objects + data = dict() + for key, var in self._vars.items(): + try: + data[key] = var.get() + except tk.TclError as e: + if self.tclerror_is_blank_value(e): + data[key] = None + else: + raise e + return data + + def reset(self): + """Resets the form entries""" + + lab = self._vars['Lab'].get() + time = self._vars['Time'].get() + technician = self._vars['Technician'].get() + try: + plot = self._vars['Plot'].get() + except tk.TclError: + plot = '' + plot_values = self._vars['Plot'].label_widget.input.cget('values') + + # clear all values + for var in self._vars.values(): + if isinstance(var, tk.BooleanVar): + var.set(False) + else: + var.set('') + + # Autofill Date + if self.settings['autofill date'].get(): + current_date = datetime.today().strftime('%Y-%m-%d') + self._vars['Date'].set(current_date) + self._vars['Time'].label_widget.input.focus() + + # check if we need to put our values back, then do it. + if ( + self.settings['autofill sheet data'].get() and + plot not in ('', 0, plot_values[-1]) + ): + self._vars['Lab'].set(lab) + self._vars['Time'].set(time) + self._vars['Technician'].set(technician) + next_plot_index = plot_values.index(plot) + 1 + self._vars['Plot'].set(plot_values[next_plot_index]) + self._vars['Seed Sample'].label_widget.input.focus() + + def get_errors(self): + """Get a list of field errors in the form""" + + errors = dict() + for key, var in self._vars.items(): + inp = var.label_widget.input + error = var.label_widget.error + + if hasattr(inp, 'trigger_focusout_validation'): + inp.trigger_focusout_validation() + if error.get(): + errors[key] = error.get() + + return errors + + # new for ch8 + def load_record(self, rownum, data=None): + self.current_record = rownum + if rownum is None: + self.reset() + self.record_label.config(text='New Record') + else: + self.record_label.config(text=f'Record #{rownum}') + for key, var in self._vars.items(): + var.set(data.get(key, '')) + try: + var.label_widget.input.trigger_focusout_validation() + except AttributeError: + pass + + +class LoginDialog(Dialog): + """A dialog that asks for username and password""" + + def __init__(self, parent, title, error=''): + + self._pw = tk.StringVar() + self._user = tk.StringVar() + self._error = tk.StringVar(value=error) + super().__init__(parent, title=title) + + def body(self, frame): + """Construct the interface and return the widget for initial focus + + Overridden from Dialog + """ + ttk.Label(frame, text='Login to ABQ').grid(row=0) + + if self._error.get(): + ttk.Label(frame, textvariable=self._error).grid(row=1) + user_inp = w.LabelInput( + frame, 'User name:', input_class=w.RequiredEntry, + var=self._user + ) + user_inp.grid() + w.LabelInput( + frame, 'Password:', input_class=w.RequiredEntry, + input_args={'show': '*'}, var=self._pw + ).grid() + return user_inp.input + + def buttonbox(self): + box = ttk.Frame(self) + ttk.Button( + box, text="Login", command=self.ok, default=tk.ACTIVE + ).grid(padx=5, pady=5) + ttk.Button( + box, text="Cancel", command=self.cancel + ).grid(row=0, column=1, padx=5, pady=5) + self.bind("", self.ok) + self.bind("", self.cancel) + box.pack() + + + def apply(self): + self.result = (self._user.get(), self._pw.get()) + + +class RecordList(tk.Frame): + """Display for CSV file contents""" + + column_defs = { + '#0': {'label': 'Row', 'anchor': tk.W}, + 'Date': {'label': 'Date', 'width': 150, 'stretch': True}, + 'Time': {'label': 'Time'}, + 'Lab': {'label': 'Lab', 'width': 40}, + 'Plot': {'label': 'Plot', 'width': 80} + } + default_width = 100 + default_minwidth = 10 + default_anchor = tk.CENTER + + def __init__(self, parent, inserted, updated, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + self.inserted = inserted + self.updated = updated + self.columnconfigure(0, weight=1) + self.rowconfigure(0, weight=1) + + # create treeview + self.treeview = ttk.Treeview( + self, + columns=list(self.column_defs.keys())[1:], + selectmode='browse' + ) + self.treeview.grid(row=0, column=0, sticky='NSEW') + + # Configure treeview columns + for name, definition in self.column_defs.items(): + label = definition.get('label', '') + anchor = definition.get('anchor', self.default_anchor) + minwidth = definition.get('minwidth', self.default_minwidth) + width = definition.get('width', self.default_width) + stretch = definition.get('stretch', False) + self.treeview.heading(name, text=label, anchor=anchor) + self.treeview.column( + name, anchor=anchor, minwidth=minwidth, + width=width, stretch=stretch + ) + + self.treeview.bind('', self._on_open_record) + self.treeview.bind('', self._on_open_record) + + # configure scrollbar for the treeview + self.scrollbar = ttk.Scrollbar( + self, + orient=tk.VERTICAL, + command=self.treeview.yview + ) + self.treeview.configure(yscrollcommand=self.scrollbar.set) + self.scrollbar.grid(row=0, column=1, sticky='NSW') + + # configure tagging + self.treeview.tag_configure('inserted', background='lightgreen') + self.treeview.tag_configure('updated', background='lightblue') + + def populate(self, rows): + """Clear the treeview and write the supplied data rows to it.""" + for row in self.treeview.get_children(): + self.treeview.delete(row) + + cids = list(self.column_defs.keys())[1:] + for rownum, rowdata in enumerate(rows): + values = [rowdata[cid] for cid in cids] + if rownum in self.inserted: + tag = 'inserted' + elif rownum in self.updated: + tag = 'updated' + else: + tag = '' + self.treeview.insert( + '', 'end', iid=str(rownum), + text=str(rownum), values=values, tag=tag) + + if len(rows) > 0: + self.treeview.focus_set() + self.treeview.selection_set('0') + self.treeview.focus('0') + + def _on_open_record(self, *args): + + self.selected_id = int(self.treeview.selection()[0]) + self.event_generate('<>') diff --git a/Chapter10/ABQ_Data_Entry/abq_data_entry/widgets.py b/Chapter10/ABQ_Data_Entry/abq_data_entry/widgets.py new file mode 100644 index 0000000..e7197f1 --- /dev/null +++ b/Chapter10/ABQ_Data_Entry/abq_data_entry/widgets.py @@ -0,0 +1,434 @@ +import tkinter as tk +from tkinter import ttk +from datetime import datetime +from decimal import Decimal, InvalidOperation +from .constants import FieldTypes as FT + + +################## +# Widget Classes # +################## + +class ValidatedMixin: + """Adds a validation functionality to an input widget""" + + def __init__(self, *args, error_var=None, **kwargs): + self.error = error_var or tk.StringVar() + super().__init__(*args, **kwargs) + + vcmd = self.register(self._validate) + invcmd = self.register(self._invalid) + + style = ttk.Style() + widget_class = self.winfo_class() + validated_style = 'ValidatedInput.' + widget_class + style.map( + validated_style, + foreground=[('invalid', 'white'), ('!invalid', 'black')], + fieldbackground=[('invalid', 'darkred'), ('!invalid', 'white')] + ) + self.configure(style=validated_style) + + self.configure( + validate='all', + validatecommand=(vcmd, '%P', '%s', '%S', '%V', '%i', '%d'), + invalidcommand=(invcmd, '%P', '%s', '%S', '%V', '%i', '%d') + ) + + def _toggle_error(self, on=False): + self.configure(foreground=('red' if on else 'black')) + + def _validate(self, proposed, current, char, event, index, action): + """The validation method. + + Don't override this, override _key_validate, and _focus_validate + """ + self.error.set('') + + valid = True + # if the widget is disabled, don't validate + state = str(self.configure('state')[-1]) + if state == tk.DISABLED: + return valid + + if event == 'focusout': + valid = self._focusout_validate(event=event) + elif event == 'key': + valid = self._key_validate( + proposed=proposed, + current=current, + char=char, + event=event, + index=index, + action=action + ) + return valid + + def _focusout_validate(self, **kwargs): + return True + + def _key_validate(self, **kwargs): + return True + + def _invalid(self, proposed, current, char, event, index, action): + if event == 'focusout': + self._focusout_invalid(event=event) + elif event == 'key': + self._key_invalid( + proposed=proposed, + current=current, + char=char, + event=event, + index=index, + action=action + ) + + def _focusout_invalid(self, **kwargs): + """Handle invalid data on a focus event""" + pass + + def _key_invalid(self, **kwargs): + """Handle invalid data on a key event. By default we want to do nothing""" + pass + + def trigger_focusout_validation(self): + valid = self._validate('', '', '', 'focusout', '', '') + if not valid: + self._focusout_invalid(event='focusout') + return valid + + +class DateEntry(ValidatedMixin, ttk.Entry): + + def _key_validate(self, action, index, char, **kwargs): + valid = True + + if action == '0': # This is a delete action + valid = True + elif index in ('0', '1', '2', '3', '5', '6', '8', '9'): + valid = char.isdigit() + elif index in ('4', '7'): + valid = char == '-' + else: + valid = False + return valid + + def _focusout_validate(self, event): + valid = True + if not self.get(): + self.error.set('A value is required') + valid = False + try: + datetime.strptime(self.get(), '%Y-%m-%d') + except ValueError: + self.error.set('Invalid date') + valid = False + return valid + + +class RequiredEntry(ValidatedMixin, ttk.Entry): + + def _focusout_validate(self, event): + valid = True + if not self.get(): + valid = False + self.error.set('A value is required') + return valid + + +class ValidatedCombobox(ValidatedMixin, ttk.Combobox): + + def _key_validate(self, proposed, action, **kwargs): + valid = True + # if the user tries to delete, + # just clear the field + if action == '0': + self.set('') + return True + + # get our values list + values = self.cget('values') + # Do a case-insensitve match against the entered text + matching = [ + x for x in values + if x.lower().startswith(proposed.lower()) + ] + if len(matching) == 0: + valid = False + elif len(matching) == 1: + self.set(matching[0]) + self.icursor(tk.END) + valid = False + return valid + + def _focusout_validate(self, **kwargs): + valid = True + if not self.get(): + valid = False + self.error.set('A value is required') + return valid + + +class ValidatedSpinbox(ValidatedMixin, ttk.Spinbox): + """A Spinbox that only accepts Numbers""" + + def __init__(self, *args, min_var=None, max_var=None, + focus_update_var=None, from_='-Infinity', to='Infinity', **kwargs + ): + super().__init__(*args, from_=from_, to=to, **kwargs) + increment = Decimal(str(kwargs.get('increment', '1.0'))) + self.precision = increment.normalize().as_tuple().exponent + # there should always be a variable, + # or some of our code will fail + self.variable = kwargs.get('textvariable') + if not self.variable: + self.variable = tk.DoubleVar() + self.configure(textvariable=self.variable) + + if min_var: + self.min_var = min_var + self.min_var.trace_add('write', self._set_minimum) + if max_var: + self.max_var = max_var + self.max_var.trace_add('write', self._set_maximum) + self.focus_update_var = focus_update_var + self.bind('', self._set_focus_update_var) + + def _set_focus_update_var(self, event): + value = self.get() + if self.focus_update_var and not self.error.get(): + self.focus_update_var.set(value) + + def _set_minimum(self, *_): + current = self.get() + try: + new_min = self.min_var.get() + self.config(from_=new_min) + except (tk.TclError, ValueError): + pass + if not current: + self.delete(0, tk.END) + else: + self.variable.set(current) + self.trigger_focusout_validation() + + def _set_maximum(self, *_): + current = self.get() + try: + new_max = self.max_var.get() + self.config(to=new_max) + except (tk.TclError, ValueError): + pass + if not current: + self.delete(0, tk.END) + else: + self.variable.set(current) + self.trigger_focusout_validation() + + def _key_validate( + self, char, index, current, proposed, action, **kwargs + ): + if action == '0': + return True + valid = True + min_val = self.cget('from') + max_val = self.cget('to') + no_negative = min_val >= 0 + no_decimal = self.precision >= 0 + + # First, filter out obviously invalid keystrokes + if any([ + (char not in '-1234567890.'), + (char == '-' and (no_negative or index != '0')), + (char == '.' and (no_decimal or '.' in current)) + ]): + return False + + # At this point, proposed is either '-', '.', '-.', + # or a valid Decimal string + if proposed in '-.': + return True + + # Proposed is a valid Decimal string + # convert to Decimal and check more: + proposed = Decimal(proposed) + proposed_precision = proposed.as_tuple().exponent + + if any([ + (proposed > max_val), + (proposed_precision < self.precision) + ]): + return False + + return valid + + def _focusout_validate(self, **kwargs): + valid = True + value = self.get() + min_val = self.cget('from') + max_val = self.cget('to') + + try: + d_value = Decimal(value) + except InvalidOperation: + self.error.set(f'Invalid number string: {value}') + return False + + if d_value < min_val: + self.error.set(f'Value is too low (min {min_val})') + valid = False + if d_value > max_val: + self.error.set(f'Value is too high (max {max_val})') + valid = False + + return valid + +class ValidatedRadio(ttk.Radiobutton): + """A validated radio button""" + + def __init__(self, *args, error_var=None, **kwargs): + super().__init__(*args, **kwargs) + self.error = error_var or tk.StringVar() + self.variable = kwargs.get("variable") + self.bind('', self._focusout_validate) + + def _focusout_validate(self, *_): + self.error.set('') + if not self.variable.get(): + self.error.set('A value is required') + + def trigger_focusout_validation(self): + self._focusout_validate() + + +class BoundText(tk.Text): + """A Text widget with a bound variable.""" + + def __init__(self, *args, textvariable=None, **kwargs): + super().__init__(*args, **kwargs) + self._variable = textvariable + if self._variable: + # insert any default value + self.insert('1.0', self._variable.get()) + self._variable.trace_add('write', self._set_content) + self.bind('<>', self._set_var) + + def _set_var(self, *_): + """Set the variable to the text contents""" + if self.edit_modified(): + content = self.get('1.0', 'end-1chars') + self._variable.set(content) + self.edit_modified(False) + + def _set_content(self, *_): + """Set the text contents to the variable""" + self.delete('1.0', tk.END) + self.insert('1.0', self._variable.get()) + + +########################### +# Compound Widget Classes # +########################### + + +class LabelInput(ttk.Frame): + """A widget containing a label and input together.""" + + field_types = { + FT.string: RequiredEntry, + FT.string_list: ValidatedCombobox, + FT.short_string_list: ValidatedRadio, + FT.iso_date_string: DateEntry, + FT.long_string: BoundText, + FT.decimal: ValidatedSpinbox, + FT.integer: ValidatedSpinbox, + FT.boolean: ttk.Checkbutton + } + + def __init__( + self, parent, label, var, input_class=None, + input_args=None, label_args=None, field_spec=None, + disable_var=None, **kwargs + ): + super().__init__(parent, **kwargs) + input_args = input_args or {} + label_args = label_args or {} + self.variable = var + self.variable.label_widget = self + + # Process the field spec to determine input_class and validation + if field_spec: + field_type = field_spec.get('type', FT.string) + input_class = input_class or self.field_types.get(field_type) + # min, max, increment + if 'min' in field_spec and 'from_' not in input_args: + input_args['from_'] = field_spec.get('min') + if 'max' in field_spec and 'to' not in input_args: + input_args['to'] = field_spec.get('max') + if 'inc' in field_spec and 'increment' not in input_args: + input_args['increment'] = field_spec.get('inc') + # values + if 'values' in field_spec and 'values' not in input_args: + input_args['values'] = field_spec.get('values') + + # setup the label + if input_class in (ttk.Checkbutton, ttk.Button): + # Buttons don't need labels, they're built-in + input_args["text"] = label + else: + self.label = ttk.Label(self, text=label, **label_args) + self.label.grid(row=0, column=0, sticky=(tk.W + tk.E)) + + # setup the variable + if input_class in ( + ttk.Checkbutton, ttk.Button, ttk.Radiobutton, ValidatedRadio + ): + input_args["variable"] = self.variable + else: + input_args["textvariable"] = self.variable + + # Setup the input + if input_class in (ttk.Radiobutton, ValidatedRadio): + # for Radiobutton, create one input per value + self.input = tk.Frame(self) + for v in input_args.pop('values', []): + button = input_class( + self.input, value=v, text=v, **input_args + ) + button.pack(side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x') + self.input.error = getattr(button, 'error') + self.input.trigger_focusout_validation = \ + button._focusout_validate + else: + self.input = input_class(self, **input_args) + + self.input.grid(row=1, column=0, sticky=(tk.W + tk.E)) + self.columnconfigure(0, weight=1) + + # Set up error handling & display + error_style = 'Error.' + label_args.get('style', 'TLabel') + ttk.Style().configure(error_style, foreground='darkred') + self.error = getattr(self.input, 'error', tk.StringVar()) + ttk.Label(self, textvariable=self.error, style=error_style).grid( + row=2, column=0, sticky=(tk.W + tk.E) + ) + + # Set up disable variable + if disable_var: + self.disable_var = disable_var + self.disable_var.trace_add('write', self._check_disable) + + def _check_disable(self, *_): + if not hasattr(self, 'disable_var'): + return + + if self.disable_var.get(): + self.input.configure(state=tk.DISABLED) + self.variable.set('') + self.error.set('') + else: + self.input.configure(state=tk.NORMAL) + + def grid(self, sticky=(tk.E + tk.W), **kwargs): + """Override grid to add default sticky values""" + super().grid(sticky=sticky, **kwargs) diff --git a/Chapter10/ABQ_Data_Entry/docs/Application_layout.png b/Chapter10/ABQ_Data_Entry/docs/Application_layout.png new file mode 100644 index 0000000000000000000000000000000000000000..93990f232d19518ca465e7715bbf4f0f10cabce9 GIT binary patch literal 9117 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 mufwd~J*Rr~aor9m$$ITZW{DrBXJ6j(Rb zM#G-gCvI~r>pZU*Jw7wH$3yUPRITQMV9m1IXE--Md^sc9EdS!m>of0$O^iSFe&Sx^ z;@WVY)XNP8q7ROT6hCj7sPtumyT}eB@gow9i&!E*ZSQzl7+!Hh^M!a61A{dHvTj!K literal 0 HcmV?d00001 From f53c9c977613169f49fd74ae1c8c84ce1fbe0a7f Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Fri, 9 Jul 2021 11:16:26 -0500 Subject: [PATCH 21/32] Fixes to ch7 per previous chpater reviews --- .../ABQ_Data_Entry/abq_data_entry/models.py | 2 +- .../ABQ_Data_Entry/abq_data_entry/views.py | 14 +- .../ABQ_Data_Entry/abq_data_entry/widgets.py | 79 +++++----- .../docs/abq_data_entry_spec.rst | 145 ++++++++++-------- 4 files changed, 130 insertions(+), 110 deletions(-) diff --git a/Chapter07/ABQ_Data_Entry/abq_data_entry/models.py b/Chapter07/ABQ_Data_Entry/abq_data_entry/models.py index 7b4f837..8156c63 100644 --- a/Chapter07/ABQ_Data_Entry/abq_data_entry/models.py +++ b/Chapter07/ABQ_Data_Entry/abq_data_entry/models.py @@ -64,7 +64,7 @@ def save_record(self, data): """Save a dict of data to the CSV file""" newfile = not self.file.exists() - with open(self.file, 'a') as fh: + with open(self.file, 'a', newline='') as fh: csvwriter = csv.DictWriter(fh, fieldnames=self.fields.keys()) if newfile: csvwriter.writeheader() diff --git a/Chapter07/ABQ_Data_Entry/abq_data_entry/views.py b/Chapter07/ABQ_Data_Entry/abq_data_entry/views.py index 1f22abc..99b31ea 100644 --- a/Chapter07/ABQ_Data_Entry/abq_data_entry/views.py +++ b/Chapter07/ABQ_Data_Entry/abq_data_entry/views.py @@ -58,21 +58,21 @@ def __init__(self, parent, model, settings, *args, **kwargs): field_spec=fields['Time'], var=self._vars['Time'], ).grid(row=0, column=1) + w.LabelInput( + r_info, "Technician", + field_spec=fields['Technician'], + var=self._vars['Technician'], + ).grid(row=0, column=2) + # line 2 w.LabelInput( r_info, "Lab", field_spec=fields['Lab'], var=self._vars['Lab'], - ).grid(row=0, column=2) - # line 2 + ).grid(row=1, column=0) w.LabelInput( r_info, "Plot", field_spec=fields['Plot'], var=self._vars['Plot'], - ).grid(row=1, column=0) - w.LabelInput( - r_info, "Technician", - field_spec=fields['Technician'], - var=self._vars['Technician'], ).grid(row=1, column=1) w.LabelInput( r_info, "Seed Sample", diff --git a/Chapter07/ABQ_Data_Entry/abq_data_entry/widgets.py b/Chapter07/ABQ_Data_Entry/abq_data_entry/widgets.py index 14783c1..c99d674 100644 --- a/Chapter07/ABQ_Data_Entry/abq_data_entry/widgets.py +++ b/Chapter07/ABQ_Data_Entry/abq_data_entry/widgets.py @@ -190,7 +190,7 @@ def _set_focus_update_var(self, event): if self.focus_update_var and not self.error.get(): self.focus_update_var.set(value) - def _set_minimum(self, *args): + def _set_minimum(self, *_): current = self.get() try: new_min = self.min_var.get() @@ -203,7 +203,7 @@ def _set_minimum(self, *args): self.variable.set(current) self.trigger_focusout_validation() - def _set_maximum(self, *args): + def _set_maximum(self, *_): current = self.get() try: new_max = self.max_var.get() @@ -219,13 +219,13 @@ def _set_maximum(self, *args): def _key_validate( self, char, index, current, proposed, action, **kwargs ): + if action == '0': + return True valid = True min_val = self.cget('from') max_val = self.cget('to') no_negative = min_val >= 0 no_decimal = self.precision >= 0 - if action == '0': - return True # First, filter out obviously invalid keystrokes if any([ @@ -260,55 +260,61 @@ def _focusout_validate(self, **kwargs): max_val = self.cget('to') try: - value = Decimal(value) + d_value = Decimal(value) except InvalidOperation: - self.error.set('Invalid number string: {}'.format(value)) + self.error.set(f'Invalid number string: {value}') return False - if value < min_val: - self.error.set('Value is too low (min {})'.format(min_val)) + if d_value < min_val: + self.error.set(f'Value is too low (min {min_val})') + valid = False + if d_value > max_val: + self.error.set(f'Value is too high (max {max_val})') valid = False - if value > max_val: - self.error.set('Value is too high (max {})'.format(max_val)) return valid +class ValidatedRadio(ttk.Radiobutton): + """A validated radio button""" + + def __init__(self, *args, error_var=None, **kwargs): + super().__init__(*args, **kwargs) + self.error = error_var or tk.StringVar() + self.variable = kwargs.get("variable") + self.bind('', self._focusout_validate) + + def _focusout_validate(self, *_): + self.error.set('') + if not self.variable.get(): + self.error.set('A value is required') + + def trigger_focusout_validation(self): + self._focusout_validate() + + class BoundText(tk.Text): """A Text widget with a bound variable.""" def __init__(self, *args, textvariable=None, **kwargs): super().__init__(*args, **kwargs) self._variable = textvariable - self._modifying = False if self._variable: # insert any default value self.insert('1.0', self._variable.get()) self._variable.trace_add('write', self._set_content) self.bind('<>', self._set_var) - def _clear_modified_flag(self): - # This also triggers a '<>' Event - self.tk.call(self._w, 'edit', 'modified', 0) - def _set_var(self, *_): """Set the variable to the text contents""" - if self._modifying: - return - self._modifying = True - # remove trailing newline from content - content = self.get('1.0', tk.END)[:-1] - self._variable.set(content) - self._clear_modified_flag() - self._modifying = False + if self.edit_modified(): + content = self.get('1.0', 'end-1chars') + self._variable.set(content) + self.edit_modified(False) def _set_content(self, *_): """Set the text contents to the variable""" - if self._modifying: - return - self._modifying = True self.delete('1.0', tk.END) self.insert('1.0', self._variable.get()) - self._modifying = False ########################### @@ -322,7 +328,7 @@ class LabelInput(ttk.Frame): field_types = { FT.string: RequiredEntry, FT.string_list: ValidatedCombobox, - FT.short_string_list: ttk.Radiobutton, + FT.short_string_list: ValidatedRadio, FT.iso_date_string: DateEntry, FT.long_string: BoundText, FT.decimal: ValidatedSpinbox, @@ -365,22 +371,25 @@ def __init__( self.label.grid(row=0, column=0, sticky=(tk.W + tk.E)) # setup the variable - if input_class in (ttk.Checkbutton, ttk.Button, ttk.Radiobutton): + if input_class in ( + ttk.Checkbutton, ttk.Button, ttk.Radiobutton, ValidatedRadio + ): input_args["variable"] = self.variable else: input_args["textvariable"] = self.variable # Setup the input - if input_class == ttk.Radiobutton: + if input_class in (ttk.Radiobutton, ValidatedRadio): # for Radiobutton, create one input per value self.input = tk.Frame(self) for v in input_args.pop('values', []): - button = ttk.Radiobutton( - self.input, value=v, text=v, **input_args) + button = input_class( + self.input, value=v, text=v, **input_args + ) button.pack(side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x') - else: - self.input = input_class(self, **input_args) - + self.input.error = getattr(button, 'error') + self.input.trigger_focusout_validation = \ + button._focusout_validate self.input.grid(row=1, column=0, sticky=(tk.W + tk.E)) self.columnconfigure(0, weight=1) diff --git a/Chapter07/ABQ_Data_Entry/docs/abq_data_entry_spec.rst b/Chapter07/ABQ_Data_Entry/docs/abq_data_entry_spec.rst index ffd762e..349be50 100644 --- a/Chapter07/ABQ_Data_Entry/docs/abq_data_entry_spec.rst +++ b/Chapter07/ABQ_Data_Entry/docs/abq_data_entry_spec.rst @@ -2,95 +2,106 @@ ABQ Data Entry Program specification ====================================== - Description ----------- -The program is being created to minimize data entry errors for laboratory measurements. +This program facilitates entry of laboratory observations +into a CSV file. -Functionality Required ----------------------- +Requirements +------------ -The program must: +Functional Requirements: -* allow all relevant, valid data to be entered, as per the field chart -* append entered data to a CSV file - - The CSV file must have a filename of abq_data_record_CURRENTDATE.csv, - where CURRENTDATE is the date of the checks in ISO format (Year-month-day) - - The CSV file must have all the fields as per the chart -* enforce correct datatypes per field -* have inputs that: + * Allow all relevant, valid data to be entered, + as per the data dictionary. + * Append entered data to a CSV file: + - The CSV file must have a filename of + abq_data_record_CURRENTDATE.csv, where CURRENTDATE is the date + of the laboratory observations in ISO format (Year-month-day). + - The CSV file must include all fields. + listed in the data dictionary. + - The CSV headers will avoid cryptic abbreviations. + * Enforce correct datatypes per field. + * have inputs that: - ignore meaningless keystrokes - display an error if the value is invalid on focusout - display an error if a required field is empty on focusout -* prevent saving the record when errors are present + * prevent saving the record when errors are present -The program should try, whenever possible, to: +Non-functional Requirements: -* enforce reasonable limits on data entered -* Auto-fill data -* Suggest likely correct values -* Provide a smooth and efficient workflow + * Enforce reasonable limits on data entered, per the data dict. + * Auto-fill data to save time. + * Suggest likely correct values. + * Provide a smooth and efficient workflow. + * Store data in a format easily understandable by Python. Functionality Not Required -------------------------- The program does not need to: -* Allow editing of data. This can be done in LibreOffice if necessary. -* Allow deletion of data. + * Allow editing of data. + * Allow deletion of data. + +Users can perform both actions in LibreOffice if needed. + Limitations ----------- The program must: -* Be efficiently operable by keyboard-only users. -* Be accessible to color blind users. -* Run on Debian Linux. -* Run acceptably on a low-end PC. + * Be efficiently operable by keyboard-only users. + * Be accessible to color blind users. + * Run on Debian GNU/Linux. + * Run acceptably on a low-end PC. Data Dictionary --------------- -+------------+----------+------+------------------+--------------------------+ -|Field | Datatype | Units| Range |Descripton | -+============+==========+======+==================+==========================+ -|Date |Date | | |Date of record | -+------------+----------+------+------------------+--------------------------+ -|Time |Time | |8:00, 12:00, |Time period | -| | | |16:00, or 20:00 | | -+------------+----------+------+------------------+--------------------------+ -|Lab |String | | A - C |Lab ID | -+------------+----------+------+------------------+--------------------------+ -|Technician |String | | |Technician name | -+------------+----------+------+------------------+--------------------------+ -|Plot |Int | | 1 - 20 |Plot ID | -+------------+----------+------+------------------+--------------------------+ -|Seed |String | | |Seed sample ID | -|sample | | | | | -+------------+----------+------+------------------+--------------------------+ -|Fault |Bool | | |Fault on environmental | -| | | | |sensor | -+------------+----------+------+------------------+--------------------------+ -|Light |Decimal |klx | 0 - 100 |Light at plot | -+------------+----------+------+------------------+--------------------------+ -|Humidity |Decimal |g/m³ | 0.5 - 52.0 |Abs humidity at plot | -+------------+----------+------+------------------+--------------------------+ -|Temperature |Decimal |°C | 4 - 40 |Temperature at plot | -+------------+----------+------+------------------+--------------------------+ -|Blossoms |Int | | 0 - 1000 |Number of blossoms in plot| -+------------+----------+------+------------------+--------------------------+ -|Fruit |Int | | 0 - 1000 |Number of fruits in plot | -+------------+----------+------+------------------+--------------------------+ -|Plants |Int | | 0 - 20 |Number of plants in plot | -+------------+----------+------+------------------+--------------------------+ -|Max height |Decimal |cm | 0 - 1000 |Height of tallest plant in| -| | | | |plot | -+------------+----------+------+------------------+--------------------------+ -|Min height |Decimal |cm | 0 - 1000 |Height of shortest plant | -| | | | |in plot | -+------------+----------+------+------------------+--------------------------+ -|Median |Decimal |cm | 0 - 1000 |Median height of plants in| -|height | | | |plot | -+------------+----------+------+------------------+--------------------------+ -|Notes |String | | |Miscellaneous notes | -+------------+----------+------+------------------+--------------------------+ ++------------+--------+----+---------------+--------------------+ +|Field | Type |Unit| Valid Values |Description | ++============+========+====+===============+====================+ +|Date |Date | | |Date of record | ++------------+--------+----+---------------+--------------------+ +|Time |Time | |8:00, 12:00, |Time period | +| | | |16:00, or 20:00| | ++------------+--------+----+---------------+--------------------+ +|Lab |String | | A - C |Lab ID | ++------------+--------+----+---------------+--------------------+ +|Technician |String | | |Technician name | ++------------+--------+----+---------------+--------------------+ +|Plot |Int | | 1 - 20 |Plot ID | ++------------+--------+----+---------------+--------------------+ +|Seed |String | | 6-character |Seed sample ID | +|Sample | | | string | | ++------------+--------+----+---------------+--------------------+ +|Fault |Bool | | True, False |Environmental | +| | | | |sensor fault | ++------------+--------+----+---------------+--------------------+ +|Light |Decimal |klx | 0 - 100 |Light at plot | +| | | | |blank on fault | ++------------+--------+----+---------------+--------------------+ +|Humidity |Decimal |g/m³| 0.5 - 52.0 |Abs humidity at plot| +| | | | |blank on fault | ++------------+--------+----+---------------+--------------------+ +|Temperature |Decimal |°C | 4 - 40 |Temperature at plot | +| | | | |blank on fault | ++------------+--------+----+---------------+--------------------+ +|Blossoms |Int | | 0 - 1000 |No. blossoms in plot| ++------------+--------+----+---------------+--------------------+ +|Fruit |Int | | 0 - 1000 |No. fruits in plot | ++------------+--------+----+---------------+--------------------+ +|Plants |Int | | 0 - 20 |No. plants in plot | ++------------+--------+----+---------------+--------------------+ +|Max height |Decimal |cm | 0 - 1000 |Height of tallest | +| | | | |plant in plot | ++------------+--------+----+---------------+--------------------+ +|Min height |Decimal |cm | 0 - 1000 |Height of shortest | +| | | | |plant in plot | ++------------+--------+----+---------------+--------------------+ +|Median |Decimal |cm | 0 - 1000 |Median height of | +|height | | | |plants in plot | ++------------+--------+----+---------------+--------------------+ +|Notes |String | | |Miscellaneous notes | ++------------+--------+----+---------------+--------------------+ From 1481314eb54ec3286fdaba49db87889d0cbec929 Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Tue, 13 Jul 2021 13:38:36 -0500 Subject: [PATCH 22/32] update application per ch5 revisions --- Chapter05/data_entry_app.py | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/Chapter05/data_entry_app.py b/Chapter05/data_entry_app.py index f2098a3..6b16d86 100644 --- a/Chapter05/data_entry_app.py +++ b/Chapter05/data_entry_app.py @@ -283,22 +283,32 @@ def _focusout_validate(self, **kwargs): return valid -class ValidatedRadio(ttk.Radiobutton): - """A validated radio button""" - def __init__(self, *args, error_var=None, **kwargs): +class ValidatedRadioGroup(ttk.Frame): + """A validated radio button group""" + + def __init__( + self, *args, variable=None, error_var=None, + values=None, button_args=None, **kwargs + ): super().__init__(*args, **kwargs) + self.variable = variable or tk.StringVar() self.error = error_var or tk.StringVar() - self.variable = kwargs.get("variable") - self.bind('', self._focusout_validate) + self.values = values or list() + button_args = button_args or dict() - def _focusout_validate(self, *_): + for v in self.values: + button = ttk.Radiobutton( + self, value=v, text=v, variable=self.variable, **button_args + ) + button.pack(side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x') + self.bind('', self.trigger_focusout_validation) + + def trigger_focusout_validation(self, *_): self.error.set('') if not self.variable.get(): self.error.set('A value is required') - def trigger_focusout_validation(self): - self._focusout_validate() class BoundText(tk.Text): """A Text widget with a bound variable.""" @@ -353,14 +363,15 @@ def __init__( # setup the variable if input_class in ( - ttk.Checkbutton, ttk.Button, ttk.Radiobutton, ValidatedRadio + ttk.Checkbutton, ttk.Button, + ttk.Radiobutton, ValidatedRadioGroup ): input_args["variable"] = self.variable else: input_args["textvariable"] = self.variable # Setup the input - if input_class in (ttk.Radiobutton, ValidatedRadio): + if input_class == ttk.Radiobutton: # for Radiobutton, create one input per value self.input = tk.Frame(self) for v in input_args.pop('values', []): @@ -368,8 +379,6 @@ def __init__( self.input, value=v, text=v, **input_args ) button.pack(side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x') - self.input.error = getattr(button, 'error') - self.input.trigger_focusout_validation = button.trigger_focusout_validation else: self.input = input_class(self, **input_args) self.input.grid(row=1, column=0, sticky=(tk.W + tk.E)) @@ -460,7 +469,7 @@ def __init__(self, *args, **kwargs): # line 2 LabelInput( - r_info, "Lab", input_class=ValidatedRadio, + r_info, "Lab", input_class=ValidatedRadioGroup, var=self._vars['Lab'], input_args={"values": ["A", "B", "C"]} ).grid(row=1, column=0) LabelInput( From 7e88f60e8f6eab62d3cca790954dd7c25ebd224c Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Wed, 14 Jul 2021 10:17:36 -0500 Subject: [PATCH 23/32] changes per chapter 6 revisions --- Chapter05/data_entry_app.py | 4 +- .../abq_data_entry/application.py | 6 +-- .../ABQ_Data_Entry/abq_data_entry/widgets.py | 41 ++++++++++++++++--- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/Chapter05/data_entry_app.py b/Chapter05/data_entry_app.py index 6b16d86..5137f6c 100644 --- a/Chapter05/data_entry_app.py +++ b/Chapter05/data_entry_app.py @@ -568,7 +568,7 @@ def __init__(self, *args, **kwargs): buttons = tk.Frame(self) buttons.grid(sticky=tk.W + tk.E, row=4) self.savebutton = ttk.Button( - buttons, text="Save", command=self.master.on_save) + buttons, text="Save", command=self.master._on_save) self.savebutton.pack(side=tk.RIGHT) self.resetbutton = ttk.Button( @@ -682,7 +682,7 @@ def __init__(self, *args, **kwargs): self._records_saved = 0 - def on_save(self): + def _on_save(self): """Handles save button clicks""" # Check for errors first diff --git a/Chapter06/ABQ_Data_Entry/abq_data_entry/application.py b/Chapter06/ABQ_Data_Entry/abq_data_entry/application.py index 1b99be6..59d7bad 100644 --- a/Chapter06/ABQ_Data_Entry/abq_data_entry/application.py +++ b/Chapter06/ABQ_Data_Entry/abq_data_entry/application.py @@ -33,7 +33,7 @@ def __init__(self, *args, **kwargs): self.statusbar = ttk.Label(self, textvariable=self.status) self.statusbar.grid(sticky=(tk.W + tk.E), row=3, padx=10) - self.records_saved = 0 + self._records_saved = 0 def _on_save(self, *_): """Handles file-save requests""" @@ -50,8 +50,8 @@ def _on_save(self, *_): data = self.recordform.get() self.model.save_record(data) - self.records_saved += 1 + self._records_saved += 1 self.status.set( - f"{self.records_saved} records saved this session" + f"{self._records_saved} records saved this session" ) self.recordform.reset() diff --git a/Chapter06/ABQ_Data_Entry/abq_data_entry/widgets.py b/Chapter06/ABQ_Data_Entry/abq_data_entry/widgets.py index 954a656..dfdba6f 100644 --- a/Chapter06/ABQ_Data_Entry/abq_data_entry/widgets.py +++ b/Chapter06/ABQ_Data_Entry/abq_data_entry/widgets.py @@ -274,6 +274,35 @@ def _focusout_validate(self, **kwargs): return valid +class ValidatedRadioGroup(ttk.Frame): + """A validated radio button group""" + + def __init__( + self, *args, variable=None, error_var=None, + values=None, button_args=None, **kwargs + ): + super().__init__(*args, **kwargs) + self.variable = variable or tk.StringVar() + self.error = error_var or tk.StringVar() + self.values = values or list() + self.button_args = button_args or dict() + + for v in self.values: + button = ttk.Radiobutton( + self, value=v, text=v, + variable=self.variable, **self.button_args + ) + button.pack( + side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x' + ) + self.bind('', self.trigger_focusout_validation) + + def trigger_focusout_validation(self, *_): + self.error.set('') + if not self.variable.get(): + self.error.set('A value is required') + + class BoundText(tk.Text): """A Text widget with a bound variable.""" @@ -310,7 +339,7 @@ class LabelInput(ttk.Frame): field_types = { FT.string: RequiredEntry, FT.string_list: ValidatedCombobox, - FT.short_string_list: ttk.Radiobutton, + FT.short_string_list: ValidatedRadioGroup, FT.iso_date_string: DateEntry, FT.long_string: BoundText, FT.decimal: ValidatedSpinbox, @@ -353,7 +382,9 @@ def __init__( self.label.grid(row=0, column=0, sticky=(tk.W + tk.E)) # setup the variable - if input_class in (ttk.Checkbutton, ttk.Button, ttk.Radiobutton): + if input_class in ( + ttk.Checkbutton, ttk.Button, ttk.Radiobutton, ValidatedRadioGroup + ): input_args["variable"] = self.variable else: input_args["textvariable"] = self.variable @@ -363,12 +394,12 @@ def __init__( # for Radiobutton, create one input per value self.input = tk.Frame(self) for v in input_args.pop('values', []): - button = ttk.Radiobutton( - self.input, value=v, text=v, **input_args) + button = input_class( + self.input, value=v, text=v, **input_args + ) button.pack(side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x') else: self.input = input_class(self, **input_args) - self.input.grid(row=1, column=0, sticky=(tk.W + tk.E)) self.columnconfigure(0, weight=1) From 885abea5970d3ebecb0332dc207cd2a9bda31a4f Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Thu, 15 Jul 2021 10:31:51 -0500 Subject: [PATCH 24/32] Fixes per chapter revision --- Chapter07/ABQ_Data_Entry/abq_data_entry/models.py | 5 ++--- Chapter07/menu_demo.py | 6 ++++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Chapter07/ABQ_Data_Entry/abq_data_entry/models.py b/Chapter07/ABQ_Data_Entry/abq_data_entry/models.py index 8156c63..6fe7434 100644 --- a/Chapter07/ABQ_Data_Entry/abq_data_entry/models.py +++ b/Chapter07/ABQ_Data_Entry/abq_data_entry/models.py @@ -100,9 +100,8 @@ def set(self, key, value): def save(self): """Save the current settings to the file""" - json_string = json.dumps(self.fields) with open(self.filepath, 'w') as fh: - fh.write(json_string) + json.dump(self.fields, fh) def load(self): """Load the settings from the file""" @@ -113,7 +112,7 @@ def load(self): # open the file and read in the raw values with open(self.filepath, 'r') as fh: - raw_values = json.loads(fh.read()) + raw_values = json.load(fh) # don't implicitly trust the raw values, but only get known keys for key in self.fields: diff --git a/Chapter07/menu_demo.py b/Chapter07/menu_demo.py index 4ce6548..f852241 100644 --- a/Chapter07/menu_demo.py +++ b/Chapter07/menu_demo.py @@ -34,8 +34,8 @@ # Add the appearance menu # ########################### -font_bold = tk.BooleanVar() -font_size = tk.IntVar() +font_bold = tk.BooleanVar(value=False) +font_size = tk.IntVar(value=10) def set_font(*args): size = font_size.get() @@ -45,6 +45,8 @@ def set_font(*args): font_bold.trace_add('write', set_font) font_size.trace_add('write', set_font) +set_font() + # Create appearance menu appearance_menu = tk.Menu(main_menu, tearoff=False) From 06496b5a066a3818bc8deabfe54dbbc854592779 Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Thu, 22 Jul 2021 13:45:37 -0500 Subject: [PATCH 25/32] Chapter 8 updates per feedback --- .../abq_data_entry/application.py | 10 +-- .../ABQ_Data_Entry/abq_data_entry/models.py | 7 +- .../ABQ_Data_Entry/abq_data_entry/views.py | 27 ++++--- .../ABQ_Data_Entry/abq_data_entry/widgets.py | 80 +++++++++++-------- Chapter08/hierarchy_example.py | 32 ++++++++ Chapter08/notebook_demo.py | 2 +- Chapter08/treeview_demo.py | 16 +++- 7 files changed, 118 insertions(+), 56 deletions(-) create mode 100644 Chapter08/hierarchy_example.py diff --git a/Chapter08/ABQ_Data_Entry/abq_data_entry/application.py b/Chapter08/ABQ_Data_Entry/abq_data_entry/application.py index f25eb37..c08285a 100644 --- a/Chapter08/ABQ_Data_Entry/abq_data_entry/application.py +++ b/Chapter08/ABQ_Data_Entry/abq_data_entry/application.py @@ -199,12 +199,12 @@ def _populate_recordlist(self): def _new_record(self, *_): """Open the record form with a blank record""" - self.recordform.load_record(None, None) + self.recordform.load_record(None) self.notebook.select(self.recordform) def _open_record(self, *_): - """Open the Record selected recordlist id in the recordform""" + """Open the selected id from recordlist in the recordform""" rowkey = self.recordlist.selected_id try: record = self.model.get_record(rowkey) @@ -212,6 +212,6 @@ def _open_record(self, *_): messagebox.showerror( title='Error', message='Problem reading file', detail=str(e) ) - return - self.recordform.load_record(rowkey, record) - self.notebook.select(self.recordform) + else: + self.recordform.load_record(rowkey, record) + self.notebook.select(self.recordform) diff --git a/Chapter08/ABQ_Data_Entry/abq_data_entry/models.py b/Chapter08/ABQ_Data_Entry/abq_data_entry/models.py index 8cb1c93..cf5e158 100644 --- a/Chapter08/ABQ_Data_Entry/abq_data_entry/models.py +++ b/Chapter08/ABQ_Data_Entry/abq_data_entry/models.py @@ -62,23 +62,20 @@ def __init__(self, filename=None): def save_record(self, data, rownum=None): """Save a dict of data to the CSV file""" - print(f"save row: {data}") if rownum is None: - print('save new record') # This is a new record newfile = not self.file.exists() - with open(self.file, 'a') as fh: + with open(self.file, 'a', newline='') as fh: csvwriter = csv.DictWriter(fh, fieldnames=self.fields.keys()) if newfile: csvwriter.writeheader() csvwriter.writerow(data) else: - print(f'update record {rownum}') # This is an update records = self.get_all_records() records[rownum] = data - with open(self.file, 'w', encoding='utf-8') as fh: + with open(self.file, 'w', encoding='utf-8', newline='') as fh: csvwriter = csv.DictWriter(fh, fieldnames=self.fields.keys()) csvwriter.writeheader() csvwriter.writerows(records) diff --git a/Chapter08/ABQ_Data_Entry/abq_data_entry/views.py b/Chapter08/ABQ_Data_Entry/abq_data_entry/views.py index 467c379..85fae58 100644 --- a/Chapter08/ABQ_Data_Entry/abq_data_entry/views.py +++ b/Chapter08/ABQ_Data_Entry/abq_data_entry/views.py @@ -66,21 +66,21 @@ def __init__(self, parent, model, settings, *args, **kwargs): field_spec=fields['Time'], var=self._vars['Time'], ).grid(row=0, column=1) + w.LabelInput( + r_info, "Technician", + field_spec=fields['Technician'], + var=self._vars['Technician'], + ).grid(row=0, column=2) + # line 2 w.LabelInput( r_info, "Lab", field_spec=fields['Lab'], var=self._vars['Lab'], - ).grid(row=0, column=2) - # line 2 + ).grid(row=1, column=0) w.LabelInput( r_info, "Plot", field_spec=fields['Plot'], var=self._vars['Plot'], - ).grid(row=1, column=0) - w.LabelInput( - r_info, "Technician", - field_spec=fields['Technician'], - var=self._vars['Technician'], ).grid(row=1, column=1) w.LabelInput( r_info, "Seed Sample", @@ -170,7 +170,7 @@ def __init__(self, parent, model, settings, *args, **kwargs): # Notes section -- Update grid row value for ch8 w.LabelInput( self, "Notes", field_spec=fields['Notes'], - var=self._vars['Notes'], input_args={"width": 85, "height": 10} + var=self._vars['Notes'], input_args={"width": 85, "height": 6} ).grid(sticky="nsew", row=4, column=0, padx=10, pady=10) # buttons @@ -369,7 +369,7 @@ def __init__(self, parent, *args, **kwargs): self.treeview.column( name, anchor=anchor, minwidth=minwidth, width=width, stretch=stretch - ) + ) self.treeview.bind('', self._on_open_record) self.treeview.bind('', self._on_open_record) @@ -389,7 +389,7 @@ def populate(self, rows): for row in self.treeview.get_children(): self.treeview.delete(row) - cids = list(self.column_defs.keys())[1:] + cids = self.treeview.cget('columns') for rownum, rowdata in enumerate(rows): values = [rowdata[cid] for cid in cids] self.treeview.insert('', 'end', iid=str(rownum), @@ -401,6 +401,9 @@ def populate(self, rows): self.treeview.focus('0') def _on_open_record(self, *args): - - self.selected_id = int(self.treeview.selection()[0]) self.event_generate('<>') + + @property + def selected_id(self): + selection = self.treeview.selection() + return int(selection[0]) if selection else None diff --git a/Chapter08/ABQ_Data_Entry/abq_data_entry/widgets.py b/Chapter08/ABQ_Data_Entry/abq_data_entry/widgets.py index 14783c1..933f945 100644 --- a/Chapter08/ABQ_Data_Entry/abq_data_entry/widgets.py +++ b/Chapter08/ABQ_Data_Entry/abq_data_entry/widgets.py @@ -190,7 +190,7 @@ def _set_focus_update_var(self, event): if self.focus_update_var and not self.error.get(): self.focus_update_var.set(value) - def _set_minimum(self, *args): + def _set_minimum(self, *_): current = self.get() try: new_min = self.min_var.get() @@ -203,7 +203,7 @@ def _set_minimum(self, *args): self.variable.set(current) self.trigger_focusout_validation() - def _set_maximum(self, *args): + def _set_maximum(self, *_): current = self.get() try: new_max = self.max_var.get() @@ -219,13 +219,13 @@ def _set_maximum(self, *args): def _key_validate( self, char, index, current, proposed, action, **kwargs ): + if action == '0': + return True valid = True min_val = self.cget('from') max_val = self.cget('to') no_negative = min_val >= 0 no_decimal = self.precision >= 0 - if action == '0': - return True # First, filter out obviously invalid keystrokes if any([ @@ -260,55 +260,69 @@ def _focusout_validate(self, **kwargs): max_val = self.cget('to') try: - value = Decimal(value) + d_value = Decimal(value) except InvalidOperation: - self.error.set('Invalid number string: {}'.format(value)) + self.error.set(f'Invalid number string: {value}') return False - if value < min_val: - self.error.set('Value is too low (min {})'.format(min_val)) + if d_value < min_val: + self.error.set(f'Value is too low (min {min_val})') + valid = False + if d_value > max_val: + self.error.set(f'Value is too high (max {max_val})') valid = False - if value > max_val: - self.error.set('Value is too high (max {})'.format(max_val)) return valid +class ValidatedRadioGroup(ttk.Frame): + """A validated radio button group""" + + def __init__( + self, *args, variable=None, error_var=None, + values=None, button_args=None, **kwargs + ): + super().__init__(*args, **kwargs) + self.variable = variable or tk.StringVar() + self.error = error_var or tk.StringVar() + self.values = values or list() + button_args = button_args or dict() + + for v in self.values: + button = ttk.Radiobutton( + self, value=v, text=v, variable=self.variable, **button_args + ) + button.pack(side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x') + self.bind('', self.trigger_focusout_validation) + + def trigger_focusout_validation(self, *_): + self.error.set('') + if not self.variable.get(): + self.error.set('A value is required') + + class BoundText(tk.Text): """A Text widget with a bound variable.""" def __init__(self, *args, textvariable=None, **kwargs): super().__init__(*args, **kwargs) self._variable = textvariable - self._modifying = False if self._variable: # insert any default value self.insert('1.0', self._variable.get()) self._variable.trace_add('write', self._set_content) self.bind('<>', self._set_var) - def _clear_modified_flag(self): - # This also triggers a '<>' Event - self.tk.call(self._w, 'edit', 'modified', 0) - def _set_var(self, *_): """Set the variable to the text contents""" - if self._modifying: - return - self._modifying = True - # remove trailing newline from content - content = self.get('1.0', tk.END)[:-1] - self._variable.set(content) - self._clear_modified_flag() - self._modifying = False + if self.edit_modified(): + content = self.get('1.0', 'end-1chars') + self._variable.set(content) + self.edit_modified(False) def _set_content(self, *_): """Set the text contents to the variable""" - if self._modifying: - return - self._modifying = True self.delete('1.0', tk.END) self.insert('1.0', self._variable.get()) - self._modifying = False ########################### @@ -322,7 +336,7 @@ class LabelInput(ttk.Frame): field_types = { FT.string: RequiredEntry, FT.string_list: ValidatedCombobox, - FT.short_string_list: ttk.Radiobutton, + FT.short_string_list: ValidatedRadioGroup, FT.iso_date_string: DateEntry, FT.long_string: BoundText, FT.decimal: ValidatedSpinbox, @@ -365,7 +379,9 @@ def __init__( self.label.grid(row=0, column=0, sticky=(tk.W + tk.E)) # setup the variable - if input_class in (ttk.Checkbutton, ttk.Button, ttk.Radiobutton): + if input_class in ( + ttk.Checkbutton, ttk.Button, ttk.Radiobutton, ValidatedRadioGroup + ): input_args["variable"] = self.variable else: input_args["textvariable"] = self.variable @@ -375,12 +391,12 @@ def __init__( # for Radiobutton, create one input per value self.input = tk.Frame(self) for v in input_args.pop('values', []): - button = ttk.Radiobutton( - self.input, value=v, text=v, **input_args) + button = input_class( + self.input, value=v, text=v, **input_args + ) button.pack(side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x') else: self.input = input_class(self, **input_args) - self.input.grid(row=1, column=0, sticky=(tk.W + tk.E)) self.columnconfigure(0, weight=1) diff --git a/Chapter08/hierarchy_example.py b/Chapter08/hierarchy_example.py new file mode 100644 index 0000000..607b80e --- /dev/null +++ b/Chapter08/hierarchy_example.py @@ -0,0 +1,32 @@ +import tkinter as tk +from tkinter import ttk + +berries = [ + {'number': '1', 'parent': '', 'value': 'Raspberry'}, + {'number': '4', 'parent': '1', 'value': 'Red Raspberry'}, + {'number': '5', 'parent': '1', 'value': 'Blackberry'}, + {'number': '2', 'parent': '', 'value': 'Banana'}, + {'number': '3', 'parent': '', 'value': 'Strawberry'} +] + +root = tk.Tk() + +tv = ttk.Treeview(root, columns=['value']) +tv.heading('#0', text='Node') +tv.heading('value', text='Value') +tv.grid(sticky='news') + +for berry in berries: + tv.insert( + berry['parent'], + 'end', + iid=berry['number'], + text=berry['number'], + values=[berry['value']] + ) + + + + + +root.mainloop() diff --git a/Chapter08/notebook_demo.py b/Chapter08/notebook_demo.py index a72d429..a5bf102 100644 --- a/Chapter08/notebook_demo.py +++ b/Chapter08/notebook_demo.py @@ -14,7 +14,7 @@ banana_facts = [ 'Banana trees are of the genus Musa.', 'Bananas are technically berries.', - 'All bananas contain small amounts of radioactive postssium.' + 'All bananas contain small amounts of radioactive potassium.' 'Bananas are used in paper and textile manufacturing.' ] diff --git a/Chapter08/treeview_demo.py b/Chapter08/treeview_demo.py index 3879710..bcd07c9 100644 --- a/Chapter08/treeview_demo.py +++ b/Chapter08/treeview_demo.py @@ -11,7 +11,7 @@ # Create and configure treeview tv = ttk.Treeview( - root, columns=['size', 'modified'], selectmode='none' + root, columns=['size', 'modified'], selectmode='none' ) tv.heading('#0', text='Name') tv.heading('size', text='Size', anchor='center') @@ -65,4 +65,18 @@ def sort(tv, col, parent='', reverse=False): tv.heading(cid, command=lambda col=cid: sort(tv, col)) +status = tk.StringVar() +tk.Label(root, textvariable=status).pack(side=tk.BOTTOM) + +def show_directory_stats(*_): + clicked_path = Path(tv.focus()) + num_children = len(list(clicked_path.iterdir())) + status.set( + f'Directory: {clicked_path.name}, {num_children} children' + ) + + +tv.bind('<>', show_directory_stats) +tv.bind('<>', lambda _: status.set('')) + root.mainloop() From 30d48c387bd18652f9ffd45c0478e03a76b36f31 Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Wed, 28 Jul 2021 20:15:21 -0500 Subject: [PATCH 26/32] changes per ch9 review --- .../abq_data_entry/application.py | 31 +++++++----------- .../ABQ_Data_Entry/abq_data_entry/views.py | 29 +++++++++++++---- .../ABQ_Data_Entry/abq_data_entry/widgets.py | 32 ++++++++++++------- 3 files changed, 54 insertions(+), 38 deletions(-) diff --git a/Chapter09/ABQ_Data_Entry/abq_data_entry/application.py b/Chapter09/ABQ_Data_Entry/abq_data_entry/application.py index 0bdb0b7..f4fc3f2 100644 --- a/Chapter09/ABQ_Data_Entry/abq_data_entry/application.py +++ b/Chapter09/ABQ_Data_Entry/abq_data_entry/application.py @@ -21,6 +21,10 @@ def __init__(self, *args, **kwargs): # Hide window while GUI is built self.withdraw() + # Set taskbar icon + self.taskbar_icon = tk.PhotoImage(file=images.ABQ_LOGO_64) + self.iconphoto(True, self.taskbar_icon) + # Authenticate if not self._show_login(): self.destroy() @@ -32,24 +36,14 @@ def __init__(self, *args, **kwargs): # Create model self.model = m.CSVModel() - # Load settings - # self.settings = { - # 'autofill date': tk.BooleanVar(), - # 'autofill sheet data': tk.BoleanVar() - # } + # load settings self.settings_model = m.SettingsModel() self._load_settings() - self.inserted_rows = [] - self.updated_rows = [] - # Begin building GUI self.title("ABQ Data Entry Application") self.columnconfigure(0, weight=1) - # Set taskbar icon - self.taskbar_icon = tk.PhotoImage(file=images.ABQ_LOGO_64) - self.iconphoto(True, self.taskbar_icon) # Create the menu menu = MainMenu(self, self.settings) @@ -91,9 +85,7 @@ def __init__(self, *args, **kwargs): # The data record list self.recordlist_icon = tk.PhotoImage(file=images.LIST_ICON) - self.recordlist = v.RecordList( - self, self.inserted_rows, self.updated_rows - ) + self.recordlist = v.RecordList(self) self.notebook.insert( 0, self.recordlist, text='Records', image=self.recordlist_icon, compound=tk.LEFT @@ -139,10 +131,10 @@ def _on_save(self, *_): rownum = self.recordform.current_record self.model.save_record(data, rownum) if rownum is not None: - self.updated_rows.append(rownum) + self.recordlist.add_updated_row(rownum) else: rownum = len(self.model.get_all_records()) -1 - self.inserted_rows.append(rownum) + self.recordlist.add_inserted_row(rownum) self.records_saved += 1 self.status.set( "{} records saved this session".format(self.records_saved) @@ -160,9 +152,8 @@ def _on_file_select(self, *_): ) if filename: self.model = m.CSVModel(filename=filename) - self.inserted_rows.clear() - self.updated_rows.clear() self._populate_recordlist() + self.recordlist.clear_tags() @staticmethod def _simple_login(username, password): @@ -260,7 +251,9 @@ def _set_font(self, *_): """Set the application's font""" font_size = self.settings['font size'].get() font_family = self.settings['font family'].get() - font_names = ('TkDefaultFont', 'TkMenuFont', 'TkTextFont') + font_names = ( + 'TkDefaultFont', 'TkMenuFont', 'TkTextFont', 'TkFixedFont' + ) for font_name in font_names: tk_font = font.nametofont(font_name) tk_font.config(size=font_size, family=font_family) diff --git a/Chapter09/ABQ_Data_Entry/abq_data_entry/views.py b/Chapter09/ABQ_Data_Entry/abq_data_entry/views.py index 24b0c40..33f6df3 100644 --- a/Chapter09/ABQ_Data_Entry/abq_data_entry/views.py +++ b/Chapter09/ABQ_Data_Entry/abq_data_entry/views.py @@ -126,14 +126,16 @@ def __init__(self, parent, model, settings, *args, **kwargs): field_spec=fields['Lab'], var=self._vars['Lab'], label_args={'style': 'RecordInfo.TLabel'}, - input_args={'style': 'RecordInfo.TRadiobutton'} + input_args={ + 'button_args':{'style': 'RecordInfo.TRadiobutton'} + } ).grid(row=1, column=0) w.LabelInput( r_info, "Plot", field_spec=fields['Plot'], var=self._vars['Plot'], label_args={'style': 'RecordInfo.TLabel'} - ).grid(row=1, column=0) + ).grid(row=1, column=1) w.LabelInput( r_info, "Seed Sample", field_spec=fields['Seed Sample'], @@ -419,10 +421,11 @@ class RecordList(tk.Frame): default_minwidth = 10 default_anchor = tk.CENTER - def __init__(self, parent, inserted, updated, *args, **kwargs): + def __init__(self, parent, *args, **kwargs): super().__init__(parent, *args, **kwargs) - self.inserted = inserted - self.updated = updated + self._inserted = list() + self._updated = list() + self.columnconfigure(0, weight=1) self.rowconfigure(0, weight=1) @@ -471,9 +474,9 @@ def populate(self, rows): cids = list(self.column_defs.keys())[1:] for rownum, rowdata in enumerate(rows): values = [rowdata[cid] for cid in cids] - if rownum in self.inserted: + if rownum in self._inserted: tag = 'inserted' - elif rownum in self.updated: + elif rownum in self._updated: tag = 'updated' else: tag = '' @@ -490,3 +493,15 @@ def _on_open_record(self, *args): self.selected_id = int(self.treeview.selection()[0]) self.event_generate('<>') + + def add_updated_row(self, row): + if row not in self._updated: + self._updated.append(row) + + def add_inserted_row(self, row): + if row not in self._inserted: + self._inserted.append(row) + + def clear_tags(self): + self._inserted.clear() + self._updated.clear() diff --git a/Chapter09/ABQ_Data_Entry/abq_data_entry/widgets.py b/Chapter09/ABQ_Data_Entry/abq_data_entry/widgets.py index 1a6413e..ec72ed4 100644 --- a/Chapter09/ABQ_Data_Entry/abq_data_entry/widgets.py +++ b/Chapter09/ABQ_Data_Entry/abq_data_entry/widgets.py @@ -283,23 +283,31 @@ def _focusout_validate(self, **kwargs): return valid -class ValidatedRadio(ttk.Radiobutton): - """A validated radio button""" +class ValidatedRadioGroup(ttk.Frame): + """A validated radio button group""" - def __init__(self, *args, error_var=None, **kwargs): + def __init__( + self, *args, variable=None, error_var=None, + values=None, button_args=None, **kwargs + ): super().__init__(*args, **kwargs) + self.variable = variable or tk.StringVar() self.error = error_var or tk.StringVar() - self.variable = kwargs.get("variable") - self.bind('', self._focusout_validate) + self.values = values or list() + button_args = button_args or dict() - def _focusout_validate(self, *_): + for v in self.values: + button = ttk.Radiobutton( + self, value=v, text=v, variable=self.variable, **button_args + ) + button.pack(side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x') + self.bind('', self.trigger_focusout_validation) + + def trigger_focusout_validation(self, *_): self.error.set('') if not self.variable.get(): self.error.set('A value is required') - def trigger_focusout_validation(self): - self._focusout_validate() - class BoundText(tk.Text): """A Text widget with a bound variable.""" @@ -337,7 +345,7 @@ class LabelInput(ttk.Frame): field_types = { FT.string: RequiredEntry, FT.string_list: ValidatedCombobox, - FT.short_string_list: ValidatedRadio, + FT.short_string_list: ValidatedRadioGroup, FT.iso_date_string: DateEntry, FT.long_string: BoundText, FT.decimal: ValidatedSpinbox, @@ -381,14 +389,14 @@ def __init__( # setup the variable if input_class in ( - ttk.Checkbutton, ttk.Button, ttk.Radiobutton, ValidatedRadio + ttk.Checkbutton, ttk.Button, ttk.Radiobutton, ValidatedRadioGroup ): input_args["variable"] = self.variable else: input_args["textvariable"] = self.variable # Setup the input - if input_class in (ttk.Radiobutton, ValidatedRadio): + if input_class == ttk.Radiobutton: # for Radiobutton, create one input per value self.input = tk.Frame(self) for v in input_args.pop('values', []): From a64830efaa1d2d3640a9ef931f56e9956cc6a43f Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Mon, 2 Aug 2021 15:48:44 -0500 Subject: [PATCH 27/32] Updates per ch9 feedback --- .../abq_data_entry/application.py | 14 ++----- .../ABQ_Data_Entry/abq_data_entry/views.py | 27 +++++++++++--- .../ABQ_Data_Entry/abq_data_entry/widgets.py | 37 +++++++++++-------- 3 files changed, 47 insertions(+), 31 deletions(-) diff --git a/Chapter10/ABQ_Data_Entry/abq_data_entry/application.py b/Chapter10/ABQ_Data_Entry/abq_data_entry/application.py index 66e19d0..52da855 100644 --- a/Chapter10/ABQ_Data_Entry/abq_data_entry/application.py +++ b/Chapter10/ABQ_Data_Entry/abq_data_entry/application.py @@ -41,9 +41,6 @@ def __init__(self, *args, **kwargs): self.settings_model = m.SettingsModel() self._load_settings() - self.inserted_rows = [] - self.updated_rows = [] - # Begin building GUI self.title("ABQ Data Entry Application") self.columnconfigure(0, weight=1) @@ -95,9 +92,7 @@ def __init__(self, *args, **kwargs): # The data record list self.recordlist_icon = tk.PhotoImage(file=images.LIST_ICON) - self.recordlist = v.RecordList( - self, self.inserted_rows, self.updated_rows - ) + self.recordlist = v.RecordList(self) self.notebook.insert( 0, self.recordlist, text='Records', image=self.recordlist_icon, compound=tk.LEFT @@ -143,10 +138,10 @@ def _on_save(self, *_): rownum = self.recordform.current_record self.model.save_record(data, rownum) if rownum is not None: - self.updated_rows.append(rownum) + self.recordlist.add_updated_row(rownum) else: rownum = len(self.model.get_all_records()) -1 - self.inserted_rows.append(rownum) + self.recordlist.add_inserted_row(rownum) self.records_saved += 1 self.status.set( "{} records saved this session".format(self.records_saved) @@ -164,8 +159,7 @@ def _on_file_select(self, *_): ) if filename: self.model = m.CSVModel(filename=filename) - self.inserted_rows.clear() - self.updated_rows.clear() + self.recordlist.clear_tags() self._populate_recordlist() @staticmethod diff --git a/Chapter10/ABQ_Data_Entry/abq_data_entry/views.py b/Chapter10/ABQ_Data_Entry/abq_data_entry/views.py index 6f484df..33f6df3 100644 --- a/Chapter10/ABQ_Data_Entry/abq_data_entry/views.py +++ b/Chapter10/ABQ_Data_Entry/abq_data_entry/views.py @@ -126,7 +126,9 @@ def __init__(self, parent, model, settings, *args, **kwargs): field_spec=fields['Lab'], var=self._vars['Lab'], label_args={'style': 'RecordInfo.TLabel'}, - input_args={'style': 'RecordInfo.TRadiobutton'} + input_args={ + 'button_args':{'style': 'RecordInfo.TRadiobutton'} + } ).grid(row=1, column=0) w.LabelInput( r_info, "Plot", @@ -419,10 +421,11 @@ class RecordList(tk.Frame): default_minwidth = 10 default_anchor = tk.CENTER - def __init__(self, parent, inserted, updated, *args, **kwargs): + def __init__(self, parent, *args, **kwargs): super().__init__(parent, *args, **kwargs) - self.inserted = inserted - self.updated = updated + self._inserted = list() + self._updated = list() + self.columnconfigure(0, weight=1) self.rowconfigure(0, weight=1) @@ -471,9 +474,9 @@ def populate(self, rows): cids = list(self.column_defs.keys())[1:] for rownum, rowdata in enumerate(rows): values = [rowdata[cid] for cid in cids] - if rownum in self.inserted: + if rownum in self._inserted: tag = 'inserted' - elif rownum in self.updated: + elif rownum in self._updated: tag = 'updated' else: tag = '' @@ -490,3 +493,15 @@ def _on_open_record(self, *args): self.selected_id = int(self.treeview.selection()[0]) self.event_generate('<>') + + def add_updated_row(self, row): + if row not in self._updated: + self._updated.append(row) + + def add_inserted_row(self, row): + if row not in self._inserted: + self._inserted.append(row) + + def clear_tags(self): + self._inserted.clear() + self._updated.clear() diff --git a/Chapter10/ABQ_Data_Entry/abq_data_entry/widgets.py b/Chapter10/ABQ_Data_Entry/abq_data_entry/widgets.py index e7197f1..ec72ed4 100644 --- a/Chapter10/ABQ_Data_Entry/abq_data_entry/widgets.py +++ b/Chapter10/ABQ_Data_Entry/abq_data_entry/widgets.py @@ -283,23 +283,31 @@ def _focusout_validate(self, **kwargs): return valid -class ValidatedRadio(ttk.Radiobutton): - """A validated radio button""" +class ValidatedRadioGroup(ttk.Frame): + """A validated radio button group""" - def __init__(self, *args, error_var=None, **kwargs): + def __init__( + self, *args, variable=None, error_var=None, + values=None, button_args=None, **kwargs + ): super().__init__(*args, **kwargs) + self.variable = variable or tk.StringVar() self.error = error_var or tk.StringVar() - self.variable = kwargs.get("variable") - self.bind('', self._focusout_validate) + self.values = values or list() + button_args = button_args or dict() - def _focusout_validate(self, *_): + for v in self.values: + button = ttk.Radiobutton( + self, value=v, text=v, variable=self.variable, **button_args + ) + button.pack(side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x') + self.bind('', self.trigger_focusout_validation) + + def trigger_focusout_validation(self, *_): self.error.set('') if not self.variable.get(): self.error.set('A value is required') - def trigger_focusout_validation(self): - self._focusout_validate() - class BoundText(tk.Text): """A Text widget with a bound variable.""" @@ -337,7 +345,7 @@ class LabelInput(ttk.Frame): field_types = { FT.string: RequiredEntry, FT.string_list: ValidatedCombobox, - FT.short_string_list: ValidatedRadio, + FT.short_string_list: ValidatedRadioGroup, FT.iso_date_string: DateEntry, FT.long_string: BoundText, FT.decimal: ValidatedSpinbox, @@ -381,22 +389,21 @@ def __init__( # setup the variable if input_class in ( - ttk.Checkbutton, ttk.Button, ttk.Radiobutton, ValidatedRadio + ttk.Checkbutton, ttk.Button, ttk.Radiobutton, ValidatedRadioGroup ): input_args["variable"] = self.variable else: input_args["textvariable"] = self.variable # Setup the input - if input_class in (ttk.Radiobutton, ValidatedRadio): + if input_class == ttk.Radiobutton: # for Radiobutton, create one input per value self.input = tk.Frame(self) for v in input_args.pop('values', []): button = input_class( - self.input, value=v, text=v, **input_args - ) + self.input, value=v, text=v, **input_args) button.pack(side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x') - self.input.error = getattr(button, 'error') + self.input.error = getattr(button, 'error', None) self.input.trigger_focusout_validation = \ button._focusout_validate else: From b4c4819da50aac8c98bd403b568f32769830e250 Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Mon, 2 Aug 2021 15:52:41 -0500 Subject: [PATCH 28/32] Added chapter 11 code --- Chapter11/ABQ_Data_Entry/.gitignore | 2 + Chapter11/ABQ_Data_Entry/README.rst | 43 ++ Chapter11/ABQ_Data_Entry/abq_data_entry.py | 4 + .../ABQ_Data_Entry/abq_data_entry/__init__.py | 0 .../abq_data_entry/application.py | 264 +++++++++ .../abq_data_entry/constants.py | 12 + .../abq_data_entry/images/__init__.py | 20 + .../abq_data_entry/images/abq_logo-16x10.png | Bin 0 -> 1346 bytes .../abq_data_entry/images/abq_logo-32x20.png | Bin 0 -> 2637 bytes .../abq_data_entry/images/abq_logo-64x40.png | Bin 0 -> 5421 bytes .../abq_data_entry/images/browser-2x.png | Bin 0 -> 174 bytes .../abq_data_entry/images/file-2x.png | Bin 0 -> 167 bytes .../abq_data_entry/images/list-2x.png | Bin 0 -> 160 bytes .../images/question-mark-2x.xbm | 6 + .../abq_data_entry/images/reload-2x.png | Bin 0 -> 336 bytes .../abq_data_entry/images/x-2x.xbm | 6 + .../ABQ_Data_Entry/abq_data_entry/mainmenu.py | 362 +++++++++++++ .../ABQ_Data_Entry/abq_data_entry/models.py | 180 +++++++ .../abq_data_entry/test/__init__.py | 0 .../abq_data_entry/test/test_application.py | 72 +++ .../abq_data_entry/test/test_models.py | 137 +++++ .../abq_data_entry/test/test_widgets.py | 243 +++++++++ .../ABQ_Data_Entry/abq_data_entry/views.py | 507 ++++++++++++++++++ .../ABQ_Data_Entry/abq_data_entry/widgets.py | 441 +++++++++++++++ .../abq_data_record_2021-04-22.csv | 4 + .../docs/Application_layout.png | Bin 0 -> 9117 bytes .../docs/abq_data_entry_spec.rst | 97 ++++ .../docs/lab-tech-paper-form.png | Bin 0 -> 22018 bytes Chapter11/unittest_demo/mycalc.py | 27 + Chapter11/unittest_demo/test_mycalc.py | 51 ++ Chapter11/unittest_demo/test_mycalc_badly.py | 15 + .../unittest_demo/test_mycalc_no_unittest.py | 12 + 32 files changed, 2505 insertions(+) create mode 100644 Chapter11/ABQ_Data_Entry/.gitignore create mode 100644 Chapter11/ABQ_Data_Entry/README.rst create mode 100644 Chapter11/ABQ_Data_Entry/abq_data_entry.py create mode 100644 Chapter11/ABQ_Data_Entry/abq_data_entry/__init__.py create mode 100644 Chapter11/ABQ_Data_Entry/abq_data_entry/application.py create mode 100644 Chapter11/ABQ_Data_Entry/abq_data_entry/constants.py create mode 100644 Chapter11/ABQ_Data_Entry/abq_data_entry/images/__init__.py create mode 100644 Chapter11/ABQ_Data_Entry/abq_data_entry/images/abq_logo-16x10.png create mode 100644 Chapter11/ABQ_Data_Entry/abq_data_entry/images/abq_logo-32x20.png create mode 100644 Chapter11/ABQ_Data_Entry/abq_data_entry/images/abq_logo-64x40.png create mode 100644 Chapter11/ABQ_Data_Entry/abq_data_entry/images/browser-2x.png create mode 100644 Chapter11/ABQ_Data_Entry/abq_data_entry/images/file-2x.png create mode 100644 Chapter11/ABQ_Data_Entry/abq_data_entry/images/list-2x.png create mode 100644 Chapter11/ABQ_Data_Entry/abq_data_entry/images/question-mark-2x.xbm create mode 100644 Chapter11/ABQ_Data_Entry/abq_data_entry/images/reload-2x.png create mode 100644 Chapter11/ABQ_Data_Entry/abq_data_entry/images/x-2x.xbm create mode 100644 Chapter11/ABQ_Data_Entry/abq_data_entry/mainmenu.py create mode 100644 Chapter11/ABQ_Data_Entry/abq_data_entry/models.py create mode 100644 Chapter11/ABQ_Data_Entry/abq_data_entry/test/__init__.py create mode 100644 Chapter11/ABQ_Data_Entry/abq_data_entry/test/test_application.py create mode 100644 Chapter11/ABQ_Data_Entry/abq_data_entry/test/test_models.py create mode 100644 Chapter11/ABQ_Data_Entry/abq_data_entry/test/test_widgets.py create mode 100644 Chapter11/ABQ_Data_Entry/abq_data_entry/views.py create mode 100644 Chapter11/ABQ_Data_Entry/abq_data_entry/widgets.py create mode 100644 Chapter11/ABQ_Data_Entry/abq_data_record_2021-04-22.csv create mode 100644 Chapter11/ABQ_Data_Entry/docs/Application_layout.png create mode 100644 Chapter11/ABQ_Data_Entry/docs/abq_data_entry_spec.rst create mode 100644 Chapter11/ABQ_Data_Entry/docs/lab-tech-paper-form.png create mode 100644 Chapter11/unittest_demo/mycalc.py create mode 100644 Chapter11/unittest_demo/test_mycalc.py create mode 100644 Chapter11/unittest_demo/test_mycalc_badly.py create mode 100644 Chapter11/unittest_demo/test_mycalc_no_unittest.py diff --git a/Chapter11/ABQ_Data_Entry/.gitignore b/Chapter11/ABQ_Data_Entry/.gitignore new file mode 100644 index 0000000..d646835 --- /dev/null +++ b/Chapter11/ABQ_Data_Entry/.gitignore @@ -0,0 +1,2 @@ +*.pyc +__pycache__/ diff --git a/Chapter11/ABQ_Data_Entry/README.rst b/Chapter11/ABQ_Data_Entry/README.rst new file mode 100644 index 0000000..5a47dd7 --- /dev/null +++ b/Chapter11/ABQ_Data_Entry/README.rst @@ -0,0 +1,43 @@ +============================ + ABQ Data Entry Application +============================ + +Description +=========== + +This program provides a data entry form for ABQ Agrilabs laboratory data. + +Features +-------- + + * Provides a validated entry form to ensure correct data + * Stores data to ABQ-format CSV files + * Auto-fills form fields whenever possible + +Authors +======= + +Alan D Moore, 2021 + +Requirements +============ + + * Python 3.7 or higher + * Tkinter + +Usage +===== + +To start the application, run:: + + python3 ABQ_Data_Entry/abq_data_entry.py + + +General Notes +============= + +The CSV file will be saved to your current directory in the format +``abq_data_record_CURRENTDATE.csv``, where CURRENTDATE is today's date in ISO format. + +This program only appends to the CSV file. You should have a spreadsheet program +installed in case you need to edit or check the file. diff --git a/Chapter11/ABQ_Data_Entry/abq_data_entry.py b/Chapter11/ABQ_Data_Entry/abq_data_entry.py new file mode 100644 index 0000000..a3b3a0d --- /dev/null +++ b/Chapter11/ABQ_Data_Entry/abq_data_entry.py @@ -0,0 +1,4 @@ +from abq_data_entry.application import Application + +app = Application() +app.mainloop() diff --git a/Chapter11/ABQ_Data_Entry/abq_data_entry/__init__.py b/Chapter11/ABQ_Data_Entry/abq_data_entry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Chapter11/ABQ_Data_Entry/abq_data_entry/application.py b/Chapter11/ABQ_Data_Entry/abq_data_entry/application.py new file mode 100644 index 0000000..52da855 --- /dev/null +++ b/Chapter11/ABQ_Data_Entry/abq_data_entry/application.py @@ -0,0 +1,264 @@ +"""The application/controller class for ABQ Data Entry""" + +import tkinter as tk +from tkinter import ttk +from tkinter import messagebox +from tkinter import filedialog +from tkinter import font +import platform + +from . import views as v +from . import models as m +from .mainmenu import get_main_menu_for_os +from . import images + +class Application(tk.Tk): + """Application root window""" + + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Hide window while GUI is built + self.withdraw() + + # Authenticate + if not self._show_login(): + self.destroy() + return + + # show the window + self.deiconify() + + # Create model + self.model = m.CSVModel() + + # Load settings + # self.settings = { + # 'autofill date': tk.BooleanVar(), + # 'autofill sheet data': tk.BoleanVar() + # } + self.settings_model = m.SettingsModel() + self._load_settings() + + # Begin building GUI + self.title("ABQ Data Entry Application") + self.columnconfigure(0, weight=1) + + # Set taskbar icon + self.taskbar_icon = tk.PhotoImage(file=images.ABQ_LOGO_64) + self.iconphoto(True, self.taskbar_icon) + + # Create the menu + #menu = MainMenu(self, self.settings) + menu_class = get_main_menu_for_os(platform.system()) + menu = menu_class(self, self.settings) + + self.config(menu=menu) + event_callbacks = { + '<>': self._on_file_select, + '<>': lambda _: self.quit(), + '<>': self._show_recordlist, + '<>': self._new_record, + + } + for sequence, callback in event_callbacks.items(): + self.bind(sequence, callback) + + # new for ch9 + self.logo = tk.PhotoImage(file=images.ABQ_LOGO_32) + ttk.Label( + self, + text="ABQ Data Entry Application", + font=("TkDefaultFont", 16), + image=self.logo, + compound=tk.LEFT + ).grid(row=0) + + # The notebook + self.notebook = ttk.Notebook(self) + self.notebook.enable_traversal() + self.notebook.grid(row=1, padx=10, sticky='NSEW') + + # The data record form + self.recordform_icon = tk.PhotoImage(file=images.FORM_ICON) + self.recordform = v.DataRecordForm(self, self.model, self.settings) + self.notebook.add( + self.recordform, text='Entry Form', + image=self.recordform_icon, compound=tk.LEFT + ) + self.recordform.bind('<>', self._on_save) + + + # The data record list + self.recordlist_icon = tk.PhotoImage(file=images.LIST_ICON) + self.recordlist = v.RecordList(self) + self.notebook.insert( + 0, self.recordlist, text='Records', + image=self.recordlist_icon, compound=tk.LEFT + ) + self._populate_recordlist() + self.recordlist.bind('<>', self._open_record) + + + self._show_recordlist() + + # status bar + self.status = tk.StringVar() + self.statusbar = ttk.Label(self, textvariable=self.status) + self.statusbar.grid(sticky=(tk.W + tk.E), row=3, padx=10) + + + self.records_saved = 0 + + + def _on_save(self, *_): + """Handles file-save requests""" + + # Check for errors first + + errors = self.recordform.get_errors() + if errors: + self.status.set( + "Cannot save, error in fields: {}" + .format(', '.join(errors.keys())) + ) + message = "Cannot save record" + detail = "The following fields have errors: \n * {}".format( + '\n * '.join(errors.keys()) + ) + messagebox.showerror( + title='Error', + message=message, + detail=detail + ) + return False + + data = self.recordform.get() + rownum = self.recordform.current_record + self.model.save_record(data, rownum) + if rownum is not None: + self.recordlist.add_updated_row(rownum) + else: + rownum = len(self.model.get_all_records()) -1 + self.recordlist.add_inserted_row(rownum) + self.records_saved += 1 + self.status.set( + "{} records saved this session".format(self.records_saved) + ) + self.recordform.reset() + self._populate_recordlist() + + def _on_file_select(self, *_): + """Handle the file->select action""" + + filename = filedialog.asksaveasfilename( + title='Select the target file for saving records', + defaultextension='.csv', + filetypes=[('CSV', '*.csv *.CSV')] + ) + if filename: + self.model = m.CSVModel(filename=filename) + self.recordlist.clear_tags() + self._populate_recordlist() + + @staticmethod + def _simple_login(username, password): + """A basic authentication backend with a hardcoded user and password""" + return username == 'abq' and password == 'Flowers' + + def _show_login(self): + """Show login dialog and attempt to login""" + error = '' + title = "Login to ABQ Data Entry" + while True: + login = v.LoginDialog(self, title, error) + if not login.result: # User canceled + return False + username, password = login.result + if self._simple_login(username, password): + return True + error = 'Login Failed' # loop and redisplay + + def _load_settings(self): + """Load settings into our self.settings dict.""" + + vartypes = { + 'bool': tk.BooleanVar, + 'str': tk.StringVar, + 'int': tk.IntVar, + 'float': tk.DoubleVar + } + + # create our dict of settings variables from the model's settings. + self.settings = dict() + for key, data in self.settings_model.fields.items(): + vartype = vartypes.get(data['type'], tk.StringVar) + self.settings[key] = vartype(value=data['value']) + + # put a trace on the variables so they get stored when changed. + for var in self.settings.values(): + var.trace_add('write', self._save_settings) + + # update font settings after loading them + self._set_font() + self.settings['font size'].trace_add('write', self._set_font) + self.settings['font family'].trace_add('write', self._set_font) + + # process theme + style = ttk.Style() + theme = self.settings.get('theme').get() + if theme in style.theme_names(): + style.theme_use(theme) + + def _save_settings(self, *_): + """Save the current settings to a preferences file""" + + for key, variable in self.settings.items(): + self.settings_model.set(key, variable.get()) + self.settings_model.save() + + def _show_recordlist(self, *_): + """Show the recordform""" + self.notebook.select(self.recordlist) + + def _populate_recordlist(self): + try: + rows = self.model.get_all_records() + except Exception as e: + messagebox.showerror( + title='Error', + message='Problem reading file', + detail=str(e) + ) + else: + self.recordlist.populate(rows) + + def _new_record(self, *_): + """Open the record form with a blank record""" + self.recordform.load_record(None, None) + self.notebook.select(self.recordform) + + + def _open_record(self, *_): + """Open the Record selected recordlist id in the recordform""" + rowkey = self.recordlist.selected_id + try: + record = self.model.get_record(rowkey) + except Exception as e: + messagebox.showerror( + title='Error', message='Problem reading file', detail=str(e) + ) + return + self.recordform.load_record(rowkey, record) + self.notebook.select(self.recordform) + + # new chapter 9 + def _set_font(self, *_): + """Set the application's font""" + font_size = self.settings['font size'].get() + font_family = self.settings['font family'].get() + font_names = ('TkDefaultFont', 'TkMenuFont', 'TkTextFont') + for font_name in font_names: + tk_font = font.nametofont(font_name) + tk_font.config(size=font_size, family=font_family) diff --git a/Chapter11/ABQ_Data_Entry/abq_data_entry/constants.py b/Chapter11/ABQ_Data_Entry/abq_data_entry/constants.py new file mode 100644 index 0000000..e747dce --- /dev/null +++ b/Chapter11/ABQ_Data_Entry/abq_data_entry/constants.py @@ -0,0 +1,12 @@ +"""Global constants and classes needed by other modules in ABQ Data Entry""" +from enum import Enum, auto + +class FieldTypes(Enum): + string = auto() + string_list = auto() + short_string_list = auto() + iso_date_string = auto() + long_string = auto() + decimal = auto() + integer = auto() + boolean = auto() diff --git a/Chapter11/ABQ_Data_Entry/abq_data_entry/images/__init__.py b/Chapter11/ABQ_Data_Entry/abq_data_entry/images/__init__.py new file mode 100644 index 0000000..57538d9 --- /dev/null +++ b/Chapter11/ABQ_Data_Entry/abq_data_entry/images/__init__.py @@ -0,0 +1,20 @@ +from pathlib import Path + +# This gives us the parent directory of this file (__init__.py) +IMAGE_DIRECTORY = Path(__file__).parent + +ABQ_LOGO_16 = IMAGE_DIRECTORY / 'abq_logo-16x10.png' +ABQ_LOGO_32 = IMAGE_DIRECTORY / 'abq_logo-32x20.png' +ABQ_LOGO_64 = IMAGE_DIRECTORY / 'abq_logo-64x40.png' + +# PNG icons + +SAVE_ICON = IMAGE_DIRECTORY / 'file-2x.png' +RESET_ICON = IMAGE_DIRECTORY / 'reload-2x.png' +LIST_ICON = IMAGE_DIRECTORY / 'list-2x.png' +FORM_ICON = IMAGE_DIRECTORY / 'browser-2x.png' + + +# BMP icons +QUIT_BMP = IMAGE_DIRECTORY / 'x-2x.xbm' +ABOUT_BMP = IMAGE_DIRECTORY / 'question-mark-2x.xbm' diff --git a/Chapter11/ABQ_Data_Entry/abq_data_entry/images/abq_logo-16x10.png b/Chapter11/ABQ_Data_Entry/abq_data_entry/images/abq_logo-16x10.png new file mode 100644 index 0000000000000000000000000000000000000000..255f2739e10827885fe11f4fece3f7e42bf73064 GIT binary patch literal 1346 zcmV-I1-<%-P) zaB^>EX>4U6ba`-PAZ2)IW&i+q+O3sqlI%7JM*nLSS%LuZ&~k(xRoOw7pHJ?dnLCx6 zls-|pyCOH&W)W)-9L)_LG2>T9&;O3zC5{ZKz{zR61+Z#hFG zKWM&JxgRhd?ftx8tG=96+jeXj6%!Y(ycqFZfw?K}ryW-);pu+;{JuM~PltRXD<3cX z?FnN3F=Rw6^@nlJigWgB$DY+x91|8bZI%y)T#+w~0^JIBsAk;L*(mi04aiRMKB~FP>n>%s5-L~A&&t-1Cg^d zP7okfUI>y~5i!6CzP|B|)1%AEFELsMAXIMM1_%wnYE4l;-U2l=RJ5t86?F~mI!vsY znxU3&?+q7ku5Rug-hG5b3k?g8h#sSJ7qq5!>)xaH(#L?)0n-Ct4`_^$oRTdyEj=T9 zj*0S_ZR)h?GiIM-@sib+E?d50^|HpMjZ)fe>$dGXcHiTm){dNZ^q}cZoPNe9HF|g6 zw^_cMK9 zTUyDFvfH6;> z;~-~iW{-1^-pYHSk<)4AKw}QH$br5kDg*Fx4^(_zJxyt>Eg4`sM^@G#t*8sef1fXB zOYMt6ZbS!*k_>;?0AjQ*a*)zq{shn6wOaaUK zNlSf$=-Pryrn)FpkWy%ilEl9#)q*v3Dt34jBAQSPmiLmpd=4fmDD=$to^#K+M*y{8 ztiWGiXGa}1wUVR5B2scKgn%E(Bfz@{qd@^VdQWK zGQcgs1=ye_6672W-4nB2VCfZZg|Fiq%IO1jreqjN=0BQn1Jq;!5CD8P_xXKzKx=&i zVC(X1!%a480TP5H^(W2fo0rUvf1!69ge!!VvpCM55P1KB*2b2SAw|ilglAh!Cq6T` z7GvP`6QUm`$aZ^)1z`DSl=epvlBP-g@ms{;{qsyp1Vxw)@cdzfr@>wt*WrQh4t1F} z0D63PXiV;m4}np?aK zy}C`Te~OJ?i);c(b02{~MVHD?MZlP4+hN_U6)yxe5A!phm>e+hNBnGk-DO*?g5#Wz z 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 literal 0 HcmV?d00001 diff --git a/Chapter11/ABQ_Data_Entry/abq_data_entry/images/abq_logo-64x40.png b/Chapter11/ABQ_Data_Entry/abq_data_entry/images/abq_logo-64x40.png new file mode 100644 index 0000000000000000000000000000000000000000..be9458ff1799dcb20cdab36eb100511f1b213606 GIT binary patch literal 5421 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 z zqn~~&0hgPPx9?`d^Dpxszx;%WRdC6mPGj$W8rA|+a3PD*Pyjc%HSQRD8x6o^<#5ZY z#mu~FDw^T$T@gZn=4SERt1)bilx9N`W{jE8Z$o{KL|vC4!_fPrbRyCm;jY2oB|Qk- z0T|{&*&~UB}N}KMDmVXh{yI8mZr(VYC2R z3ZSsm&27f3utX@o$mHPD>K}x+QTEVK$()N^SV?O(NSIUm9VQIB2&Cwl7WV-GPh_~T z+4|mizA^ng0NT@Wf{$JW#p1=hKW9Bew`Vut6^-_kExX6??{7Q9Wivm{G2G`2@VUKezzoBZOrLDj?le%>g0IL6GLfP4!pVeE zTA72^LGO2|6c!+eho~>9K6C(<2U1BzZ^6{c3vmYvC^HJtg-+5=aojx32d57~7zhEy z@W|Fx-1qy{q!w&OmwhjEc@>}uH#{>JN(HoQ*h$Ij?_A8p32I7sbOVIIrD+(tjsy$~ zrUtHNj9W4Msg+<)Yzq{Gc=pLd%zyB+-1zHX8P3hO)gYc8ul8_&Xj>Bbcs3RGGDm!k zXrbaV*#ygXF5tq+Bd63V!u{0NaJ@hwJNXz2G8(u=15(?qK;l{l5XH@}z4G_Ti%8|;g1nfF-0JOdryK_zf z0J`SSxmGydOupjfoK!$_>{PDg9~rzLZ(4lrDL~WN{K+o0B-$`x$f&$Y(Yd1lAwVx+ z^TB#3W99f@%K z)EREr`h-fL{6Gvg-}NMhaOJHi&$sY$1cnR0NGgY<#}_<*?xo z(CTf=U>O$(Q0_;BzbWmjSI?Ai#`M8RFqIwejdJ109*xNCuu!(hKH}em!{(~kj_M`Ndi01WuaY6#q}C@ogK zSj`XT4gc~=0MI>PB{;nIQzES~T3g#Wee49WwszW^S||tv@D&9qDm#xs=a;c}=N{hp z=U4C#b1|r@if7+kjnH-FE?+|UmH*bj-S_^H&co66HSpBknNe$js}F&Bp?bG?;Qk8! zW!X4fZa&}l2Ld5L>%O%lRWP${&^`S67aRE5txr*2?dG#jO}0edrXb)S`2W%bsU$qI zXfG?C3F9~(KL~G)h5BqXa0?hK;f8%+)~c-+ei-*%NS6TRKp}tK*W_A(FzC&&|G(g~XJ9*hU6cEN XhPBd*{i(!N00000NkvXXu0mjfWYLOi literal 0 HcmV?d00001 diff --git a/Chapter11/ABQ_Data_Entry/abq_data_entry/images/browser-2x.png b/Chapter11/ABQ_Data_Entry/abq_data_entry/images/browser-2x.png new file mode 100644 index 0000000000000000000000000000000000000000..2a6efb0d3ad404d3e6df2c4f7e2c9441945b9377 GIT binary patch literal 174 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`EX7WqAsj$Z!;#Vf4nJ za0`PlBg3pY50TY@YDj113tz;u?-H7A2U5ODE0AZ z@(JiUbDVWXB2U-ehVx>J7`TOIPjuP)f90d5KxVdP#t_fW4nJ za0`PlBg3pY54nJ za0`PlBg3pY5<39QJlxHc{CLc#vuuf5Bd*}mNt{%q1HX$~}v!PC{xWt~$(695?x BFjxQp literal 0 HcmV?d00001 diff --git a/Chapter11/ABQ_Data_Entry/abq_data_entry/images/question-mark-2x.xbm b/Chapter11/ABQ_Data_Entry/abq_data_entry/images/question-mark-2x.xbm new file mode 100644 index 0000000..8981c9b --- /dev/null +++ b/Chapter11/ABQ_Data_Entry/abq_data_entry/images/question-mark-2x.xbm @@ -0,0 +1,6 @@ +#define question_mark_2x_width 16 +#define question_mark_2x_height 16 +static unsigned char question_mark_2x_bits[] = { + 0xc0, 0x0f, 0xe0, 0x1f, 0x70, 0x38, 0x30, 0x30, 0x00, 0x30, 0x00, 0x30, + 0x00, 0x18, 0x00, 0x1c, 0x00, 0x0e, 0x00, 0x07, 0x00, 0x03, 0x00, 0x03, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x03 }; diff --git a/Chapter11/ABQ_Data_Entry/abq_data_entry/images/reload-2x.png b/Chapter11/ABQ_Data_Entry/abq_data_entry/images/reload-2x.png new file mode 100644 index 0000000000000000000000000000000000000000..2a79f1665d1156d2e140a10f7902bc69f8bb26bb GIT binary patch literal 336 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`EX7WqAsj$Z!;#Vf4nJ za0`PlBg3pY5>Y*U9P$-MRL!(@9^X8fhq)(E zFJr#NVI#l5GfnbJnH`@4HZ}2mE9hQ-qOz;b%b=q*ea-b7vyRl=n5ChmHt%@F;V9;a z6Z5u)Owe0%kz&bqH;`{=)pW(4FHy7Od@(eJR=g%=_#4 zi__yCxo)1S*Z0cIYSL7p_d6W?wLbFf-tj58wY^j&QX})1kLtS(w^GujuU`_^dvqmc e)r~mQpVIIC_*-03aXkd|J%gvKpUXO@geCw(qlWDO literal 0 HcmV?d00001 diff --git a/Chapter11/ABQ_Data_Entry/abq_data_entry/images/x-2x.xbm b/Chapter11/ABQ_Data_Entry/abq_data_entry/images/x-2x.xbm new file mode 100644 index 0000000..940af96 --- /dev/null +++ b/Chapter11/ABQ_Data_Entry/abq_data_entry/images/x-2x.xbm @@ -0,0 +1,6 @@ +#define x_2x_width 16 +#define x_2x_height 16 +static unsigned char x_2x_bits[] = { + 0x04, 0x10, 0x0e, 0x38, 0x1f, 0x7c, 0x3e, 0x7e, 0x7c, 0x3f, 0xf8, 0x1f, + 0xf0, 0x0f, 0xe0, 0x07, 0xf0, 0x07, 0xf8, 0x0f, 0xfc, 0x1f, 0x7e, 0x3e, + 0x3f, 0x7c, 0x1e, 0x78, 0x0c, 0x30, 0x00, 0x00 }; diff --git a/Chapter11/ABQ_Data_Entry/abq_data_entry/mainmenu.py b/Chapter11/ABQ_Data_Entry/abq_data_entry/mainmenu.py new file mode 100644 index 0000000..4d3d72a --- /dev/null +++ b/Chapter11/ABQ_Data_Entry/abq_data_entry/mainmenu.py @@ -0,0 +1,362 @@ +"""The Main Menu class for ABQ Data Entry""" + +import tkinter as tk +from tkinter import ttk +from tkinter import messagebox +from tkinter import font + +from . import images + +class GenericMainMenu(tk.Menu): + """The Application's main menu""" + + accelerators = { + 'file_open': 'Ctrl+O', + 'quit': 'Ctrl+Q', + 'record_list': 'Ctrl+L', + 'new_record': 'Ctrl+R', + } + + keybinds = { + '': '<>', + '': '<>', + '': '<>', + '': '<>' + } + + styles = {} + + def _event(self, sequence): + """Return a callback function that generates the sequence""" + def callback(*_): + root = self.master.winfo_toplevel() + root.event_generate(sequence) + + return callback + + def _create_icons(self): + + # must be done in a method because PhotoImage can't be created + # until there is a Tk instance. + # There isn't one when the class is defined, but there is when + # the instance is created. + self.icons = { + 'file_open': tk.PhotoImage(file=images.SAVE_ICON), + 'record_list': tk.PhotoImage(file=images.LIST_ICON), + 'new_record': tk.PhotoImage(file=images.FORM_ICON), + 'quit': tk.BitmapImage(file=images.QUIT_BMP, foreground='red'), + 'about': tk.BitmapImage( + file=images.ABOUT_BMP, foreground='#CC0', background='#A09' + ), + } + + def _add_file_open(self, menu): + + menu.add_command( + label='Select file…', command=self._event('<>'), + image=self.icons.get('file'), compound=tk.LEFT + ) + + def _add_quit(self, menu): + menu.add_command( + label='Quit', command=self._event('<>'), + image=self.icons.get('quit'), compound=tk.LEFT + ) + + def _add_autofill_date(self, menu): + menu.add_checkbutton( + label='Autofill Date', variable=self.settings['autofill date'] + ) + + def _add_autofill_sheet(self, menu): + menu.add_checkbutton( + label='Autofill Sheet data', + variable=self.settings['autofill sheet data'] + ) + + def _add_font_size_menu(self, menu): + font_size_menu = tk.Menu(self, tearoff=False, **self.styles) + for size in range(6, 17, 1): + font_size_menu.add_radiobutton( + label=size, value=size, + variable=self.settings['font size'] + ) + menu.add_cascade(label='Font size', menu=font_size_menu) + + def _add_font_family_menu(self, menu): + font_family_menu = tk.Menu(self, tearoff=False, **self.styles) + for family in font.families(): + font_family_menu.add_radiobutton( + label=family, value=family, + variable=self.settings['font family'] + ) + menu.add_cascade(label='Font family', menu=font_family_menu) + + def _add_themes_menu(self, menu): + style = ttk.Style() + themes_menu = tk.Menu(self, tearoff=False, **self.styles) + for theme in style.theme_names(): + themes_menu.add_radiobutton( + label=theme, value=theme, + variable=self.settings['theme'] + ) + menu.add_cascade(label='Theme', menu=themes_menu) + self.settings['theme'].trace_add('write', self._on_theme_change) + + def _add_go_record_list(self, menu): + menu.add_command( + label="Record List", command=self._event('<>'), + image=self.icons.get('record_list'), compound=tk.LEFT + ) + + def _add_go_new_record(self, menu): + menu.add_command( + label="New Record", command=self._event('<>'), + image=self.icons.get('new_record'), compound=tk.LEFT + ) + + def _add_about(self, menu): + menu.add_command( + label='About…', command=self.show_about, + image=self.icons.get('about'), compound=tk.LEFT + ) + + def _build_menu(self): + # The file menu + self._menus['File'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_file_open(self._menus['File']) + self._menus['File'].add_separator() + self._add_quit(self._menus['File']) + + # The options menu + self._menus['Options'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_autofill_date(self._menus['Options']) + self._add_autofill_sheet(self._menus['Options']) + self._add_font_size_menu(self._menus['Options']) + self._add_font_family_menu(self._menus['Options']) + self._add_themes_menu(self._menus['Options']) + + # switch from recordlist to recordform + self._menus['Go'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_go_record_list(self._menus['Go']) + self._add_go_new_record(self._menus['Go']) + + # The help menu + self._menus['Help'] = tk.Menu(self, tearoff=False, **self.styles) + self.add_cascade(label='Help', menu=self._menus['Help']) + self._add_about(self._menus['Help']) + + for label, menu in self._menus.items(): + self.add_cascade(label=label, menu=menu) + self.configure(**self.styles) + + def __init__(self, parent, settings, **kwargs): + super().__init__(parent, **kwargs) + self.settings = settings + self._create_icons() + self._menus = dict() + self._build_menu() + self._bind_accelerators() + self.configure(**self.styles) + + def show_about(self): + """Show the about dialog""" + + about_message = 'ABQ Data Entry' + about_detail = ( + 'by Alan D Moore\n' + 'For assistance please contact the author.' + ) + + messagebox.showinfo( + title='About', message=about_message, detail=about_detail + ) + @staticmethod + def _on_theme_change(*_): + """Popup a message about theme changes""" + message = "Change requires restart" + detail = ( + "Theme changes do not take effect" + " until application restart" + ) + messagebox.showwarning( + title='Warning', + message=message, + detail=detail + ) + + def _bind_accelerators(self): + + for key, sequence in self.keybinds.items(): + self.bind_all(key, self._event(sequence)) + +class WindowsMainMenu(GenericMainMenu): + """ + Changes: + - Windows uses file->exit instead of file->quit, + and no accelerator is used. + - Windows can handle commands on the menubar, so + put 'Record List' / 'New Record' on the bar + - Windows can't handle icons on the menu bar, though + - Put 'options' under 'Tools' with separator + """ + + def _create_icons(self): + super()._create_icons() + del(self.icons['new_record']) + del(self.icons['record_list']) + + def __init__(self, *args, **kwargs): + del(self.keybinds['']) + super().__init__(*args, **kwargs) + + def _add_quit(self, menu): + menu.add_command( + label='Exit', + command=self._event('<>'), + image=self.icons.get('quit'), + compound=tk.LEFT + ) + + def _build_menu(self): + # File Menu + self._menus['File'] = tk.Menu(self, tearoff=False) + self._add_file_open(self._menus['File']) + self._menus['File'].add_separator() + self._add_quit(self._menus['File']) + + #Tools menu + self._menus['Tools'] = tk.Menu(self, tearoff=False) + self._add_autofill_date(self._menus['Tools']) + self._add_autofill_sheet(self._menus['Tools']) + self._add_font_size_menu(self._menus['Tools']) + self._add_font_family_menu(self._menus['Tools']) + self._add_themes_menu(self._menus['Tools']) + + # The help menu + self._menus['Help'] = tk.Menu(self, tearoff=False) + self._add_about(self._menus['Help']) + + # Build main menu + self.add_cascade(label='File', menu=self._menus['File']) + self.add_cascade(label='Tools', menu=self._menus['Tools']) + self._add_go_record_list(self) + self._add_go_new_record(self) + self.add_cascade(label='Help', menu=self._menus['Help']) + + +class LinuxMainMenu(GenericMainMenu): + """Differences for Linux: + + - Edit menu for autofill options + - View menu for font & theme options + - Use color theme for menu + """ + styles = { + 'background': '#333', + 'foreground': 'white', + 'activebackground': '#777', + 'activeforeground': 'white', + 'relief': tk.GROOVE + } + + + def _build_menu(self): + self._menus['File'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_file_open(self._menus['File']) + self._menus['File'].add_separator() + self._add_quit(self._menus['File']) + + # The edit menu + self._menus['Edit'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_autofill_date(self._menus['Edit']) + self._add_autofill_sheet(self._menus['Edit']) + + # The View menu + self._menus['View'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_font_size_menu(self._menus['View']) + self._add_font_family_menu(self._menus['View']) + self._add_themes_menu(self._menus['View']) + + # switch from recordlist to recordform + self._menus['Go'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_go_record_list(self._menus['Go']) + self._add_go_new_record(self._menus['Go']) + + # The help menu + self._menus['Help'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_about(self._menus['Help']) + + for label, menu in self._menus.items(): + self.add_cascade(label=label, menu=menu) + + +class MacOsMainMenu(GenericMainMenu): + """ + Differences for MacOS: + + - Create App Menu + - Move about to app menu, remove 'help' + - Remove redundant quit command + - Change accelerators to Command-[] + - Add View menu for font & theme options + - Add Edit menu for autofill options + - Add Window menu for navigation commands + """ + keybinds = { + '': '<>', + '': '<>', + '': '<>' + } + accelerators = { + 'file_open': 'Cmd-O', + 'record_list': 'Cmd-L', + 'new_record': 'Cmd-R', + } + + def _add_about(self, menu): + menu.add_command( + label='About ABQ Data Entry', command=self.show_about, + image=self.icons.get('about'), compound=tk.LEFT + ) + + def _build_menu(self): + self._menus['ABQ Data Entry'] = tk.Menu( + self, tearoff=False, + name='apple' + ) + self._add_about(self._menus['ABQ Data Entry']) + self._menus['ABQ Data Entry'].add_separator() + + self._menus['File'] = tk.Menu(self, tearoff=False) + self._add_file_open(self._menus['File']) + + self._menus['Edit'] = tk.Menu(self, tearoff=False) + self._add_autofill_date(self._menus['Edit']) + self._add_autofill_sheet(self._menus['Edit']) + + # View menu + self._menus['View'] = tk.Menu(self, tearoff=False) + self._add_font_size_menu(self._menus['View']) + self._add_font_family_menu(self._menus['View']) + self._add_themes_menu(self._menus['View']) + + # Window Menu + self._menus['Window'] = tk.Menu(self, name='window', tearoff=False) + self._add_go_record_list(self._menus['Window']) + self._add_go_new_record(self._menus['Window']) + + for label, menu in self._menus.items(): + self.add_cascade(label=label, menu=menu) + + +def get_main_menu_for_os(os_name): + """Return the menu class appropriate to the given OS""" + menus = { + 'Linux': LinuxMainMenu, + 'Darwin': MacOsMainMenu, + 'freebsd7': LinuxMainMenu, + 'Windows': WindowsMainMenu + } + + return menus.get(os_name, GenericMainMenu) diff --git a/Chapter11/ABQ_Data_Entry/abq_data_entry/models.py b/Chapter11/ABQ_Data_Entry/abq_data_entry/models.py new file mode 100644 index 0000000..f723882 --- /dev/null +++ b/Chapter11/ABQ_Data_Entry/abq_data_entry/models.py @@ -0,0 +1,180 @@ +import csv +from pathlib import Path +import os +import json +import platform + +from .constants import FieldTypes as FT +from decimal import Decimal +from datetime import datetime + +class CSVModel: + """CSV file storage""" + + fields = { + "Date": {'req': True, 'type': FT.iso_date_string}, + "Time": {'req': True, 'type': FT.string_list, + 'values': ['8:00', '12:00', '16:00', '20:00']}, + "Technician": {'req': True, 'type': FT.string}, + "Lab": {'req': True, 'type': FT.short_string_list, + 'values': ['A', 'B', 'C']}, + "Plot": {'req': True, 'type': FT.string_list, + 'values': [str(x) for x in range(1, 21)]}, + "Seed Sample": {'req': True, 'type': FT.string}, + "Humidity": {'req': True, 'type': FT.decimal, + 'min': 0.5, 'max': 52.0, 'inc': .01}, + "Light": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 100.0, 'inc': .01}, + "Temperature": {'req': True, 'type': FT.decimal, + 'min': 4, 'max': 40, 'inc': .01}, + "Equipment Fault": {'req': False, 'type': FT.boolean}, + "Plants": {'req': True, 'type': FT.integer, 'min': 0, 'max': 20}, + "Blossoms": {'req': True, 'type': FT.integer, 'min': 0, 'max': 1000}, + "Fruit": {'req': True, 'type': FT.integer, 'min': 0, 'max': 1000}, + "Min Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Max Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Med Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Notes": {'req': False, 'type': FT.long_string} + } + + + def __init__(self, filename=None): + + if not filename: + datestring = datetime.today().strftime("%Y-%m-%d") + filename = "abq_data_record_{}.csv".format(datestring) + self.file = Path(filename) + + # Check for append permissions: + file_exists = os.access(self.file, os.F_OK) + parent_writeable = os.access(self.file.parent, os.W_OK) + file_writeable = os.access(self.file, os.W_OK) + if ( + (not file_exists and not parent_writeable) or + (file_exists and not file_writeable) + ): + msg = f'Permission denied accessing file: {filename}' + raise PermissionError(msg) + + + def save_record(self, data, rownum=None): + """Save a dict of data to the CSV file""" + + if rownum is None: + # This is a new record + newfile = not self.file.exists() + + with open(self.file, 'a', encoding='utf-8', newline='') as fh: + csvwriter = csv.DictWriter(fh, fieldnames=self.fields.keys()) + if newfile: + csvwriter.writeheader() + csvwriter.writerow(data) + else: + # This is an update + records = self.get_all_records() + records[rownum] = data + with open(self.file, 'w', encoding='utf-8', newline='') as fh: + csvwriter = csv.DictWriter(fh, fieldnames=self.fields.keys()) + csvwriter.writeheader() + csvwriter.writerows(records) + + def get_all_records(self): + """Read in all records from the CSV and return a list""" + if not self.file.exists(): + return [] + + with open(self.file, 'r', encoding='utf-8') as fh: + # Casting to list is necessary for unit tests to work + csvreader = csv.DictReader(list(fh.readlines())) + missing_fields = set(self.fields.keys()) - set(csvreader.fieldnames) + if len(missing_fields) > 0: + fields_string = ', '.join(missing_fields) + raise Exception( + f"File is missing fields: {fields_string}" + ) + records = list(csvreader) + + # Correct issue with boolean fields + trues = ('true', 'yes', '1') + bool_fields = [ + key for key, meta + in self.fields.items() + if meta['type'] == FT.boolean + ] + for record in records: + for key in bool_fields: + record[key] = record[key].lower() in trues + return records + + def get_record(self, rownum): + """Get a single record by row number + + Callling code should catch IndexError + in case of a bad rownum. + """ + + return self.get_all_records()[rownum] + + +class SettingsModel: + """A model for saving settings""" + + fields = { + 'autofill date': {'type': 'bool', 'value': True}, + 'autofill sheet data': {'type': 'bool', 'value': True}, + 'font size': {'type': 'int', 'value': 9}, + 'font family': {'type': 'str', 'value': ''}, + 'theme': {'type': 'str', 'value': 'default'} + } + + config_dirs = { + "Linux": Path(os.environ.get('$XDG_CONFIG_HOME', Path.home() / '.config')), + "freebsd7": Path(os.environ.get('$XDG_CONFIG_HOME', Path.home() / '.config')), + 'Darwin': Path.home() / 'Library' / 'Application Support', + 'Windows': Path.home() / 'AppData' / 'Local' + } + + def __init__(self): + # determine the file path + filename = 'abq_settings.json' + filedir = self.config_dirs.get(platform.system(), Path.home()) + self.filepath = filedir / filename + + # load in saved values + self.load() + + def set(self, key, value): + """Set a variable value""" + if ( + key in self.fields and + type(value).__name__ == self.fields[key]['type'] + ): + self.fields[key]['value'] = value + else: + raise ValueError("Bad key or wrong variable type") + + def save(self): + """Save the current settings to the file""" + json_string = json.dumps(self.fields) + with open(self.filepath, 'w', encoding='utf-8') as fh: + fh.write(json_string) + + def load(self): + """Load the settings from the file""" + + # if the file doesn't exist, return + if not self.filepath.exists(): + return + + # open the file and read in the raw values + with open(self.filepath, 'r') as fh: + raw_values = json.loads(fh.read()) + + # don't implicitly trust the raw values, but only get known keys + for key in self.fields: + if key in raw_values and 'value' in raw_values[key]: + raw_value = raw_values[key]['value'] + self.fields[key]['value'] = raw_value diff --git a/Chapter11/ABQ_Data_Entry/abq_data_entry/test/__init__.py b/Chapter11/ABQ_Data_Entry/abq_data_entry/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Chapter11/ABQ_Data_Entry/abq_data_entry/test/test_application.py b/Chapter11/ABQ_Data_Entry/abq_data_entry/test/test_application.py new file mode 100644 index 0000000..73cc7b4 --- /dev/null +++ b/Chapter11/ABQ_Data_Entry/abq_data_entry/test/test_application.py @@ -0,0 +1,72 @@ +from unittest import TestCase +from unittest.mock import patch +from .. import application + + +class TestApplication(TestCase): + records = [ + {'Date': '2018-06-01', 'Time': '8:00', 'Technician': 'J Simms', + 'Lab': 'A', 'Plot': '1', 'Seed Sample': 'AX477', + 'Humidity': '24.09', 'Light': '1.03', 'Temperature': '22.01', + 'Equipment Fault': False, 'Plants': '9', 'Blossoms': '21', + 'Fruit': '3', 'Max Height': '8.7', 'Med Height': '2.73', + 'Min Height': '1.67', 'Notes': '\n\n', + }, + {'Date': '2018-06-01', 'Time': '8:00', 'Technician': 'J Simms', + 'Lab': 'A', 'Plot': '2', 'Seed Sample': 'AX478', + 'Humidity': '24.47', 'Light': '1.01', 'Temperature': '21.44', + 'Equipment Fault': False, 'Plants': '14', 'Blossoms': '27', + 'Fruit': '1', 'Max Height': '9.2', 'Med Height': '5.09', + 'Min Height': '2.35', 'Notes': '' + } + ] + + settings = { + 'autofill date': {'type': 'bool', 'value': True}, + 'autofill sheet data': {'type': 'bool', 'value': True}, + 'font size': {'type': 'int', 'value': 9}, + 'font family': {'type': 'str', 'value': ''}, + 'theme': {'type': 'str', 'value': 'default'} + } + + def setUp(self): + # can be parenthesized in python 3.10+ + with \ + patch('abq_data_entry.application.m.CSVModel') as csvmodel,\ + patch('abq_data_entry.application.m.SettingsModel') as settingsmodel,\ + patch('abq_data_entry.application.Application._show_login') as show_login,\ + patch('abq_data_entry.application.v.DataRecordForm'),\ + patch('abq_data_entry.application.v.RecordList'),\ + patch('abq_data_entry.application.ttk.Notebook'),\ + patch('abq_data_entry.application.get_main_menu_for_os')\ + : + + settingsmodel().fields = self.settings + csvmodel().get_all_records.return_value = self.records + show_login.return_value = True + self.app = application.Application() + + def tearDown(self): + self.app.update() + self.app.destroy() + + def test_show_recordlist(self): + self.app._show_recordlist() + self.app.update() + self.app.notebook.select.assert_called_with(self.app.recordlist) + + def test_populate_recordlist(self): + # test correct functions + self.app._populate_recordlist() + self.app.model.get_all_records.assert_called() + self.app.recordlist.populate.assert_called_with(self.records) + + # test exceptions + + self.app.model.get_all_records.side_effect = Exception('Test message') + with patch('abq_data_entry.application.messagebox'): + self.app._populate_recordlist() + application.messagebox.showerror.assert_called_with( + title='Error', message='Problem reading file', + detail='Test message' + ) diff --git a/Chapter11/ABQ_Data_Entry/abq_data_entry/test/test_models.py b/Chapter11/ABQ_Data_Entry/abq_data_entry/test/test_models.py new file mode 100644 index 0000000..fced4a8 --- /dev/null +++ b/Chapter11/ABQ_Data_Entry/abq_data_entry/test/test_models.py @@ -0,0 +1,137 @@ +from .. import models +from unittest import TestCase +from unittest import mock + +from pathlib import Path + +class TestCSVModel(TestCase): + + def setUp(self): + + self.file1_open = mock.mock_open( + read_data=( + "Date,Time,Technician,Lab,Plot,Seed Sample,Humidity,Light," + "Temperature,Equipment Fault,Plants,Blossoms,Fruit,Min Height," + "Max Height,Med Height,Notes\r\n" + "2021-06-01,8:00,J Simms,A,2,AX478,24.47,1.01,21.44,False,14," + "27,1,2.35,9.2,5.09,\r\n" + "2021-06-01,8:00,J Simms,A,3,AX479,24.15,1,20.82,False,18,49," + "6,2.47,14.2,11.83,\r\n")) + self.file2_open = mock.mock_open(read_data='') + + self.model1 = models.CSVModel('file1') + self.model2 = models.CSVModel('file2') + + @mock.patch('abq_data_entry.models.Path.exists') + def test_get_all_records(self, mock_path_exists): + mock_path_exists.return_value = True + + with mock.patch( + 'abq_data_entry.models.open', + self.file1_open + ): + records = self.model1.get_all_records() + + self.assertEqual(len(records), 2) + self.assertIsInstance(records, list) + self.assertIsInstance(records[0], dict) + + fields = ( + 'Date', 'Time', 'Technician', 'Lab', 'Plot', + 'Seed Sample', 'Humidity', 'Light', + 'Temperature', 'Equipment Fault', 'Plants', + 'Blossoms', 'Fruit', 'Min Height', 'Max Height', + 'Med Height', 'Notes') + + for field in fields: + self.assertIn(field, records[0].keys()) + + # testing boolean conversion + self.assertFalse(records[0]['Equipment Fault']) + + self.file1_open.assert_called_with( + Path('file1'), 'r', encoding='utf-8' + ) + + @mock.patch('abq_data_entry.models.Path.exists') + def test_get_record(self, mock_path_exists): + mock_path_exists.return_value = True + + with mock.patch( + 'abq_data_entry.models.open', + self.file1_open + ): + record0 = self.model1.get_record(0) + record1 = self.model1.get_record(1) + + self.assertNotEqual(record0, record1) + self.assertEqual(record0['Date'], '2021-06-01') + self.assertEqual(record1['Plot'], '3') + self.assertEqual(record0['Med Height'], '5.09') + + @mock.patch('abq_data_entry.models.Path.exists') + def test_save_record(self, mock_path_exists): + + record = { + "Date": '2021-07-01', "Time": '12:00', + "Technician": 'Test Technician', "Lab": 'E', + "Plot": '17', "Seed Sample": 'test sample', + "Humidity": '10', "Light": '99', + "Temperature": '20', "Equipment Fault": False, + "Plants": '10', "Blossoms": '200', + "Fruit": '250', "Min Height": '40', + "Max Height": '50', "Med Height": '55', + "Notes": 'Test Note\r\nTest Note\r\n' + } + record_as_csv = ( + '2021-07-01,12:00,Test Technician,E,17,test sample,10,99,' + '20,False,10,200,250,40,50,55,"Test Note\r\nTest Note\r\n"' + '\r\n') + + # test appending a file + mock_path_exists.return_value = True + + # test insert + with mock.patch('abq_data_entry.models.open', self.file2_open): + self.model2.save_record(record, None) + self.file2_open.assert_called_with( + Path('file2'), 'a', encoding='utf-8', newline='' + ) + file2_handle = self.file2_open() + file2_handle.write.assert_called_with(record_as_csv) + + # test update + with mock.patch('abq_data_entry.models.open', self.file1_open): + self.model1.save_record(record, 1) + self.file1_open.assert_called_with( + Path('file1'), 'w', encoding='utf-8', newline='' + ) + file1_handle = self.file1_open() + file1_handle.write.assert_has_calls([ + mock.call( + 'Date,Time,Technician,Lab,Plot,Seed Sample,Humidity,Light,' + 'Temperature,Equipment Fault,Plants,Blossoms,Fruit,' + 'Min Height,Max Height,Med Height,Notes\r\n'), + mock.call( + '2021-06-01,8:00,J Simms,A,2,AX478,24.47,1.01,21.44,False,' + '14,27,1,2.35,9.2,5.09,\r\n'), + mock.call( + '2021-07-01,12:00,Test Technician,E,17,test sample,10,99,' + '20,False,10,200,250,40,50,55,"Test Note\r\nTest Note\r\n"' + '\r\n') + ]) + + # test new file + mock_path_exists.return_value = False + with mock.patch('abq_data_entry.models.open', self.file2_open): + self.model2.save_record(record, None) + file2_handle = self.file2_open() + file2_handle.write.assert_has_calls([ + mock.call( + 'Date,Time,Technician,Lab,Plot,Seed Sample,Humidity,Light,' + 'Temperature,Equipment Fault,Plants,Blossoms,Fruit,' + 'Min Height,Max Height,Med Height,Notes\r\n'), + mock.call(record_as_csv) + ]) + with self.assertRaises(IndexError): + self.model2.save_record(record, 2) diff --git a/Chapter11/ABQ_Data_Entry/abq_data_entry/test/test_widgets.py b/Chapter11/ABQ_Data_Entry/abq_data_entry/test/test_widgets.py new file mode 100644 index 0000000..87173e3 --- /dev/null +++ b/Chapter11/ABQ_Data_Entry/abq_data_entry/test/test_widgets.py @@ -0,0 +1,243 @@ +from .. import widgets +from unittest import TestCase +from unittest.mock import Mock +import tkinter as tk +from tkinter import ttk + + +class TkTestCase(TestCase): + """A test case designed for Tkinter widgets and views""" + + keysyms = { + '-': 'minus', + ' ': 'space', + ':': 'colon', + # For more see http://www.tcl.tk/man/tcl8.4/TkCmd/keysyms.htm + } + @classmethod + def setUpClass(cls): + cls.root = tk.Tk() + cls.root.wait_visibility() + + @classmethod + def tearDownClass(cls): + cls.root.update() + cls.root.destroy() + + def type_in_widget(self, widget, string): + widget.focus_force() + for char in string: + char = self.keysyms.get(char, char) + self.root.update() + widget.event_generate(''.format(char)) + self.root.update() + + def click_on_widget(self, widget, x, y, button=1): + widget.focus_force() + self.root.update() + widget.event_generate("".format(button), x=x, y=y) + self.root.update() + + @staticmethod + def find_element(widget, element): + """Return x and y coordinates where element can be found""" + widget.update_idletasks() + x_coords = range(widget.winfo_width()) + y_coords = range(widget.winfo_height()) + for x in x_coords: + for y in y_coords: + if widget.identify(x, y) == element: + return (x, y) + raise Exception(f'{element} was not found in widget') + + +class TestValidatedMixin(TkTestCase): + + def setUp(self): + class TestClass(widgets.ValidatedMixin, ttk.Entry): + pass + self.vw1 = TestClass(self.root) + + def assertEndsWith(self, text, ending): + if not text.endswith(ending): + raise AssertionError( + "'{}' does not end with '{}'".format(text, ending) + ) + + def test_init(self): + + # check error var setup + self.assertIsInstance(self.vw1.error, tk.StringVar) + + # check validation config + self.assertEqual(self.vw1.cget('validate'), 'all') + self.assertEndsWith( + self.vw1.cget('validatecommand'), + '%P %s %S %V %i %d' + ) + self.assertEndsWith( + self.vw1.cget('invalidcommand'), + '%P %s %S %V %i %d' + ) + + def test__validate(self): + + # by default, _validate should return true + args = { + 'proposed': 'abc', + 'current': 'ab', + 'char': 'c', + 'event': 'key', + 'index': '2', + 'action': '1' + } + # test key validate routing + self.assertTrue( + self.vw1._validate(**args) + ) + fake_key_val = Mock(return_value=False) + self.vw1._key_validate = fake_key_val + self.assertFalse( + self.vw1._validate(**args) + ) + fake_key_val.assert_called_with(**args) + + # test focusout validate routing + args['event'] = 'focusout' + self.assertTrue(self.vw1._validate(**args)) + fake_focusout_val = Mock(return_value=False) + self.vw1._focusout_validate = fake_focusout_val + self.assertFalse(self.vw1._validate(**args)) + fake_focusout_val.assert_called_with(event='focusout') + + + def test_trigger_focusout_validation(self): + + fake_focusout_val = Mock(return_value=False) + self.vw1._focusout_validate = fake_focusout_val + fake_focusout_invalid = Mock() + self.vw1._focusout_invalid = fake_focusout_invalid + + val = self.vw1.trigger_focusout_validation() + self.assertFalse(val) + fake_focusout_val.assert_called_with(event='focusout') + fake_focusout_invalid.assert_called_with(event='focusout') + + +class TestValidatedSpinbox(TkTestCase): + + def setUp(self): + self.value = tk.DoubleVar() + self.vsb = widgets.ValidatedSpinbox( + self.root, + textvariable=self.value, + from_=-10, to=10, increment=1 + ) + self.vsb.pack() + self.vsb.wait_visibility() + ttk.Style().theme_use('classic') + self.vsb.update_idletasks() + + def tearDown(self): + self.vsb.destroy() + + def key_validate(self, new, current=''): + return self.vsb._key_validate( + new, # inserted char + 'end', # position to insert + current, # current value + current + new, # proposed value + '1' # action code (1 == insert) + ) + + def click_arrow_naive(self, arrow, times=1): + x = self.vsb.winfo_width() - 5 + y = 5 if arrow == 'up' else 15 + for _ in range(times): + self.click_on_widget(self.vsb, x=x, y=y) + + def click_arrow(self, arrow, times=1): + """Click the arrow the given number of times. + + arrow must be up or down""" + # Format for classic theme + element = f'{arrow}arrow' + x, y = self.find_element(self.vsb, element) + for _ in range(times): + self.click_on_widget(self.vsb, x=x, y=y) + + def test__key_validate(self): + ################### + # Unit-test Style # + ################### + + # test valid input + for x in range(10): + x = str(x) + p_valid = self.vsb._key_validate(x, 'end', '', x, '1') + n_valid = self.vsb._key_validate(x, 'end', '-', '-' + x, '1') + self.assertTrue(p_valid) + self.assertTrue(n_valid) + + # test letters + valid = self.key_validate('a') + self.assertFalse(valid) + + # test non-increment number + valid = self.key_validate('1', '0.') + self.assertFalse(valid) + + # test too high number + valid = self.key_validate('0', '10') + self.assertFalse(valid) + + def test__key_validate_integration(self): + ########################## + # Integration test style # + ########################## + + self.vsb.delete(0, 'end') + self.type_in_widget(self.vsb, '10') + self.assertEqual(self.vsb.get(), '10') + + self.vsb.delete(0, 'end') + self.type_in_widget(self.vsb, 'abcdef') + self.assertEqual(self.vsb.get(), '') + + self.vsb.delete(0, 'end') + self.type_in_widget(self.vsb, '200') + self.assertEqual(self.vsb.get(), '2') + + def test__focusout_validate(self): + + # test valid + for x in range(10): + self.value.set(x) + posvalid = self.vsb._focusout_validate() + self.value.set(-x) + negvalid = self.vsb._focusout_validate() + + self.assertTrue(posvalid) + self.assertTrue(negvalid) + + # test too low + self.value.set('-200') + valid = self.vsb._focusout_validate() + self.assertFalse(valid) + + # test invalid number + self.vsb.delete(0, 'end') + self.vsb.insert('end', '-a2-.3') + valid = self.vsb._focusout_validate() + self.assertFalse(valid) + + def test_arrows(self): + self.value.set(0) + self.click_arrow('up', times=1) + self.assertEqual(self.vsb.get(), '1') + + self.click_arrow('up', times=5) + self.assertEqual(self.vsb.get(), '6') + + self.click_arrow(arrow='down', times=1) + self.assertEqual(self.vsb.get(), '5') diff --git a/Chapter11/ABQ_Data_Entry/abq_data_entry/views.py b/Chapter11/ABQ_Data_Entry/abq_data_entry/views.py new file mode 100644 index 0000000..33f6df3 --- /dev/null +++ b/Chapter11/ABQ_Data_Entry/abq_data_entry/views.py @@ -0,0 +1,507 @@ +import tkinter as tk +from tkinter import ttk +from tkinter.simpledialog import Dialog +from datetime import datetime +from . import widgets as w +from .constants import FieldTypes as FT +from . import images + +class DataRecordForm(tk.Frame): + """The input form for our widgets""" + + var_types = { + FT.string: tk.StringVar, + FT.string_list: tk.StringVar, + FT.short_string_list: tk.StringVar, + FT.iso_date_string: tk.StringVar, + FT.long_string: tk.StringVar, + FT.decimal: tk.DoubleVar, + FT.integer: tk.IntVar, + FT.boolean: tk.BooleanVar + } + + def _add_frame(self, label, style='', cols=3): + """Add a labelframe to the form""" + + frame = ttk.LabelFrame(self, text=label) + if style: + frame.configure(style=style) + frame.grid(sticky=tk.W + tk.E) + for i in range(cols): + frame.columnconfigure(i, weight=1) + return frame + + def __init__(self, parent, model, settings, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + + self.model= model + self.settings = settings + fields = self.model.fields + + # new for ch9 + style = ttk.Style() + + # Frame styles + style.configure( + 'RecordInfo.TLabelframe', + background='khaki', padx=10, pady=10 + ) + style.configure( + 'EnvironmentInfo.TLabelframe', background='lightblue', + padx=10, pady=10 + ) + style.configure( + 'PlantInfo.TLabelframe', + background='lightgreen', padx=10, pady=10 + ) + # Style the label Element as well + style.configure( + 'RecordInfo.TLabelframe.Label', background='khaki', + padx=10, pady=10 + ) + style.configure( + 'EnvironmentInfo.TLabelframe.Label', + background='lightblue', padx=10, pady=10 + ) + style.configure( + 'PlantInfo.TLabelframe.Label', + background='lightgreen', padx=10, pady=10 + ) + + # Style for the form labels and buttons + style.configure('RecordInfo.TLabel', background='khaki') + style.configure('RecordInfo.TRadiobutton', background='khaki') + style.configure('EnvironmentInfo.TLabel', background='lightblue') + style.configure( + 'EnvironmentInfo.TCheckbutton', + background='lightblue' + ) + style.configure('PlantInfo.TLabel', background='lightgreen') + + + # Create a dict to keep track of input widgets + self._vars = { + key: self.var_types[spec['type']]() + for key, spec in fields.items() + } + + # Build the form + self.columnconfigure(0, weight=1) + + # new chapter 8 + # variable to track current record id + self.current_record = None + + # Label for displaying what record we're editing + self.record_label = ttk.Label(self) + self.record_label.grid(row=0, column=0) + + # Record info section + r_info = self._add_frame( + "Record Information", 'RecordInfo.TLabelframe' + ) + + # line 1 + w.LabelInput( + r_info, "Date", + field_spec=fields['Date'], + var=self._vars['Date'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=0, column=0) + w.LabelInput( + r_info, "Time", + field_spec=fields['Time'], + var=self._vars['Time'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=0, column=1) + w.LabelInput( + r_info, "Technician", + field_spec=fields['Technician'], + var=self._vars['Technician'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=0, column=2) + # line 2 + w.LabelInput( + r_info, "Lab", + field_spec=fields['Lab'], + var=self._vars['Lab'], + label_args={'style': 'RecordInfo.TLabel'}, + input_args={ + 'button_args':{'style': 'RecordInfo.TRadiobutton'} + } + ).grid(row=1, column=0) + w.LabelInput( + r_info, "Plot", + field_spec=fields['Plot'], + var=self._vars['Plot'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=1, column=1) + w.LabelInput( + r_info, "Seed Sample", + field_spec=fields['Seed Sample'], + var=self._vars['Seed Sample'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=1, column=2) + + + # Environment Data + e_info = self._add_frame( + "Environment Data", 'EnvironmentInfo.TLabelframe' + ) + + e_info = ttk.LabelFrame( + self, + text="Environment Data", + style='EnvironmentInfo.TLabelframe' + ) + e_info.grid(row=2, column=0, sticky="we") + w.LabelInput( + e_info, "Humidity (g/m³)", + field_spec=fields['Humidity'], + var=self._vars['Humidity'], + disable_var=self._vars['Equipment Fault'], + label_args={'style': 'EnvironmentInfo.TLabel'} + ).grid(row=0, column=0) + w.LabelInput( + e_info, "Light (klx)", + field_spec=fields['Light'], + var=self._vars['Light'], + disable_var=self._vars['Equipment Fault'], + label_args={'style': 'EnvironmentInfo.TLabel'} + ).grid(row=0, column=1) + w.LabelInput( + e_info, "Temperature (°C)", + field_spec=fields['Temperature'], + disable_var=self._vars['Equipment Fault'], + var=self._vars['Temperature'], + label_args={'style': 'EnvironmentInfo.TLabel'} + ).grid(row=0, column=2) + w.LabelInput( + e_info, "Equipment Fault", + field_spec=fields['Equipment Fault'], + var=self._vars['Equipment Fault'], + label_args={'style': 'EnvironmentInfo.TLabel'}, + input_args={'style': 'EnvironmentInfo.TCheckbutton'} + ).grid(row=1, column=0, columnspan=3) + + # Plant Data section + p_info = self._add_frame("Plant Data", 'PlantInfo.TLabelframe') + + w.LabelInput( + p_info, "Plants", + field_spec=fields['Plants'], + var=self._vars['Plants'], + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=0, column=0) + w.LabelInput( + p_info, "Blossoms", + field_spec=fields['Blossoms'], + var=self._vars['Blossoms'], + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=0, column=1) + w.LabelInput( + p_info, "Fruit", + field_spec=fields['Fruit'], + var=self._vars['Fruit'], + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=0, column=2) + # Height data + # create variables to be updated for min/max height + # they can be referenced for min/max variables + min_height_var = tk.DoubleVar(value='-infinity') + max_height_var = tk.DoubleVar(value='infinity') + + w.LabelInput( + p_info, "Min Height (cm)", + field_spec=fields['Min Height'], + var=self._vars['Min Height'], + input_args={"max_var": max_height_var, + "focus_update_var": min_height_var}, + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=1, column=0) + w.LabelInput( + p_info, "Max Height (cm)", + field_spec=fields['Max Height'], + var=self._vars['Max Height'], + input_args={"min_var": min_height_var, + "focus_update_var": max_height_var}, + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=1, column=1) + w.LabelInput( + p_info, "Median Height (cm)", + field_spec=fields['Med Height'], + var=self._vars['Med Height'], + input_args={"min_var": min_height_var, + "max_var": max_height_var}, + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=1, column=2) + + + # Notes section -- Update grid row value for ch8 + w.LabelInput( + self, "Notes", field_spec=fields['Notes'], + var=self._vars['Notes'], input_args={"width": 85, "height": 10} + ).grid(sticky="nsew", row=4, column=0, padx=10, pady=10) + + # buttons + buttons = tk.Frame(self) + buttons.grid(sticky=tk.W + tk.E, row=5) + self.save_button_logo = tk.PhotoImage(file=images.SAVE_ICON) + self.savebutton = ttk.Button( + buttons, text="Save", command=self._on_save, + image=self.save_button_logo, compound=tk.LEFT + ) + self.savebutton.pack(side=tk.RIGHT) + + self.reset_button_logo = tk.PhotoImage(file=images.RESET_ICON) + self.resetbutton = ttk.Button( + buttons, text="Reset", command=self.reset, + image=self.reset_button_logo, compound=tk.LEFT + ) + self.resetbutton.pack(side=tk.RIGHT) + + # default the form + self.reset() + + def _on_save(self): + self.event_generate('<>') + + @staticmethod + def tclerror_is_blank_value(exception): + blank_value_errors = ( + 'expected integer but got ""', + 'expected floating-point number but got ""', + 'expected boolean value but got ""' + ) + is_bve = str(exception).strip() in blank_value_errors + return is_bve + + def get(self): + """Retrieve data from form as a dict""" + + # We need to retrieve the data from Tkinter variables + # and place it in regular Python objects + data = dict() + for key, var in self._vars.items(): + try: + data[key] = var.get() + except tk.TclError as e: + if self.tclerror_is_blank_value(e): + data[key] = None + else: + raise e + return data + + def reset(self): + """Resets the form entries""" + + lab = self._vars['Lab'].get() + time = self._vars['Time'].get() + technician = self._vars['Technician'].get() + try: + plot = self._vars['Plot'].get() + except tk.TclError: + plot = '' + plot_values = self._vars['Plot'].label_widget.input.cget('values') + + # clear all values + for var in self._vars.values(): + if isinstance(var, tk.BooleanVar): + var.set(False) + else: + var.set('') + + # Autofill Date + if self.settings['autofill date'].get(): + current_date = datetime.today().strftime('%Y-%m-%d') + self._vars['Date'].set(current_date) + self._vars['Time'].label_widget.input.focus() + + # check if we need to put our values back, then do it. + if ( + self.settings['autofill sheet data'].get() and + plot not in ('', 0, plot_values[-1]) + ): + self._vars['Lab'].set(lab) + self._vars['Time'].set(time) + self._vars['Technician'].set(technician) + next_plot_index = plot_values.index(plot) + 1 + self._vars['Plot'].set(plot_values[next_plot_index]) + self._vars['Seed Sample'].label_widget.input.focus() + + def get_errors(self): + """Get a list of field errors in the form""" + + errors = dict() + for key, var in self._vars.items(): + inp = var.label_widget.input + error = var.label_widget.error + + if hasattr(inp, 'trigger_focusout_validation'): + inp.trigger_focusout_validation() + if error.get(): + errors[key] = error.get() + + return errors + + # new for ch8 + def load_record(self, rownum, data=None): + self.current_record = rownum + if rownum is None: + self.reset() + self.record_label.config(text='New Record') + else: + self.record_label.config(text=f'Record #{rownum}') + for key, var in self._vars.items(): + var.set(data.get(key, '')) + try: + var.label_widget.input.trigger_focusout_validation() + except AttributeError: + pass + + +class LoginDialog(Dialog): + """A dialog that asks for username and password""" + + def __init__(self, parent, title, error=''): + + self._pw = tk.StringVar() + self._user = tk.StringVar() + self._error = tk.StringVar(value=error) + super().__init__(parent, title=title) + + def body(self, frame): + """Construct the interface and return the widget for initial focus + + Overridden from Dialog + """ + ttk.Label(frame, text='Login to ABQ').grid(row=0) + + if self._error.get(): + ttk.Label(frame, textvariable=self._error).grid(row=1) + user_inp = w.LabelInput( + frame, 'User name:', input_class=w.RequiredEntry, + var=self._user + ) + user_inp.grid() + w.LabelInput( + frame, 'Password:', input_class=w.RequiredEntry, + input_args={'show': '*'}, var=self._pw + ).grid() + return user_inp.input + + def buttonbox(self): + box = ttk.Frame(self) + ttk.Button( + box, text="Login", command=self.ok, default=tk.ACTIVE + ).grid(padx=5, pady=5) + ttk.Button( + box, text="Cancel", command=self.cancel + ).grid(row=0, column=1, padx=5, pady=5) + self.bind("", self.ok) + self.bind("", self.cancel) + box.pack() + + + def apply(self): + self.result = (self._user.get(), self._pw.get()) + + +class RecordList(tk.Frame): + """Display for CSV file contents""" + + column_defs = { + '#0': {'label': 'Row', 'anchor': tk.W}, + 'Date': {'label': 'Date', 'width': 150, 'stretch': True}, + 'Time': {'label': 'Time'}, + 'Lab': {'label': 'Lab', 'width': 40}, + 'Plot': {'label': 'Plot', 'width': 80} + } + default_width = 100 + default_minwidth = 10 + default_anchor = tk.CENTER + + def __init__(self, parent, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + self._inserted = list() + self._updated = list() + + self.columnconfigure(0, weight=1) + self.rowconfigure(0, weight=1) + + # create treeview + self.treeview = ttk.Treeview( + self, + columns=list(self.column_defs.keys())[1:], + selectmode='browse' + ) + self.treeview.grid(row=0, column=0, sticky='NSEW') + + # Configure treeview columns + for name, definition in self.column_defs.items(): + label = definition.get('label', '') + anchor = definition.get('anchor', self.default_anchor) + minwidth = definition.get('minwidth', self.default_minwidth) + width = definition.get('width', self.default_width) + stretch = definition.get('stretch', False) + self.treeview.heading(name, text=label, anchor=anchor) + self.treeview.column( + name, anchor=anchor, minwidth=minwidth, + width=width, stretch=stretch + ) + + self.treeview.bind('', self._on_open_record) + self.treeview.bind('', self._on_open_record) + + # configure scrollbar for the treeview + self.scrollbar = ttk.Scrollbar( + self, + orient=tk.VERTICAL, + command=self.treeview.yview + ) + self.treeview.configure(yscrollcommand=self.scrollbar.set) + self.scrollbar.grid(row=0, column=1, sticky='NSW') + + # configure tagging + self.treeview.tag_configure('inserted', background='lightgreen') + self.treeview.tag_configure('updated', background='lightblue') + + def populate(self, rows): + """Clear the treeview and write the supplied data rows to it.""" + for row in self.treeview.get_children(): + self.treeview.delete(row) + + cids = list(self.column_defs.keys())[1:] + for rownum, rowdata in enumerate(rows): + values = [rowdata[cid] for cid in cids] + if rownum in self._inserted: + tag = 'inserted' + elif rownum in self._updated: + tag = 'updated' + else: + tag = '' + self.treeview.insert( + '', 'end', iid=str(rownum), + text=str(rownum), values=values, tag=tag) + + if len(rows) > 0: + self.treeview.focus_set() + self.treeview.selection_set('0') + self.treeview.focus('0') + + def _on_open_record(self, *args): + + self.selected_id = int(self.treeview.selection()[0]) + self.event_generate('<>') + + def add_updated_row(self, row): + if row not in self._updated: + self._updated.append(row) + + def add_inserted_row(self, row): + if row not in self._inserted: + self._inserted.append(row) + + def clear_tags(self): + self._inserted.clear() + self._updated.clear() diff --git a/Chapter11/ABQ_Data_Entry/abq_data_entry/widgets.py b/Chapter11/ABQ_Data_Entry/abq_data_entry/widgets.py new file mode 100644 index 0000000..ec72ed4 --- /dev/null +++ b/Chapter11/ABQ_Data_Entry/abq_data_entry/widgets.py @@ -0,0 +1,441 @@ +import tkinter as tk +from tkinter import ttk +from datetime import datetime +from decimal import Decimal, InvalidOperation +from .constants import FieldTypes as FT + + +################## +# Widget Classes # +################## + +class ValidatedMixin: + """Adds a validation functionality to an input widget""" + + def __init__(self, *args, error_var=None, **kwargs): + self.error = error_var or tk.StringVar() + super().__init__(*args, **kwargs) + + vcmd = self.register(self._validate) + invcmd = self.register(self._invalid) + + style = ttk.Style() + widget_class = self.winfo_class() + validated_style = 'ValidatedInput.' + widget_class + style.map( + validated_style, + foreground=[('invalid', 'white'), ('!invalid', 'black')], + fieldbackground=[('invalid', 'darkred'), ('!invalid', 'white')] + ) + self.configure(style=validated_style) + + self.configure( + validate='all', + validatecommand=(vcmd, '%P', '%s', '%S', '%V', '%i', '%d'), + invalidcommand=(invcmd, '%P', '%s', '%S', '%V', '%i', '%d') + ) + + def _toggle_error(self, on=False): + self.configure(foreground=('red' if on else 'black')) + + def _validate(self, proposed, current, char, event, index, action): + """The validation method. + + Don't override this, override _key_validate, and _focus_validate + """ + self.error.set('') + + valid = True + # if the widget is disabled, don't validate + state = str(self.configure('state')[-1]) + if state == tk.DISABLED: + return valid + + if event == 'focusout': + valid = self._focusout_validate(event=event) + elif event == 'key': + valid = self._key_validate( + proposed=proposed, + current=current, + char=char, + event=event, + index=index, + action=action + ) + return valid + + def _focusout_validate(self, **kwargs): + return True + + def _key_validate(self, **kwargs): + return True + + def _invalid(self, proposed, current, char, event, index, action): + if event == 'focusout': + self._focusout_invalid(event=event) + elif event == 'key': + self._key_invalid( + proposed=proposed, + current=current, + char=char, + event=event, + index=index, + action=action + ) + + def _focusout_invalid(self, **kwargs): + """Handle invalid data on a focus event""" + pass + + def _key_invalid(self, **kwargs): + """Handle invalid data on a key event. By default we want to do nothing""" + pass + + def trigger_focusout_validation(self): + valid = self._validate('', '', '', 'focusout', '', '') + if not valid: + self._focusout_invalid(event='focusout') + return valid + + +class DateEntry(ValidatedMixin, ttk.Entry): + + def _key_validate(self, action, index, char, **kwargs): + valid = True + + if action == '0': # This is a delete action + valid = True + elif index in ('0', '1', '2', '3', '5', '6', '8', '9'): + valid = char.isdigit() + elif index in ('4', '7'): + valid = char == '-' + else: + valid = False + return valid + + def _focusout_validate(self, event): + valid = True + if not self.get(): + self.error.set('A value is required') + valid = False + try: + datetime.strptime(self.get(), '%Y-%m-%d') + except ValueError: + self.error.set('Invalid date') + valid = False + return valid + + +class RequiredEntry(ValidatedMixin, ttk.Entry): + + def _focusout_validate(self, event): + valid = True + if not self.get(): + valid = False + self.error.set('A value is required') + return valid + + +class ValidatedCombobox(ValidatedMixin, ttk.Combobox): + + def _key_validate(self, proposed, action, **kwargs): + valid = True + # if the user tries to delete, + # just clear the field + if action == '0': + self.set('') + return True + + # get our values list + values = self.cget('values') + # Do a case-insensitve match against the entered text + matching = [ + x for x in values + if x.lower().startswith(proposed.lower()) + ] + if len(matching) == 0: + valid = False + elif len(matching) == 1: + self.set(matching[0]) + self.icursor(tk.END) + valid = False + return valid + + def _focusout_validate(self, **kwargs): + valid = True + if not self.get(): + valid = False + self.error.set('A value is required') + return valid + + +class ValidatedSpinbox(ValidatedMixin, ttk.Spinbox): + """A Spinbox that only accepts Numbers""" + + def __init__(self, *args, min_var=None, max_var=None, + focus_update_var=None, from_='-Infinity', to='Infinity', **kwargs + ): + super().__init__(*args, from_=from_, to=to, **kwargs) + increment = Decimal(str(kwargs.get('increment', '1.0'))) + self.precision = increment.normalize().as_tuple().exponent + # there should always be a variable, + # or some of our code will fail + self.variable = kwargs.get('textvariable') + if not self.variable: + self.variable = tk.DoubleVar() + self.configure(textvariable=self.variable) + + if min_var: + self.min_var = min_var + self.min_var.trace_add('write', self._set_minimum) + if max_var: + self.max_var = max_var + self.max_var.trace_add('write', self._set_maximum) + self.focus_update_var = focus_update_var + self.bind('', self._set_focus_update_var) + + def _set_focus_update_var(self, event): + value = self.get() + if self.focus_update_var and not self.error.get(): + self.focus_update_var.set(value) + + def _set_minimum(self, *_): + current = self.get() + try: + new_min = self.min_var.get() + self.config(from_=new_min) + except (tk.TclError, ValueError): + pass + if not current: + self.delete(0, tk.END) + else: + self.variable.set(current) + self.trigger_focusout_validation() + + def _set_maximum(self, *_): + current = self.get() + try: + new_max = self.max_var.get() + self.config(to=new_max) + except (tk.TclError, ValueError): + pass + if not current: + self.delete(0, tk.END) + else: + self.variable.set(current) + self.trigger_focusout_validation() + + def _key_validate( + self, char, index, current, proposed, action, **kwargs + ): + if action == '0': + return True + valid = True + min_val = self.cget('from') + max_val = self.cget('to') + no_negative = min_val >= 0 + no_decimal = self.precision >= 0 + + # First, filter out obviously invalid keystrokes + if any([ + (char not in '-1234567890.'), + (char == '-' and (no_negative or index != '0')), + (char == '.' and (no_decimal or '.' in current)) + ]): + return False + + # At this point, proposed is either '-', '.', '-.', + # or a valid Decimal string + if proposed in '-.': + return True + + # Proposed is a valid Decimal string + # convert to Decimal and check more: + proposed = Decimal(proposed) + proposed_precision = proposed.as_tuple().exponent + + if any([ + (proposed > max_val), + (proposed_precision < self.precision) + ]): + return False + + return valid + + def _focusout_validate(self, **kwargs): + valid = True + value = self.get() + min_val = self.cget('from') + max_val = self.cget('to') + + try: + d_value = Decimal(value) + except InvalidOperation: + self.error.set(f'Invalid number string: {value}') + return False + + if d_value < min_val: + self.error.set(f'Value is too low (min {min_val})') + valid = False + if d_value > max_val: + self.error.set(f'Value is too high (max {max_val})') + valid = False + + return valid + +class ValidatedRadioGroup(ttk.Frame): + """A validated radio button group""" + + def __init__( + self, *args, variable=None, error_var=None, + values=None, button_args=None, **kwargs + ): + super().__init__(*args, **kwargs) + self.variable = variable or tk.StringVar() + self.error = error_var or tk.StringVar() + self.values = values or list() + button_args = button_args or dict() + + for v in self.values: + button = ttk.Radiobutton( + self, value=v, text=v, variable=self.variable, **button_args + ) + button.pack(side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x') + self.bind('', self.trigger_focusout_validation) + + def trigger_focusout_validation(self, *_): + self.error.set('') + if not self.variable.get(): + self.error.set('A value is required') + + +class BoundText(tk.Text): + """A Text widget with a bound variable.""" + + def __init__(self, *args, textvariable=None, **kwargs): + super().__init__(*args, **kwargs) + self._variable = textvariable + if self._variable: + # insert any default value + self.insert('1.0', self._variable.get()) + self._variable.trace_add('write', self._set_content) + self.bind('<>', self._set_var) + + def _set_var(self, *_): + """Set the variable to the text contents""" + if self.edit_modified(): + content = self.get('1.0', 'end-1chars') + self._variable.set(content) + self.edit_modified(False) + + def _set_content(self, *_): + """Set the text contents to the variable""" + self.delete('1.0', tk.END) + self.insert('1.0', self._variable.get()) + + +########################### +# Compound Widget Classes # +########################### + + +class LabelInput(ttk.Frame): + """A widget containing a label and input together.""" + + field_types = { + FT.string: RequiredEntry, + FT.string_list: ValidatedCombobox, + FT.short_string_list: ValidatedRadioGroup, + FT.iso_date_string: DateEntry, + FT.long_string: BoundText, + FT.decimal: ValidatedSpinbox, + FT.integer: ValidatedSpinbox, + FT.boolean: ttk.Checkbutton + } + + def __init__( + self, parent, label, var, input_class=None, + input_args=None, label_args=None, field_spec=None, + disable_var=None, **kwargs + ): + super().__init__(parent, **kwargs) + input_args = input_args or {} + label_args = label_args or {} + self.variable = var + self.variable.label_widget = self + + # Process the field spec to determine input_class and validation + if field_spec: + field_type = field_spec.get('type', FT.string) + input_class = input_class or self.field_types.get(field_type) + # min, max, increment + if 'min' in field_spec and 'from_' not in input_args: + input_args['from_'] = field_spec.get('min') + if 'max' in field_spec and 'to' not in input_args: + input_args['to'] = field_spec.get('max') + if 'inc' in field_spec and 'increment' not in input_args: + input_args['increment'] = field_spec.get('inc') + # values + if 'values' in field_spec and 'values' not in input_args: + input_args['values'] = field_spec.get('values') + + # setup the label + if input_class in (ttk.Checkbutton, ttk.Button): + # Buttons don't need labels, they're built-in + input_args["text"] = label + else: + self.label = ttk.Label(self, text=label, **label_args) + self.label.grid(row=0, column=0, sticky=(tk.W + tk.E)) + + # setup the variable + if input_class in ( + ttk.Checkbutton, ttk.Button, ttk.Radiobutton, ValidatedRadioGroup + ): + input_args["variable"] = self.variable + else: + input_args["textvariable"] = self.variable + + # Setup the input + if input_class == ttk.Radiobutton: + # for Radiobutton, create one input per value + self.input = tk.Frame(self) + for v in input_args.pop('values', []): + button = input_class( + self.input, value=v, text=v, **input_args) + button.pack(side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x') + self.input.error = getattr(button, 'error', None) + self.input.trigger_focusout_validation = \ + button._focusout_validate + else: + self.input = input_class(self, **input_args) + + self.input.grid(row=1, column=0, sticky=(tk.W + tk.E)) + self.columnconfigure(0, weight=1) + + # Set up error handling & display + error_style = 'Error.' + label_args.get('style', 'TLabel') + ttk.Style().configure(error_style, foreground='darkred') + self.error = getattr(self.input, 'error', tk.StringVar()) + ttk.Label(self, textvariable=self.error, style=error_style).grid( + row=2, column=0, sticky=(tk.W + tk.E) + ) + + # Set up disable variable + if disable_var: + self.disable_var = disable_var + self.disable_var.trace_add('write', self._check_disable) + + def _check_disable(self, *_): + if not hasattr(self, 'disable_var'): + return + + if self.disable_var.get(): + self.input.configure(state=tk.DISABLED) + self.variable.set('') + self.error.set('') + else: + self.input.configure(state=tk.NORMAL) + + def grid(self, sticky=(tk.E + tk.W), **kwargs): + """Override grid to add default sticky values""" + super().grid(sticky=sticky, **kwargs) diff --git a/Chapter11/ABQ_Data_Entry/abq_data_record_2021-04-22.csv b/Chapter11/ABQ_Data_Entry/abq_data_record_2021-04-22.csv new file mode 100644 index 0000000..18797a3 --- /dev/null +++ b/Chapter11/ABQ_Data_Entry/abq_data_record_2021-04-22.csv @@ -0,0 +1,4 @@ +Date,Time,Technician,Lab,Plot,Seed Sample,Humidity,Light,Temperature,Equipment Fault,Plants,Blossoms,Fruit,Min Height,Max Height,Med Height,Notes +2021-05-04,8:00,J Simms,A,4,AX3423,8.0,10.0,30.0,False,2,30,42,10.0,15.0,12.0, +2021-05-04,8:00,J Simms,A,2,ZX23423,2.0,3.0,5.0,False,10,3,2,10.0,30.0,20.0, +2021-05-04,8:00,J Simms,A,4,AX2433,10.0,11.0,12.0,False,1,2,1,10.0,20.0,15.0, diff --git a/Chapter11/ABQ_Data_Entry/docs/Application_layout.png b/Chapter11/ABQ_Data_Entry/docs/Application_layout.png new file mode 100644 index 0000000000000000000000000000000000000000..93990f232d19518ca465e7715bbf4f0f10cabce9 GIT binary patch literal 9117 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 mufwd~J*Rr~aor9m$$ITZW{ Date: Mon, 2 Aug 2021 16:08:53 -0500 Subject: [PATCH 29/32] updated views per ch8 changes --- Chapter09/ABQ_Data_Entry/abq_data_entry/views.py | 7 +++++-- Chapter10/ABQ_Data_Entry/abq_data_entry/views.py | 7 +++++-- Chapter11/ABQ_Data_Entry/abq_data_entry/views.py | 7 +++++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/Chapter09/ABQ_Data_Entry/abq_data_entry/views.py b/Chapter09/ABQ_Data_Entry/abq_data_entry/views.py index 33f6df3..a188493 100644 --- a/Chapter09/ABQ_Data_Entry/abq_data_entry/views.py +++ b/Chapter09/ABQ_Data_Entry/abq_data_entry/views.py @@ -490,10 +490,13 @@ def populate(self, rows): self.treeview.focus('0') def _on_open_record(self, *args): - - self.selected_id = int(self.treeview.selection()[0]) self.event_generate('<>') + @property + def selected_id(self): + selection = self.treeview.selection() + return int(selection[0]) if selection else None + def add_updated_row(self, row): if row not in self._updated: self._updated.append(row) diff --git a/Chapter10/ABQ_Data_Entry/abq_data_entry/views.py b/Chapter10/ABQ_Data_Entry/abq_data_entry/views.py index 33f6df3..a188493 100644 --- a/Chapter10/ABQ_Data_Entry/abq_data_entry/views.py +++ b/Chapter10/ABQ_Data_Entry/abq_data_entry/views.py @@ -490,10 +490,13 @@ def populate(self, rows): self.treeview.focus('0') def _on_open_record(self, *args): - - self.selected_id = int(self.treeview.selection()[0]) self.event_generate('<>') + @property + def selected_id(self): + selection = self.treeview.selection() + return int(selection[0]) if selection else None + def add_updated_row(self, row): if row not in self._updated: self._updated.append(row) diff --git a/Chapter11/ABQ_Data_Entry/abq_data_entry/views.py b/Chapter11/ABQ_Data_Entry/abq_data_entry/views.py index 33f6df3..a188493 100644 --- a/Chapter11/ABQ_Data_Entry/abq_data_entry/views.py +++ b/Chapter11/ABQ_Data_Entry/abq_data_entry/views.py @@ -490,10 +490,13 @@ def populate(self, rows): self.treeview.focus('0') def _on_open_record(self, *args): - - self.selected_id = int(self.treeview.selection()[0]) self.event_generate('<>') + @property + def selected_id(self): + selection = self.treeview.selection() + return int(selection[0]) if selection else None + def add_updated_row(self, row): if row not in self._updated: self._updated.append(row) From 877c799be76be70d9c3b675c876a8c85b05424a1 Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Mon, 2 Aug 2021 16:09:05 -0500 Subject: [PATCH 30/32] Added chapter12 code --- Chapter12/ABQ_Data_Entry/.gitignore | 2 + Chapter12/ABQ_Data_Entry/README.rst | 43 ++ Chapter12/ABQ_Data_Entry/abq_data_entry.py | 4 + .../ABQ_Data_Entry/abq_data_entry/__init__.py | 0 .../abq_data_entry/application.py | 277 +++++++++ .../abq_data_entry/constants.py | 12 + .../abq_data_entry/images/__init__.py | 20 + .../abq_data_entry/images/abq_logo-16x10.png | Bin 0 -> 1346 bytes .../abq_data_entry/images/abq_logo-32x20.png | Bin 0 -> 2637 bytes .../abq_data_entry/images/abq_logo-64x40.png | Bin 0 -> 5421 bytes .../abq_data_entry/images/browser-2x.png | Bin 0 -> 174 bytes .../abq_data_entry/images/file-2x.png | Bin 0 -> 167 bytes .../abq_data_entry/images/list-2x.png | Bin 0 -> 160 bytes .../images/question-mark-2x.xbm | 6 + .../abq_data_entry/images/reload-2x.png | Bin 0 -> 336 bytes .../abq_data_entry/images/x-2x.xbm | 6 + .../ABQ_Data_Entry/abq_data_entry/mainmenu.py | 362 +++++++++++ .../ABQ_Data_Entry/abq_data_entry/models.py | 351 +++++++++++ .../abq_data_entry/test/__init__.py | 0 .../abq_data_entry/test/test_application.py | 72 +++ .../abq_data_entry/test/test_models.py | 137 +++++ .../abq_data_entry/test/test_widgets.py | 219 +++++++ .../ABQ_Data_Entry/abq_data_entry/views.py | 573 ++++++++++++++++++ .../ABQ_Data_Entry/abq_data_entry/widgets.py | 441 ++++++++++++++ .../docs/Application_layout.png | Bin 0 -> 9117 bytes .../docs/abq_data_entry_spec.rst | 97 +++ .../docs/lab-tech-paper-form.png | Bin 0 -> 22018 bytes Chapter12/ABQ_Data_Entry/sql/create_db.sql | 82 +++ .../ABQ_Data_Entry/sql/lookup_populate.sql | 20 + Chapter12/create_sample_data.py | 92 +++ Chapter12/psycopg2_demo.py | 67 ++ 31 files changed, 2883 insertions(+) create mode 100644 Chapter12/ABQ_Data_Entry/.gitignore create mode 100644 Chapter12/ABQ_Data_Entry/README.rst create mode 100644 Chapter12/ABQ_Data_Entry/abq_data_entry.py create mode 100644 Chapter12/ABQ_Data_Entry/abq_data_entry/__init__.py create mode 100644 Chapter12/ABQ_Data_Entry/abq_data_entry/application.py create mode 100644 Chapter12/ABQ_Data_Entry/abq_data_entry/constants.py create mode 100644 Chapter12/ABQ_Data_Entry/abq_data_entry/images/__init__.py create mode 100644 Chapter12/ABQ_Data_Entry/abq_data_entry/images/abq_logo-16x10.png create mode 100644 Chapter12/ABQ_Data_Entry/abq_data_entry/images/abq_logo-32x20.png create mode 100644 Chapter12/ABQ_Data_Entry/abq_data_entry/images/abq_logo-64x40.png create mode 100644 Chapter12/ABQ_Data_Entry/abq_data_entry/images/browser-2x.png create mode 100644 Chapter12/ABQ_Data_Entry/abq_data_entry/images/file-2x.png create mode 100644 Chapter12/ABQ_Data_Entry/abq_data_entry/images/list-2x.png create mode 100644 Chapter12/ABQ_Data_Entry/abq_data_entry/images/question-mark-2x.xbm create mode 100644 Chapter12/ABQ_Data_Entry/abq_data_entry/images/reload-2x.png create mode 100644 Chapter12/ABQ_Data_Entry/abq_data_entry/images/x-2x.xbm create mode 100644 Chapter12/ABQ_Data_Entry/abq_data_entry/mainmenu.py create mode 100644 Chapter12/ABQ_Data_Entry/abq_data_entry/models.py create mode 100644 Chapter12/ABQ_Data_Entry/abq_data_entry/test/__init__.py create mode 100644 Chapter12/ABQ_Data_Entry/abq_data_entry/test/test_application.py create mode 100644 Chapter12/ABQ_Data_Entry/abq_data_entry/test/test_models.py create mode 100644 Chapter12/ABQ_Data_Entry/abq_data_entry/test/test_widgets.py create mode 100644 Chapter12/ABQ_Data_Entry/abq_data_entry/views.py create mode 100644 Chapter12/ABQ_Data_Entry/abq_data_entry/widgets.py create mode 100644 Chapter12/ABQ_Data_Entry/docs/Application_layout.png create mode 100644 Chapter12/ABQ_Data_Entry/docs/abq_data_entry_spec.rst create mode 100644 Chapter12/ABQ_Data_Entry/docs/lab-tech-paper-form.png create mode 100644 Chapter12/ABQ_Data_Entry/sql/create_db.sql create mode 100644 Chapter12/ABQ_Data_Entry/sql/lookup_populate.sql create mode 100644 Chapter12/create_sample_data.py create mode 100644 Chapter12/psycopg2_demo.py diff --git a/Chapter12/ABQ_Data_Entry/.gitignore b/Chapter12/ABQ_Data_Entry/.gitignore new file mode 100644 index 0000000..d646835 --- /dev/null +++ b/Chapter12/ABQ_Data_Entry/.gitignore @@ -0,0 +1,2 @@ +*.pyc +__pycache__/ diff --git a/Chapter12/ABQ_Data_Entry/README.rst b/Chapter12/ABQ_Data_Entry/README.rst new file mode 100644 index 0000000..5a47dd7 --- /dev/null +++ b/Chapter12/ABQ_Data_Entry/README.rst @@ -0,0 +1,43 @@ +============================ + ABQ Data Entry Application +============================ + +Description +=========== + +This program provides a data entry form for ABQ Agrilabs laboratory data. + +Features +-------- + + * Provides a validated entry form to ensure correct data + * Stores data to ABQ-format CSV files + * Auto-fills form fields whenever possible + +Authors +======= + +Alan D Moore, 2021 + +Requirements +============ + + * Python 3.7 or higher + * Tkinter + +Usage +===== + +To start the application, run:: + + python3 ABQ_Data_Entry/abq_data_entry.py + + +General Notes +============= + +The CSV file will be saved to your current directory in the format +``abq_data_record_CURRENTDATE.csv``, where CURRENTDATE is today's date in ISO format. + +This program only appends to the CSV file. You should have a spreadsheet program +installed in case you need to edit or check the file. diff --git a/Chapter12/ABQ_Data_Entry/abq_data_entry.py b/Chapter12/ABQ_Data_Entry/abq_data_entry.py new file mode 100644 index 0000000..a3b3a0d --- /dev/null +++ b/Chapter12/ABQ_Data_Entry/abq_data_entry.py @@ -0,0 +1,4 @@ +from abq_data_entry.application import Application + +app = Application() +app.mainloop() diff --git a/Chapter12/ABQ_Data_Entry/abq_data_entry/__init__.py b/Chapter12/ABQ_Data_Entry/abq_data_entry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Chapter12/ABQ_Data_Entry/abq_data_entry/application.py b/Chapter12/ABQ_Data_Entry/abq_data_entry/application.py new file mode 100644 index 0000000..cc3219f --- /dev/null +++ b/Chapter12/ABQ_Data_Entry/abq_data_entry/application.py @@ -0,0 +1,277 @@ +"""The application/controller class for ABQ Data Entry""" + +import tkinter as tk +from tkinter import ttk +from tkinter import messagebox +from tkinter import filedialog +from tkinter import font +import platform + +from . import views as v +from . import models as m +from .mainmenu import get_main_menu_for_os +from . import images + +class Application(tk.Tk): + """Application root window""" + + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # move here for ch12 because we need some settings data to authenticate + self.settings_model = m.SettingsModel() + self._load_settings() + + # Hide window while GUI is built + self.withdraw() + + # Authenticate + if not self._show_login(): + self.destroy() + return + + # show the window + self.deiconify() + + # Create model + # remove for ch12 + # self.model = m.CSVModel() + + # Begin building GUI + self.title("ABQ Data Entry Application") + self.columnconfigure(0, weight=1) + + # Set taskbar icon + self.taskbar_icon = tk.PhotoImage(file=images.ABQ_LOGO_64) + self.iconphoto(True, self.taskbar_icon) + + # Create the menu + #menu = MainMenu(self, self.settings) + menu_class = get_main_menu_for_os(platform.system()) + menu = menu_class(self, self.settings) + + self.config(menu=menu) + event_callbacks = { +# remove file capabilities for ch12 +# '<>': self._on_file_select, + '<>': lambda _: self.quit(), + '<>': self._show_recordlist, + '<>': self._new_record, + + } + for sequence, callback in event_callbacks.items(): + self.bind(sequence, callback) + + # new for ch9 + self.logo = tk.PhotoImage(file=images.ABQ_LOGO_32) + ttk.Label( + self, + text="ABQ Data Entry Application", + font=("TkDefaultFont", 16), + image=self.logo, + compound=tk.LEFT + ).grid(row=0) + + # The notebook + self.notebook = ttk.Notebook(self) + self.notebook.enable_traversal() + self.notebook.grid(row=1, padx=10, sticky='NSEW') + + # The data record form + self.recordform_icon = tk.PhotoImage(file=images.FORM_ICON) + self.recordform = v.DataRecordForm(self, self.model, self.settings) + self.notebook.add( + self.recordform, text='Entry Form', + image=self.recordform_icon, compound=tk.LEFT + ) + self.recordform.bind('<>', self._on_save) + + + # The data record list + self.recordlist_icon = tk.PhotoImage(file=images.LIST_ICON) + self.recordlist = v.RecordList(self) + self.notebook.insert( + 0, self.recordlist, text='Records', + image=self.recordlist_icon, compound=tk.LEFT + ) + self._populate_recordlist() + self.recordlist.bind('<>', self._open_record) + + + self._show_recordlist() + + # status bar + self.status = tk.StringVar() + self.statusbar = ttk.Label(self, textvariable=self.status) + self.statusbar.grid(sticky=(tk.W + tk.E), row=3, padx=10) + + + self.records_saved = 0 + + + def _on_save(self, *_): + """Handles file-save requests""" + + # Check for errors first + + errors = self.recordform.get_errors() + if errors: + self.status.set( + "Cannot save, error in fields: {}" + .format(', '.join(errors.keys())) + ) + message = "Cannot save record" + detail = "The following fields have errors: \n * {}".format( + '\n * '.join(errors.keys()) + ) + messagebox.showerror( + title='Error', + message=message, + detail=detail + ) + return False + + data = self.recordform.get() + rowkey = self.recordform.current_record + self.model.save_record(data, rowkey) + if rowkey is not None: + self.recordlist.add_updated_row(rowkey) + else: + rowkey = (data['Date'], data['Time'], data['Lab'], data['Plot']) + self.recordlist.add_inserted_row(rowkey) + self.records_saved += 1 + self.status.set( + "{} records saved this session".format(self.records_saved) + ) + self.recordform.reset() + self._populate_recordlist() + +# Remove for ch12 +# def _on_file_select(self, *_): +# """Handle the file->select action""" +# +# filename = filedialog.asksaveasfilename( +# title='Select the target file for saving records', +# defaultextension='.csv', +# filetypes=[('CSV', '*.csv *.CSV')] +# ) +# if filename: +# self.model = m.CSVModel(filename=filename) +# self.inserted_rows.clear() +# self.updated_rows.clear() +# self._populate_recordlist() + + @staticmethod + def _simple_login(username, password): + """A basic authentication backend with a hardcoded user and password""" + return username == 'abq' and password == 'Flowers' + + # new ch12 + def _database_login(self, username, password): + """Try to login to the database and create self.data_model""" + db_host = self.settings['db_host'].get() + db_name = self.settings['db_name'].get() + try: + self.model = m.SQLModel( + db_host, db_name, username, password) + except m.pg.OperationalError as e: + print(e) + return False + return True + + def _show_login(self): + """Show login dialog and attempt to login""" + error = '' + title = "Login to ABQ Data Entry" + while True: + login = v.LoginDialog(self, title, error) + if not login.result: # User canceled + return False + username, password = login.result + if self._database_login(username, password): + return True + error = 'Login Failed' # loop and redisplay + + def _load_settings(self): + """Load settings into our self.settings dict.""" + + vartypes = { + 'bool': tk.BooleanVar, + 'str': tk.StringVar, + 'int': tk.IntVar, + 'float': tk.DoubleVar + } + + # create our dict of settings variables from the model's settings. + self.settings = dict() + for key, data in self.settings_model.fields.items(): + vartype = vartypes.get(data['type'], tk.StringVar) + self.settings[key] = vartype(value=data['value']) + + # put a trace on the variables so they get stored when changed. + for var in self.settings.values(): + var.trace_add('write', self._save_settings) + + # update font settings after loading them + self._set_font() + self.settings['font size'].trace_add('write', self._set_font) + self.settings['font family'].trace_add('write', self._set_font) + + # process theme + style = ttk.Style() + theme = self.settings.get('theme').get() + if theme in style.theme_names(): + style.theme_use(theme) + + def _save_settings(self, *_): + """Save the current settings to a preferences file""" + + for key, variable in self.settings.items(): + self.settings_model.set(key, variable.get()) + self.settings_model.save() + + def _show_recordlist(self, *_): + """Show the recordform""" + self.notebook.select(self.recordlist) + + def _populate_recordlist(self): + try: + rows = self.model.get_all_records() + except Exception as e: + messagebox.showerror( + title='Error', + message='Problem reading file', + detail=str(e) + ) + else: + self.recordlist.populate(rows) + + def _new_record(self, *_): + """Open the record form with a blank record""" + self.recordform.load_record(None, None) + self.notebook.select(self.recordform) + + + def _open_record(self, *_): + """Open the Record selected recordlist id in the recordform""" + rowkey = self.recordlist.selected_id + try: + record = self.model.get_record(rowkey) + except Exception as e: + messagebox.showerror( + title='Error', message='Problem reading file', detail=str(e) + ) + return + self.recordform.load_record(rowkey, record) + self.notebook.select(self.recordform) + + # new chapter 9 + def _set_font(self, *_): + """Set the application's font""" + font_size = self.settings['font size'].get() + font_family = self.settings['font family'].get() + font_names = ('TkDefaultFont', 'TkMenuFont', 'TkTextFont') + for font_name in font_names: + tk_font = font.nametofont(font_name) + tk_font.config(size=font_size, family=font_family) diff --git a/Chapter12/ABQ_Data_Entry/abq_data_entry/constants.py b/Chapter12/ABQ_Data_Entry/abq_data_entry/constants.py new file mode 100644 index 0000000..e747dce --- /dev/null +++ b/Chapter12/ABQ_Data_Entry/abq_data_entry/constants.py @@ -0,0 +1,12 @@ +"""Global constants and classes needed by other modules in ABQ Data Entry""" +from enum import Enum, auto + +class FieldTypes(Enum): + string = auto() + string_list = auto() + short_string_list = auto() + iso_date_string = auto() + long_string = auto() + decimal = auto() + integer = auto() + boolean = auto() diff --git a/Chapter12/ABQ_Data_Entry/abq_data_entry/images/__init__.py b/Chapter12/ABQ_Data_Entry/abq_data_entry/images/__init__.py new file mode 100644 index 0000000..57538d9 --- /dev/null +++ b/Chapter12/ABQ_Data_Entry/abq_data_entry/images/__init__.py @@ -0,0 +1,20 @@ +from pathlib import Path + +# This gives us the parent directory of this file (__init__.py) +IMAGE_DIRECTORY = Path(__file__).parent + +ABQ_LOGO_16 = IMAGE_DIRECTORY / 'abq_logo-16x10.png' +ABQ_LOGO_32 = IMAGE_DIRECTORY / 'abq_logo-32x20.png' +ABQ_LOGO_64 = IMAGE_DIRECTORY / 'abq_logo-64x40.png' + +# PNG icons + +SAVE_ICON = IMAGE_DIRECTORY / 'file-2x.png' +RESET_ICON = IMAGE_DIRECTORY / 'reload-2x.png' +LIST_ICON = IMAGE_DIRECTORY / 'list-2x.png' +FORM_ICON = IMAGE_DIRECTORY / 'browser-2x.png' + + +# BMP icons +QUIT_BMP = IMAGE_DIRECTORY / 'x-2x.xbm' +ABOUT_BMP = IMAGE_DIRECTORY / 'question-mark-2x.xbm' diff --git a/Chapter12/ABQ_Data_Entry/abq_data_entry/images/abq_logo-16x10.png b/Chapter12/ABQ_Data_Entry/abq_data_entry/images/abq_logo-16x10.png new file mode 100644 index 0000000000000000000000000000000000000000..255f2739e10827885fe11f4fece3f7e42bf73064 GIT binary patch literal 1346 zcmV-I1-<%-P) zaB^>EX>4U6ba`-PAZ2)IW&i+q+O3sqlI%7JM*nLSS%LuZ&~k(xRoOw7pHJ?dnLCx6 zls-|pyCOH&W)W)-9L)_LG2>T9&;O3zC5{ZKz{zR61+Z#hFG zKWM&JxgRhd?ftx8tG=96+jeXj6%!Y(ycqFZfw?K}ryW-);pu+;{JuM~PltRXD<3cX z?FnN3F=Rw6^@nlJigWgB$DY+x91|8bZI%y)T#+w~0^JIBsAk;L*(mi04aiRMKB~FP>n>%s5-L~A&&t-1Cg^d zP7okfUI>y~5i!6CzP|B|)1%AEFELsMAXIMM1_%wnYE4l;-U2l=RJ5t86?F~mI!vsY znxU3&?+q7ku5Rug-hG5b3k?g8h#sSJ7qq5!>)xaH(#L?)0n-Ct4`_^$oRTdyEj=T9 zj*0S_ZR)h?GiIM-@sib+E?d50^|HpMjZ)fe>$dGXcHiTm){dNZ^q}cZoPNe9HF|g6 zw^_cMK9 zTUyDFvfH6;> z;~-~iW{-1^-pYHSk<)4AKw}QH$br5kDg*Fx4^(_zJxyt>Eg4`sM^@G#t*8sef1fXB zOYMt6ZbS!*k_>;?0AjQ*a*)zq{shn6wOaaUK zNlSf$=-Pryrn)FpkWy%ilEl9#)q*v3Dt34jBAQSPmiLmpd=4fmDD=$to^#K+M*y{8 ztiWGiXGa}1wUVR5B2scKgn%E(Bfz@{qd@^VdQWK zGQcgs1=ye_6672W-4nB2VCfZZg|Fiq%IO1jreqjN=0BQn1Jq;!5CD8P_xXKzKx=&i zVC(X1!%a480TP5H^(W2fo0rUvf1!69ge!!VvpCM55P1KB*2b2SAw|ilglAh!Cq6T` z7GvP`6QUm`$aZ^)1z`DSl=epvlBP-g@ms{;{qsyp1Vxw)@cdzfr@>wt*WrQh4t1F} z0D63PXiV;m4}np?aK zy}C`Te~OJ?i);c(b02{~MVHD?MZlP4+hN_U6)yxe5A!phm>e+hNBnGk-DO*?g5#Wz z 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 literal 0 HcmV?d00001 diff --git a/Chapter12/ABQ_Data_Entry/abq_data_entry/images/abq_logo-64x40.png b/Chapter12/ABQ_Data_Entry/abq_data_entry/images/abq_logo-64x40.png new file mode 100644 index 0000000000000000000000000000000000000000..be9458ff1799dcb20cdab36eb100511f1b213606 GIT binary patch literal 5421 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 z zqn~~&0hgPPx9?`d^Dpxszx;%WRdC6mPGj$W8rA|+a3PD*Pyjc%HSQRD8x6o^<#5ZY z#mu~FDw^T$T@gZn=4SERt1)bilx9N`W{jE8Z$o{KL|vC4!_fPrbRyCm;jY2oB|Qk- z0T|{&*&~UB}N}KMDmVXh{yI8mZr(VYC2R z3ZSsm&27f3utX@o$mHPD>K}x+QTEVK$()N^SV?O(NSIUm9VQIB2&Cwl7WV-GPh_~T z+4|mizA^ng0NT@Wf{$JW#p1=hKW9Bew`Vut6^-_kExX6??{7Q9Wivm{G2G`2@VUKezzoBZOrLDj?le%>g0IL6GLfP4!pVeE zTA72^LGO2|6c!+eho~>9K6C(<2U1BzZ^6{c3vmYvC^HJtg-+5=aojx32d57~7zhEy z@W|Fx-1qy{q!w&OmwhjEc@>}uH#{>JN(HoQ*h$Ij?_A8p32I7sbOVIIrD+(tjsy$~ zrUtHNj9W4Msg+<)Yzq{Gc=pLd%zyB+-1zHX8P3hO)gYc8ul8_&Xj>Bbcs3RGGDm!k zXrbaV*#ygXF5tq+Bd63V!u{0NaJ@hwJNXz2G8(u=15(?qK;l{l5XH@}z4G_Ti%8|;g1nfF-0JOdryK_zf z0J`SSxmGydOupjfoK!$_>{PDg9~rzLZ(4lrDL~WN{K+o0B-$`x$f&$Y(Yd1lAwVx+ z^TB#3W99f@%K z)EREr`h-fL{6Gvg-}NMhaOJHi&$sY$1cnR0NGgY<#}_<*?xo z(CTf=U>O$(Q0_;BzbWmjSI?Ai#`M8RFqIwejdJ109*xNCuu!(hKH}em!{(~kj_M`Ndi01WuaY6#q}C@ogK zSj`XT4gc~=0MI>PB{;nIQzES~T3g#Wee49WwszW^S||tv@D&9qDm#xs=a;c}=N{hp z=U4C#b1|r@if7+kjnH-FE?+|UmH*bj-S_^H&co66HSpBknNe$js}F&Bp?bG?;Qk8! zW!X4fZa&}l2Ld5L>%O%lRWP${&^`S67aRE5txr*2?dG#jO}0edrXb)S`2W%bsU$qI zXfG?C3F9~(KL~G)h5BqXa0?hK;f8%+)~c-+ei-*%NS6TRKp}tK*W_A(FzC&&|G(g~XJ9*hU6cEN XhPBd*{i(!N00000NkvXXu0mjfWYLOi literal 0 HcmV?d00001 diff --git a/Chapter12/ABQ_Data_Entry/abq_data_entry/images/browser-2x.png b/Chapter12/ABQ_Data_Entry/abq_data_entry/images/browser-2x.png new file mode 100644 index 0000000000000000000000000000000000000000..2a6efb0d3ad404d3e6df2c4f7e2c9441945b9377 GIT binary patch literal 174 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`EX7WqAsj$Z!;#Vf4nJ za0`PlBg3pY50TY@YDj113tz;u?-H7A2U5ODE0AZ z@(JiUbDVWXB2U-ehVx>J7`TOIPjuP)f90d5KxVdP#t_fW4nJ za0`PlBg3pY54nJ za0`PlBg3pY5<39QJlxHc{CLc#vuuf5Bd*}mNt{%q1HX$~}v!PC{xWt~$(695?x BFjxQp literal 0 HcmV?d00001 diff --git a/Chapter12/ABQ_Data_Entry/abq_data_entry/images/question-mark-2x.xbm b/Chapter12/ABQ_Data_Entry/abq_data_entry/images/question-mark-2x.xbm new file mode 100644 index 0000000..8981c9b --- /dev/null +++ b/Chapter12/ABQ_Data_Entry/abq_data_entry/images/question-mark-2x.xbm @@ -0,0 +1,6 @@ +#define question_mark_2x_width 16 +#define question_mark_2x_height 16 +static unsigned char question_mark_2x_bits[] = { + 0xc0, 0x0f, 0xe0, 0x1f, 0x70, 0x38, 0x30, 0x30, 0x00, 0x30, 0x00, 0x30, + 0x00, 0x18, 0x00, 0x1c, 0x00, 0x0e, 0x00, 0x07, 0x00, 0x03, 0x00, 0x03, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x03 }; diff --git a/Chapter12/ABQ_Data_Entry/abq_data_entry/images/reload-2x.png b/Chapter12/ABQ_Data_Entry/abq_data_entry/images/reload-2x.png new file mode 100644 index 0000000000000000000000000000000000000000..2a79f1665d1156d2e140a10f7902bc69f8bb26bb GIT binary patch literal 336 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`EX7WqAsj$Z!;#Vf4nJ za0`PlBg3pY5>Y*U9P$-MRL!(@9^X8fhq)(E zFJr#NVI#l5GfnbJnH`@4HZ}2mE9hQ-qOz;b%b=q*ea-b7vyRl=n5ChmHt%@F;V9;a z6Z5u)Owe0%kz&bqH;`{=)pW(4FHy7Od@(eJR=g%=_#4 zi__yCxo)1S*Z0cIYSL7p_d6W?wLbFf-tj58wY^j&QX})1kLtS(w^GujuU`_^dvqmc e)r~mQpVIIC_*-03aXkd|J%gvKpUXO@geCw(qlWDO literal 0 HcmV?d00001 diff --git a/Chapter12/ABQ_Data_Entry/abq_data_entry/images/x-2x.xbm b/Chapter12/ABQ_Data_Entry/abq_data_entry/images/x-2x.xbm new file mode 100644 index 0000000..940af96 --- /dev/null +++ b/Chapter12/ABQ_Data_Entry/abq_data_entry/images/x-2x.xbm @@ -0,0 +1,6 @@ +#define x_2x_width 16 +#define x_2x_height 16 +static unsigned char x_2x_bits[] = { + 0x04, 0x10, 0x0e, 0x38, 0x1f, 0x7c, 0x3e, 0x7e, 0x7c, 0x3f, 0xf8, 0x1f, + 0xf0, 0x0f, 0xe0, 0x07, 0xf0, 0x07, 0xf8, 0x0f, 0xfc, 0x1f, 0x7e, 0x3e, + 0x3f, 0x7c, 0x1e, 0x78, 0x0c, 0x30, 0x00, 0x00 }; diff --git a/Chapter12/ABQ_Data_Entry/abq_data_entry/mainmenu.py b/Chapter12/ABQ_Data_Entry/abq_data_entry/mainmenu.py new file mode 100644 index 0000000..78d92f7 --- /dev/null +++ b/Chapter12/ABQ_Data_Entry/abq_data_entry/mainmenu.py @@ -0,0 +1,362 @@ +"""The Main Menu class for ABQ Data Entry""" + +import tkinter as tk +from tkinter import ttk +from tkinter import messagebox +from tkinter import font + +from . import images + +class GenericMainMenu(tk.Menu): + """The Application's main menu""" + + accelerators = { + 'file_open': 'Ctrl+O', + 'quit': 'Ctrl+Q', + 'record_list': 'Ctrl+L', + 'new_record': 'Ctrl+R', + } + + keybinds = { + '': '<>', + '': '<>', + '': '<>', + '': '<>' + } + + styles = {} + + def _event(self, sequence): + """Return a callback function that generates the sequence""" + def callback(*_): + root = self.master.winfo_toplevel() + root.event_generate(sequence) + + return callback + + def _create_icons(self): + + # must be done in a method because PhotoImage can't be created + # until there is a Tk instance. + # There isn't one when the class is defined, but there is when + # the instance is created. + self.icons = { + # 'file_open': tk.PhotoImage(file=images.SAVE_ICON), + 'record_list': tk.PhotoImage(file=images.LIST_ICON), + 'new_record': tk.PhotoImage(file=images.FORM_ICON), + 'quit': tk.BitmapImage(file=images.QUIT_BMP, foreground='red'), + 'about': tk.BitmapImage( + file=images.ABOUT_BMP, foreground='#CC0', background='#A09' + ), + } + + def _add_file_open(self, menu): + + menu.add_command( + label='Select file…', command=self._event('<>'), + image=self.icons.get('file'), compound=tk.LEFT + ) + + def _add_quit(self, menu): + menu.add_command( + label='Quit', command=self._event('<>'), + image=self.icons.get('quit'), compound=tk.LEFT + ) + + def _add_autofill_date(self, menu): + menu.add_checkbutton( + label='Autofill Date', variable=self.settings['autofill date'] + ) + + def _add_autofill_sheet(self, menu): + menu.add_checkbutton( + label='Autofill Sheet data', + variable=self.settings['autofill sheet data'] + ) + + def _add_font_size_menu(self, menu): + font_size_menu = tk.Menu(self, tearoff=False, **self.styles) + for size in range(6, 17, 1): + font_size_menu.add_radiobutton( + label=size, value=size, + variable=self.settings['font size'] + ) + menu.add_cascade(label='Font size', menu=font_size_menu) + + def _add_font_family_menu(self, menu): + font_family_menu = tk.Menu(self, tearoff=False, **self.styles) + for family in font.families(): + font_family_menu.add_radiobutton( + label=family, value=family, + variable=self.settings['font family'] + ) + menu.add_cascade(label='Font family', menu=font_family_menu) + + def _add_themes_menu(self, menu): + style = ttk.Style() + themes_menu = tk.Menu(self, tearoff=False, **self.styles) + for theme in style.theme_names(): + themes_menu.add_radiobutton( + label=theme, value=theme, + variable=self.settings['theme'] + ) + menu.add_cascade(label='Theme', menu=themes_menu) + self.settings['theme'].trace_add('write', self._on_theme_change) + + def _add_go_record_list(self, menu): + menu.add_command( + label="Record List", command=self._event('<>'), + image=self.icons.get('record_list'), compound=tk.LEFT + ) + + def _add_go_new_record(self, menu): + menu.add_command( + label="New Record", command=self._event('<>'), + image=self.icons.get('new_record'), compound=tk.LEFT + ) + + def _add_about(self, menu): + menu.add_command( + label='About…', command=self.show_about, + image=self.icons.get('about'), compound=tk.LEFT + ) + + def _build_menu(self): + # The file menu + self._menus['File'] = tk.Menu(self, tearoff=False, **self.styles) + #self._add_file_open(self._menus['File']) + self._menus['File'].add_separator() + self._add_quit(self._menus['File']) + + # The options menu + self._menus['Options'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_autofill_date(self._menus['Options']) + self._add_autofill_sheet(self._menus['Options']) + self._add_font_size_menu(self._menus['Options']) + self._add_font_family_menu(self._menus['Options']) + self._add_themes_menu(self._menus['Options']) + + # switch from recordlist to recordform + self._menus['Go'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_go_record_list(self._menus['Go']) + self._add_go_new_record(self._menus['Go']) + + # The help menu + self._menus['Help'] = tk.Menu(self, tearoff=False, **self.styles) + self.add_cascade(label='Help', menu=self._menus['Help']) + self._add_about(self._menus['Help']) + + for label, menu in self._menus.items(): + self.add_cascade(label=label, menu=menu) + self.configure(**self.styles) + + def __init__(self, parent, settings, **kwargs): + super().__init__(parent, **kwargs) + self.settings = settings + self._create_icons() + self._menus = dict() + self._build_menu() + self._bind_accelerators() + self.configure(**self.styles) + + def show_about(self): + """Show the about dialog""" + + about_message = 'ABQ Data Entry' + about_detail = ( + 'by Alan D Moore\n' + 'For assistance please contact the author.' + ) + + messagebox.showinfo( + title='About', message=about_message, detail=about_detail + ) + @staticmethod + def _on_theme_change(*_): + """Popup a message about theme changes""" + message = "Change requires restart" + detail = ( + "Theme changes do not take effect" + " until application restart" + ) + messagebox.showwarning( + title='Warning', + message=message, + detail=detail + ) + + def _bind_accelerators(self): + + for key, sequence in self.keybinds.items(): + self.bind_all(key, self._event(sequence)) + +class WindowsMainMenu(GenericMainMenu): + """ + Changes: + - Windows uses file->exit instead of file->quit, + and no accelerator is used. + - Windows can handle commands on the menubar, so + put 'Record List' / 'New Record' on the bar + - Windows can't handle icons on the menu bar, though + - Put 'options' under 'Tools' with separator + """ + + def _create_icons(self): + super()._create_icons() + del(self.icons['new_record']) + del(self.icons['record_list']) + + def __init__(self, *args, **kwargs): + del(self.keybinds['']) + super().__init__(*args, **kwargs) + + def _add_quit(self, menu): + menu.add_command( + label='Exit', + command=self._event('<>'), + image=self.icons.get('quit'), + compound=tk.LEFT + ) + + def _build_menu(self): + # File Menu + self._menus['File'] = tk.Menu(self, tearoff=False) +# self._add_file_open(self._menus['File']) + self._menus['File'].add_separator() + self._add_quit(self._menus['File']) + + #Tools menu + self._menus['Tools'] = tk.Menu(self, tearoff=False) + self._add_autofill_date(self._menus['Tools']) + self._add_autofill_sheet(self._menus['Tools']) + self._add_font_size_menu(self._menus['Tools']) + self._add_font_family_menu(self._menus['Tools']) + self._add_themes_menu(self._menus['Tools']) + + # The help menu + self._menus['Help'] = tk.Menu(self, tearoff=False) + self._add_about(self._menus['Help']) + + # Build main menu + self.add_cascade(label='File', menu=self._menus['File']) + self.add_cascade(label='Tools', menu=self._menus['Tools']) + self._add_go_record_list(self) + self._add_go_new_record(self) + self.add_cascade(label='Help', menu=self._menus['Help']) + + +class LinuxMainMenu(GenericMainMenu): + """Differences for Linux: + + - Edit menu for autofill options + - View menu for font & theme options + - Use color theme for menu + """ + styles = { + 'background': '#333', + 'foreground': 'white', + 'activebackground': '#777', + 'activeforeground': 'white', + 'relief': tk.GROOVE + } + + + def _build_menu(self): + self._menus['File'] = tk.Menu(self, tearoff=False, **self.styles) +# self._add_file_open(self._menus['File']) + self._menus['File'].add_separator() + self._add_quit(self._menus['File']) + + # The edit menu + self._menus['Edit'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_autofill_date(self._menus['Edit']) + self._add_autofill_sheet(self._menus['Edit']) + + # The View menu + self._menus['View'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_font_size_menu(self._menus['View']) + self._add_font_family_menu(self._menus['View']) + self._add_themes_menu(self._menus['View']) + + # switch from recordlist to recordform + self._menus['Go'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_go_record_list(self._menus['Go']) + self._add_go_new_record(self._menus['Go']) + + # The help menu + self._menus['Help'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_about(self._menus['Help']) + + for label, menu in self._menus.items(): + self.add_cascade(label=label, menu=menu) + + +class MacOsMainMenu(GenericMainMenu): + """ + Differences for MacOS: + + - Create App Menu + - Move about to app menu, remove 'help' + - Remove redundant quit command + - Change accelerators to Command-[] + - Add View menu for font & theme options + - Add Edit menu for autofill options + - Add Window menu for navigation commands + """ + keybinds = { + '': '<>', + '': '<>', + '': '<>' + } + accelerators = { + 'file_open': 'Cmd-O', + 'record_list': 'Cmd-L', + 'new_record': 'Cmd-R', + } + + def _add_about(self, menu): + menu.add_command( + label='About ABQ Data Entry', command=self.show_about, + image=self.icons.get('about'), compound=tk.LEFT + ) + + def _build_menu(self): + self._menus['ABQ Data Entry'] = tk.Menu( + self, tearoff=False, + name='apple' + ) + self._add_about(self._menus['ABQ Data Entry']) + self._menus['ABQ Data Entry'].add_separator() + + self._menus['File'] = tk.Menu(self, tearoff=False) +# self._add_file_open(self._menus['File']) + + self._menus['Edit'] = tk.Menu(self, tearoff=False) + self._add_autofill_date(self._menus['Edit']) + self._add_autofill_sheet(self._menus['Edit']) + + # View menu + self._menus['View'] = tk.Menu(self, tearoff=False) + self._add_font_size_menu(self._menus['View']) + self._add_font_family_menu(self._menus['View']) + self._add_themes_menu(self._menus['View']) + + # Window Menu + self._menus['Window'] = tk.Menu(self, name='window', tearoff=False) + self._add_go_record_list(self._menus['Window']) + self._add_go_new_record(self._menus['Window']) + + for label, menu in self._menus.items(): + self.add_cascade(label=label, menu=menu) + + +def get_main_menu_for_os(os_name): + """Return the menu class appropriate to the given OS""" + menus = { + 'Linux': LinuxMainMenu, + 'Darwin': MacOsMainMenu, + 'freebsd7': LinuxMainMenu, + 'Windows': WindowsMainMenu + } + + return menus.get(os_name, GenericMainMenu) diff --git a/Chapter12/ABQ_Data_Entry/abq_data_entry/models.py b/Chapter12/ABQ_Data_Entry/abq_data_entry/models.py new file mode 100644 index 0000000..4092e16 --- /dev/null +++ b/Chapter12/ABQ_Data_Entry/abq_data_entry/models.py @@ -0,0 +1,351 @@ +import csv +from pathlib import Path +import os +import json +import platform +from datetime import datetime + +import psycopg2 as pg +from psycopg2.extras import DictCursor + +from .constants import FieldTypes as FT + + +class SQLModel: + """Data Model for SQL data storage""" + + fields = { + "Date": {'req': True, 'type': FT.iso_date_string}, + "Time": {'req': True, 'type': FT.string_list, + 'values': ['8:00', '12:00', '16:00', '20:00']}, + "Technician": {'req': True, 'type': FT.string_list, + 'values': []}, + "Lab": {'req': True, 'type': FT.short_string_list, + 'values': []}, + "Plot": {'req': True, 'type': FT.string_list, + 'values': []}, + "Seed Sample": {'req': True, 'type': FT.string}, + "Humidity": {'req': True, 'type': FT.decimal, + 'min': 0.5, 'max': 52.0, 'inc': .01}, + "Light": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 100.0, 'inc': .01}, + "Temperature": {'req': True, 'type': FT.decimal, + 'min': 4, 'max': 40, 'inc': .01}, + "Equipment Fault": {'req': False, 'type': FT.boolean}, + "Plants": {'req': True, 'type': FT.integer, + 'min': 0, 'max': 20}, + "Blossoms": {'req': True, 'type': FT.integer, + 'min': 0, 'max': 1000}, + "Fruit": {'req': True, 'type': FT.integer, + 'min': 0, 'max': 1000}, + "Min Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Max Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Med Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Notes": {'req': False, 'type': FT.long_string} + } + lc_update_query = ( + 'UPDATE lab_checks SET lab_tech_id = ' + '(SELECT id FROM lab_techs WHERE name = %(Technician)s) ' + 'WHERE date=%(Date)s AND time=%(Time)s AND lab_id=%(Lab)s' + ) + + lc_insert_query = ( + 'INSERT INTO lab_checks VALUES (%(Date)s, %(Time)s, %(Lab)s, ' + '(SELECT id FROM lab_techs WHERE name LIKE %(Technician)s))' + ) + + pc_update_query = ( + 'UPDATE plot_checks SET date=%(Date)s, time=%(Time)s, ' + 'lab_id=%(Lab)s, plot=%(Plot)s, seed_sample = %(Seed Sample)s, ' + 'humidity = %(Humidity)s, light = %(Light)s, ' + 'temperature = %(Temperature)s, ' + 'equipment_fault = %(Equipment Fault)s, ' + 'blossoms = %(Blossoms)s, plants = %(Plants)s, ' + 'fruit = %(Fruit)s, max_height = %(Max Height)s, ' + 'min_height = %(Min Height)s, median_height = %(Med Height)s, ' + 'notes = %(Notes)s WHERE date=%(key_date)s AND time=%(key_time)s ' + 'AND lab_id=%(key_lab)s AND plot=%(key_plot)s') + + pc_insert_query = ( + 'INSERT INTO plot_checks VALUES (%(Date)s, %(Time)s, %(Lab)s,' + ' %(Plot)s, %(Seed Sample)s, %(Humidity)s, %(Light)s,' + ' %(Temperature)s, %(Equipment Fault)s, %(Blossoms)s, %(Plants)s,' + ' %(Fruit)s, %(Max Height)s, %(Min Height)s,' + ' %(Med Height)s, %(Notes)s)') + + def __init__(self, host, database, user, password): + self.connection = pg.connect(host=host, database=database, + user=user, password=password, cursor_factory=DictCursor) + + techs = self.query("SELECT * FROM lab_techs ORDER BY name") + labs = self.query("SELECT id FROM labs ORDER BY id") + plots = self.query("SELECT DISTINCT plot FROM plots ORDER BY plot") + self.fields['Technician']['values'] = [x['name'] for x in techs] + self.fields['Lab']['values'] = [x['id'] for x in labs] + self.fields['Plot']['values'] = [str(x['plot']) for x in plots] + + def query(self, query, parameters=None): + cursor = self.connection.cursor() + try: + cursor.execute(query, parameters) + except (pg.Error) as e: + self.connection.rollback() + raise e + else: + self.connection.commit() + # cursor.description is None when + # no rows are returned + if cursor.description is not None: + return cursor.fetchall() + + def get_all_records(self, all_dates=False): + """Return all records. + + By default, only return today's records, unless + all_dates is True. + """ + query = ('SELECT * FROM data_record_view ' + 'WHERE %(all_dates)s OR "Date" = CURRENT_DATE ' + 'ORDER BY "Date" DESC, "Time", "Lab", "Plot"') + return self.query(query, {'all_dates': all_dates}) + + def get_record(self, rowkey): + """Return a single record + + rowkey must be a tuple of date, time, lab, and plot + """ + date, time, lab, plot = rowkey + query = ( + 'SELECT * FROM data_record_view ' + 'WHERE "Date" = %(date)s AND "Time" = %(time)s ' + 'AND "Lab" = %(lab)s AND "Plot" = %(plot)s') + result = self.query( + query, + {"date": date, "time": time, "lab": lab, "plot": plot} + ) + return result[0] if result else dict() + + def save_record(self, record, rowkey): + """Save a record to the database + + rowkey must be a tuple of date, time, lab, and plot. + Or None if this is a new record. + """ + if rowkey: + key_date, key_time, key_lab, key_plot = rowkey + record.update({ + "key_date": key_date, + "key_time": key_time, + "key_lab": key_lab, + "key_plot": key_plot + }) + + # Lab check is based on the entered date/time/lab + if self.get_lab_check( + record['Date'], record['Time'], record['Lab'] + ): + lc_query = self.lc_update_query + else: + lc_query = self.lc_insert_query + # Plot check is based on the key values + if rowkey: + pc_query = self.pc_update_query + else: + pc_query = self.pc_insert_query + + self.query(lc_query, record) + self.query(pc_query, record) + + def get_lab_check(self, date, time, lab): + """Retrieve the lab check record for the given date, time, and lab""" + query = ('SELECT date, time, lab_id, lab_tech_id, ' + 'lt.name as lab_tech FROM lab_checks JOIN lab_techs lt ' + 'ON lab_checks.lab_tech_id = lt.id WHERE ' + 'lab_id = %(lab)s AND date = %(date)s AND time = %(time)s') + results = self.query( + query, {'date': date, 'time': time, 'lab': lab}) + return results[0] if results else dict() + + def get_current_seed_sample(self, lab, plot): + """Get the seed sample currently planted in the given lab and plot""" + result = self.query('SELECT current_seed_sample FROM plots ' + 'WHERE lab_id=%(lab)s AND plot=%(plot)s', + {'lab': lab, 'plot': plot}) + return result[0]['current_seed_sample'] if result else '' + + +class CSVModel: + """CSV file storage""" + + fields = { + "Date": {'req': True, 'type': FT.iso_date_string}, + "Time": {'req': True, 'type': FT.string_list, + 'values': ['8:00', '12:00', '16:00', '20:00']}, + "Technician": {'req': True, 'type': FT.string}, + "Lab": {'req': True, 'type': FT.short_string_list, + 'values': ['A', 'B', 'C']}, + "Plot": {'req': True, 'type': FT.string_list, + 'values': [str(x) for x in range(1, 21)]}, + "Seed Sample": {'req': True, 'type': FT.string}, + "Humidity": {'req': True, 'type': FT.decimal, + 'min': 0.5, 'max': 52.0, 'inc': .01}, + "Light": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 100.0, 'inc': .01}, + "Temperature": {'req': True, 'type': FT.decimal, + 'min': 4, 'max': 40, 'inc': .01}, + "Equipment Fault": {'req': False, 'type': FT.boolean}, + "Plants": {'req': True, 'type': FT.integer, 'min': 0, 'max': 20}, + "Blossoms": {'req': True, 'type': FT.integer, 'min': 0, 'max': 1000}, + "Fruit": {'req': True, 'type': FT.integer, 'min': 0, 'max': 1000}, + "Min Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Max Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Med Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Notes": {'req': False, 'type': FT.long_string} + } + + + def __init__(self, filename=None): + + if not filename: + datestring = datetime.today().strftime("%Y-%m-%d") + filename = "abq_data_record_{}.csv".format(datestring) + self.file = Path(filename) + + # Check for append permissions: + file_exists = os.access(self.file, os.F_OK) + parent_writeable = os.access(self.file.parent, os.W_OK) + file_writeable = os.access(self.file, os.W_OK) + if ( + (not file_exists and not parent_writeable) or + (file_exists and not file_writeable) + ): + msg = f'Permission denied accessing file: {filename}' + raise PermissionError(msg) + + + def save_record(self, data, rownum=None): + """Save a dict of data to the CSV file""" + + if rownum is None: + # This is a new record + newfile = not self.file.exists() + + with open(self.file, 'a', newline='') as fh: + csvwriter = csv.DictWriter(fh, fieldnames=self.fields.keys()) + if newfile: + csvwriter.writeheader() + csvwriter.writerow(data) + else: + # This is an update + records = self.get_all_records() + records[rownum] = data + with open(self.file, 'w', encoding='utf-8', newline='') as fh: + csvwriter = csv.DictWriter(fh, fieldnames=self.fields.keys()) + csvwriter.writeheader() + csvwriter.writerows(records) + + def get_all_records(self): + """Read in all records from the CSV and return a list""" + if not self.file.exists(): + return [] + + with open(self.file, 'r', encoding='utf-8') as fh: + # Casting to list is necessary for unit tests to work + csvreader = csv.DictReader(list(fh.readlines())) + missing_fields = set(self.fields.keys()) - set(csvreader.fieldnames) + if len(missing_fields) > 0: + fields_string = ', '.join(missing_fields) + raise Exception( + f"File is missing fields: {fields_string}" + ) + records = list(csvreader) + + # Correct issue with boolean fields + trues = ('true', 'yes', '1') + bool_fields = [ + key for key, meta + in self.fields.items() + if meta['type'] == FT.boolean + ] + for record in records: + for key in bool_fields: + record[key] = record[key].lower() in trues + return records + + def get_record(self, rownum): + """Get a single record by row number + + Callling code should catch IndexError + in case of a bad rownum. + """ + + return self.get_all_records()[rownum] + + +class SettingsModel: + """A model for saving settings""" + + fields = { + 'autofill date': {'type': 'bool', 'value': True}, + 'autofill sheet data': {'type': 'bool', 'value': True}, + 'font size': {'type': 'int', 'value': 9}, + 'font family': {'type': 'str', 'value': ''}, + 'theme': {'type': 'str', 'value': 'default'}, + 'db_host': {'type': 'str', 'value': 'localhost'}, + 'db_name': {'type': 'str', 'value': 'abq'} + } + + config_dirs = { + "Linux": Path(os.environ.get('$XDG_CONFIG_HOME', Path.home() / '.config')), + "freebsd7": Path(os.environ.get('$XDG_CONFIG_HOME', Path.home() / '.config')), + 'Darwin': Path.home() / 'Library' / 'Application Support', + 'Windows': Path.home() / 'AppData' / 'Local' + } + + def __init__(self): + # determine the file path + filename = 'abq_settings.json' + filedir = self.config_dirs.get(platform.system(), Path.home()) + self.filepath = filedir / filename + + # load in saved values + self.load() + + def set(self, key, value): + """Set a variable value""" + if ( + key in self.fields and + type(value).__name__ == self.fields[key]['type'] + ): + self.fields[key]['value'] = value + else: + raise ValueError("Bad key or wrong variable type") + + def save(self): + """Save the current settings to the file""" + json_string = json.dumps(self.fields) + with open(self.filepath, 'w', encoding='utf-8') as fh: + fh.write(json_string) + + def load(self): + """Load the settings from the file""" + + # if the file doesn't exist, return + if not self.filepath.exists(): + return + + # open the file and read in the raw values + with open(self.filepath, 'r') as fh: + raw_values = json.loads(fh.read()) + + # don't implicitly trust the raw values, but only get known keys + for key in self.fields: + if key in raw_values and 'value' in raw_values[key]: + raw_value = raw_values[key]['value'] + self.fields[key]['value'] = raw_value diff --git a/Chapter12/ABQ_Data_Entry/abq_data_entry/test/__init__.py b/Chapter12/ABQ_Data_Entry/abq_data_entry/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Chapter12/ABQ_Data_Entry/abq_data_entry/test/test_application.py b/Chapter12/ABQ_Data_Entry/abq_data_entry/test/test_application.py new file mode 100644 index 0000000..73cc7b4 --- /dev/null +++ b/Chapter12/ABQ_Data_Entry/abq_data_entry/test/test_application.py @@ -0,0 +1,72 @@ +from unittest import TestCase +from unittest.mock import patch +from .. import application + + +class TestApplication(TestCase): + records = [ + {'Date': '2018-06-01', 'Time': '8:00', 'Technician': 'J Simms', + 'Lab': 'A', 'Plot': '1', 'Seed Sample': 'AX477', + 'Humidity': '24.09', 'Light': '1.03', 'Temperature': '22.01', + 'Equipment Fault': False, 'Plants': '9', 'Blossoms': '21', + 'Fruit': '3', 'Max Height': '8.7', 'Med Height': '2.73', + 'Min Height': '1.67', 'Notes': '\n\n', + }, + {'Date': '2018-06-01', 'Time': '8:00', 'Technician': 'J Simms', + 'Lab': 'A', 'Plot': '2', 'Seed Sample': 'AX478', + 'Humidity': '24.47', 'Light': '1.01', 'Temperature': '21.44', + 'Equipment Fault': False, 'Plants': '14', 'Blossoms': '27', + 'Fruit': '1', 'Max Height': '9.2', 'Med Height': '5.09', + 'Min Height': '2.35', 'Notes': '' + } + ] + + settings = { + 'autofill date': {'type': 'bool', 'value': True}, + 'autofill sheet data': {'type': 'bool', 'value': True}, + 'font size': {'type': 'int', 'value': 9}, + 'font family': {'type': 'str', 'value': ''}, + 'theme': {'type': 'str', 'value': 'default'} + } + + def setUp(self): + # can be parenthesized in python 3.10+ + with \ + patch('abq_data_entry.application.m.CSVModel') as csvmodel,\ + patch('abq_data_entry.application.m.SettingsModel') as settingsmodel,\ + patch('abq_data_entry.application.Application._show_login') as show_login,\ + patch('abq_data_entry.application.v.DataRecordForm'),\ + patch('abq_data_entry.application.v.RecordList'),\ + patch('abq_data_entry.application.ttk.Notebook'),\ + patch('abq_data_entry.application.get_main_menu_for_os')\ + : + + settingsmodel().fields = self.settings + csvmodel().get_all_records.return_value = self.records + show_login.return_value = True + self.app = application.Application() + + def tearDown(self): + self.app.update() + self.app.destroy() + + def test_show_recordlist(self): + self.app._show_recordlist() + self.app.update() + self.app.notebook.select.assert_called_with(self.app.recordlist) + + def test_populate_recordlist(self): + # test correct functions + self.app._populate_recordlist() + self.app.model.get_all_records.assert_called() + self.app.recordlist.populate.assert_called_with(self.records) + + # test exceptions + + self.app.model.get_all_records.side_effect = Exception('Test message') + with patch('abq_data_entry.application.messagebox'): + self.app._populate_recordlist() + application.messagebox.showerror.assert_called_with( + title='Error', message='Problem reading file', + detail='Test message' + ) diff --git a/Chapter12/ABQ_Data_Entry/abq_data_entry/test/test_models.py b/Chapter12/ABQ_Data_Entry/abq_data_entry/test/test_models.py new file mode 100644 index 0000000..0cb1baf --- /dev/null +++ b/Chapter12/ABQ_Data_Entry/abq_data_entry/test/test_models.py @@ -0,0 +1,137 @@ +from .. import models +from unittest import TestCase +from unittest import mock + +from pathlib import Path + +class TestCSVModel(TestCase): + + def setUp(self): + + self.file1_open = mock.mock_open( + read_data=( + "Date,Time,Technician,Lab,Plot,Seed Sample,Humidity,Light," + "Temperature,Equipment Fault,Plants,Blossoms,Fruit,Min Height," + "Max Height,Med Height,Notes\r\n" + "2021-06-01,8:00,J Simms,A,2,AX478,24.47,1.01,21.44,False,14," + "27,1,2.35,9.2,5.09,\r\n" + "2021-06-01,8:00,J Simms,A,3,AX479,24.15,1,20.82,False,18,49," + "6,2.47,14.2,11.83,\r\n")) + self.file2_open = mock.mock_open(read_data='') + + self.model1 = models.CSVModel('file1') + self.model2 = models.CSVModel('file2') + + @mock.patch('abq_data_entry.models.Path.exists') + def test_get_all_records(self, mock_path_exists): + mock_path_exists.return_value = True + + with mock.patch( + 'abq_data_entry.models.open', + self.file1_open + ): + records = self.model1.get_all_records() + + self.assertEqual(len(records), 2) + self.assertIsInstance(records, list) + self.assertIsInstance(records[0], dict) + + fields = ( + 'Date', 'Time', 'Technician', 'Lab', 'Plot', + 'Seed Sample', 'Humidity', 'Light', + 'Temperature', 'Equipment Fault', 'Plants', + 'Blossoms', 'Fruit', 'Min Height', 'Max Height', + 'Med Height', 'Notes') + + for field in fields: + self.assertIn(field, records[0].keys()) + + # testing boolean conversion + self.assertFalse(records[0]['Equipment Fault']) + + self.file1_open.assert_called_with( + Path('file1'), 'r', encoding='utf-8' + ) + + @mock.patch('abq_data_entry.models.Path.exists') + def test_get_record(self, mock_path_exists): + mock_path_exists.return_value = True + + with mock.patch( + 'abq_data_entry.models.open', + self.file1_open + ): + record0 = self.model1.get_record(0) + record1 = self.model1.get_record(1) + + self.assertNotEqual(record0, record1) + self.assertEqual(record0['Date'], '2021-06-01') + self.assertEqual(record1['Plot'], '3') + self.assertEqual(record0['Med Height'], '5.09') + + @mock.patch('abq_data_entry.models.Path.exists') + def test_save_record(self, mock_path_exists): + + record = { + "Date": '2021-07-01', "Time": '12:00', + "Technician": 'Test Technician', "Lab": 'E', + "Plot": '17', "Seed Sample": 'test sample', + "Humidity": '10', "Light": '99', + "Temperature": '20', "Equipment Fault": False, + "Plants": '10', "Blossoms": '200', + "Fruit": '250', "Min Height": '40', + "Max Height": '50', "Med Height": '55', + "Notes": 'Test Note\r\nTest Note\r\n' + } + record_as_csv = ( + '2021-07-01,12:00,Test Technician,E,17,test sample,10,99,' + '20,False,10,200,250,40,50,55,"Test Note\r\nTest Note\r\n"' + '\r\n') + + # test appending a file + mock_path_exists.return_value = True + + # test insert + with mock.patch('abq_data_entry.models.open', self.file2_open): + self.model2.save_record(record, None) + self.file2_open.assert_called_with( + Path('file2'), 'a', encoding='utf-8' + ) + file2_handle = self.file2_open() + file2_handle.write.assert_called_with(record_as_csv) + + # test update + with mock.patch('abq_data_entry.models.open', self.file1_open): + self.model1.save_record(record, 1) + self.file1_open.assert_called_with( + Path('file1'), 'w', encoding='utf-8' + ) + file1_handle = self.file1_open() + file1_handle.write.assert_has_calls([ + mock.call( + 'Date,Time,Technician,Lab,Plot,Seed Sample,Humidity,Light,' + 'Temperature,Equipment Fault,Plants,Blossoms,Fruit,' + 'Min Height,Max Height,Med Height,Notes\r\n'), + mock.call( + '2021-06-01,8:00,J Simms,A,2,AX478,24.47,1.01,21.44,False,' + '14,27,1,2.35,9.2,5.09,\r\n'), + mock.call( + '2021-07-01,12:00,Test Technician,E,17,test sample,10,99,' + '20,False,10,200,250,40,50,55,"Test Note\r\nTest Note\r\n"' + '\r\n') + ]) + + # test new file + mock_path_exists.return_value = False + with mock.patch('abq_data_entry.models.open', self.file2_open): + self.model2.save_record(record, None) + file2_handle = self.file2_open() + file2_handle.write.assert_has_calls([ + mock.call( + 'Date,Time,Technician,Lab,Plot,Seed Sample,Humidity,Light,' + 'Temperature,Equipment Fault,Plants,Blossoms,Fruit,' + 'Min Height,Max Height,Med Height,Notes\r\n'), + mock.call(record_as_csv) + ]) + with self.assertRaises(IndexError): + self.model2.save_record(record, 2) diff --git a/Chapter12/ABQ_Data_Entry/abq_data_entry/test/test_widgets.py b/Chapter12/ABQ_Data_Entry/abq_data_entry/test/test_widgets.py new file mode 100644 index 0000000..e796eb8 --- /dev/null +++ b/Chapter12/ABQ_Data_Entry/abq_data_entry/test/test_widgets.py @@ -0,0 +1,219 @@ +from .. import widgets +from unittest import TestCase +from unittest.mock import Mock +import tkinter as tk +from tkinter import ttk + + +class TkTestCase(TestCase): + """A test case designed for Tkinter widgets and views""" + + keysyms = { + '-': 'minus', + ' ': 'space', + ':': 'colon', + # For more see http://www.tcl.tk/man/tcl8.4/TkCmd/keysyms.htm + } + @classmethod + def setUpClass(cls): + cls.root = tk.Tk() + cls.root.wait_visibility() + + @classmethod + def tearDownClass(cls): + cls.root.update() + cls.root.destroy() + + def type_in_widget(self, widget, string): + widget.focus_force() + for char in string: + char = self.keysyms.get(char, char) + self.root.update() + widget.event_generate(''.format(char)) + self.root.update() + + def click_on_widget(self, widget, x, y, button=1): + widget.focus_force() + self.root.update() + widget.event_generate("".format(button), x=x, y=y) + self.root.update() + + +class TestValidatedMixin(TkTestCase): + + def setUp(self): + class TestClass(widgets.ValidatedMixin, ttk.Entry): + pass + self.vw1 = TestClass(self.root) + + def assertEndsWith(self, text, ending): + if not text.endswith(ending): + raise AssertionError( + "'{}' does not end with '{}'".format(text, ending) + ) + + def test_init(self): + + # check error var setup + self.assertIsInstance(self.vw1.error, tk.StringVar) + + # check validation config + self.assertEqual(self.vw1.cget('validate'), 'all') + self.assertEndsWith( + self.vw1.cget('validatecommand'), + '%P %s %S %V %i %d' + ) + self.assertEndsWith( + self.vw1.cget('invalidcommand'), + '%P %s %S %V %i %d' + ) + + def test__validate(self): + + # by default, _validate should return true + args = { + 'proposed': 'abc', + 'current': 'ab', + 'char': 'c', + 'event': 'key', + 'index': '2', + 'action': '1' + } + # test key validate routing + self.assertTrue( + self.vw1._validate(**args) + ) + fake_key_val = Mock(return_value=False) + self.vw1._key_validate = fake_key_val + self.assertFalse( + self.vw1._validate(**args) + ) + fake_key_val.assert_called_with(**args) + + # test focusout validate routing + args['event'] = 'focusout' + self.assertTrue(self.vw1._validate(**args)) + fake_focusout_val = Mock(return_value=False) + self.vw1._focusout_validate = fake_focusout_val + self.assertFalse(self.vw1._validate(**args)) + fake_focusout_val.assert_called_with(event='focusout') + + + def test_trigger_focusout_validation(self): + + fake_focusout_val = Mock(return_value=False) + self.vw1._focusout_validate = fake_focusout_val + fake_focusout_invalid = Mock() + self.vw1._focusout_invalid = fake_focusout_invalid + + val = self.vw1.trigger_focusout_validation() + self.assertFalse(val) + fake_focusout_val.assert_called_with(event='focusout') + fake_focusout_invalid.assert_called_with(event='focusout') + + +class TestValidatedSpinbox(TkTestCase): + + def setUp(self): + self.value = tk.DoubleVar() + self.vsb = widgets.ValidatedSpinbox( + self.root, + textvariable=self.value, + from_=-10, to=10, increment=1 + ) + self.vsb.pack() + self.vsb.wait_visibility() + + def tearDown(self): + self.vsb.destroy() + + def key_validate(self, new, current=''): + return self.vsb._key_validate( + new, # inserted char + 'end', # position to insert + current, # current value + current + new, # proposed value + '1' # action code (1 == insert) + ) + + def click_arrow(self, arrow='inc', times=1): + x = self.vsb.winfo_width() - 5 + y = 5 if arrow == 'inc' else 15 + for _ in range(times): + self.click_on_widget(self.vsb, x=x, y=y) + + def test__key_validate(self): + ################### + # Unit-test Style # + ################### + + # test valid input + for x in range(10): + x = str(x) + p_valid = self.vsb._key_validate(x, 'end', '', x, '1') + n_valid = self.vsb._key_validate(x, 'end', '-', '-' + x, '1') + self.assertTrue(p_valid) + self.assertTrue(n_valid) + + # test letters + valid = self.key_validate('a') + self.assertFalse(valid) + + # test non-increment number + valid = self.key_validate('1', '0.') + self.assertFalse(valid) + + # test too high number + valid = self.key_validate('0', '10') + self.assertFalse(valid) + + def test__key_validate_integration(self): + ########################## + # Integration test style # + ########################## + + self.vsb.delete(0, 'end') + self.type_in_widget(self.vsb, '10') + self.assertEqual(self.vsb.get(), '10') + + self.vsb.delete(0, 'end') + self.type_in_widget(self.vsb, 'abcdef') + self.assertEqual(self.vsb.get(), '') + + self.vsb.delete(0, 'end') + self.type_in_widget(self.vsb, '200') + self.assertEqual(self.vsb.get(), '2') + + def test__focusout_validate(self): + + # test valid + for x in range(10): + self.value.set(x) + posvalid = self.vsb._focusout_validate() + self.value.set(-x) + negvalid = self.vsb._focusout_validate() + + self.assertTrue(posvalid) + self.assertTrue(negvalid) + + # test too low + self.value.set('-200') + valid = self.vsb._focusout_validate() + self.assertFalse(valid) + + # test invalid number + self.vsb.delete(0, 'end') + self.vsb.insert('end', '-a2-.3') + valid = self.vsb._focusout_validate() + self.assertFalse(valid) + + def test_arrows(self): + self.value.set(0) + self.click_arrow(times=1) + self.assertEqual(self.vsb.get(), '1') + + self.click_arrow(times=5) + self.assertEqual(self.vsb.get(), '6') + + self.click_arrow(arrow='dec', times=1) + self.assertEqual(self.vsb.get(), '5') diff --git a/Chapter12/ABQ_Data_Entry/abq_data_entry/views.py b/Chapter12/ABQ_Data_Entry/abq_data_entry/views.py new file mode 100644 index 0000000..feb634b --- /dev/null +++ b/Chapter12/ABQ_Data_Entry/abq_data_entry/views.py @@ -0,0 +1,573 @@ +import tkinter as tk +from tkinter import ttk +from tkinter.simpledialog import Dialog +from datetime import datetime +from . import widgets as w +from .constants import FieldTypes as FT +from . import images + +class DataRecordForm(tk.Frame): + """The input form for our widgets""" + + var_types = { + FT.string: tk.StringVar, + FT.string_list: tk.StringVar, + FT.short_string_list: tk.StringVar, + FT.iso_date_string: tk.StringVar, + FT.long_string: tk.StringVar, + FT.decimal: tk.DoubleVar, + FT.integer: tk.IntVar, + FT.boolean: tk.BooleanVar + } + + def _add_frame(self, label, style='', cols=3): + """Add a labelframe to the form""" + + frame = ttk.LabelFrame(self, text=label) + if style: + frame.configure(style=style) + frame.grid(sticky=tk.W + tk.E) + for i in range(cols): + frame.columnconfigure(i, weight=1) + return frame + + def __init__(self, parent, model, settings, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + + self.model= model + self.settings = settings + fields = self.model.fields + + # new for ch9 + style = ttk.Style() + + # Frame styles + style.configure( + 'RecordInfo.TLabelframe', + background='khaki', padx=10, pady=10 + ) + style.configure( + 'EnvironmentInfo.TLabelframe', background='lightblue', + padx=10, pady=10 + ) + style.configure( + 'PlantInfo.TLabelframe', + background='lightgreen', padx=10, pady=10 + ) + # Style the label Element as well + style.configure( + 'RecordInfo.TLabelframe.Label', background='khaki', + padx=10, pady=10 + ) + style.configure( + 'EnvironmentInfo.TLabelframe.Label', + background='lightblue', padx=10, pady=10 + ) + style.configure( + 'PlantInfo.TLabelframe.Label', + background='lightgreen', padx=10, pady=10 + ) + + # Style for the form labels and buttons + style.configure('RecordInfo.TLabel', background='khaki') + style.configure('RecordInfo.TRadiobutton', background='khaki') + style.configure('EnvironmentInfo.TLabel', background='lightblue') + style.configure( + 'EnvironmentInfo.TCheckbutton', + background='lightblue' + ) + style.configure('PlantInfo.TLabel', background='lightgreen') + + + # Create a dict to keep track of input widgets + self._vars = { + key: self.var_types[spec['type']]() + for key, spec in fields.items() + } + + # Build the form + self.columnconfigure(0, weight=1) + + # new chapter 8 + # variable to track current record id + self.current_record = None + + # Label for displaying what record we're editing + self.record_label = ttk.Label(self) + self.record_label.grid(row=0, column=0) + + # Record info section + r_info = self._add_frame( + "Record Information", 'RecordInfo.TLabelframe' + ) + + # line 1 + w.LabelInput( + r_info, "Date", + field_spec=fields['Date'], + var=self._vars['Date'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=0, column=0) + w.LabelInput( + r_info, "Time", + field_spec=fields['Time'], + var=self._vars['Time'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=0, column=1) + # swap order for chapter 12 + w.LabelInput( + r_info, "Lab", + field_spec=fields['Lab'], + var=self._vars['Lab'], + label_args={'style': 'RecordInfo.TLabel'}, + input_args={'style': 'RecordInfo.TRadiobutton'} + ).grid(row=0, column=2) + # line 2 + w.LabelInput( + r_info, "Plot", + field_spec=fields['Plot'], + var=self._vars['Plot'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=1, column=0) + w.LabelInput( + r_info, "Technician", + field_spec=fields['Technician'], + var=self._vars['Technician'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=1, column=1) + w.LabelInput( + r_info, "Seed Sample", + field_spec=fields['Seed Sample'], + var=self._vars['Seed Sample'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=1, column=2) + + + # Environment Data + e_info = self._add_frame( + "Environment Data", 'EnvironmentInfo.TLabelframe' + ) + + e_info = ttk.LabelFrame( + self, + text="Environment Data", + style='EnvironmentInfo.TLabelframe' + ) + e_info.grid(row=2, column=0, sticky="we") + w.LabelInput( + e_info, "Humidity (g/m³)", + field_spec=fields['Humidity'], + var=self._vars['Humidity'], + disable_var=self._vars['Equipment Fault'], + label_args={'style': 'EnvironmentInfo.TLabel'} + ).grid(row=0, column=0) + w.LabelInput( + e_info, "Light (klx)", + field_spec=fields['Light'], + var=self._vars['Light'], + disable_var=self._vars['Equipment Fault'], + label_args={'style': 'EnvironmentInfo.TLabel'} + ).grid(row=0, column=1) + w.LabelInput( + e_info, "Temperature (°C)", + field_spec=fields['Temperature'], + disable_var=self._vars['Equipment Fault'], + var=self._vars['Temperature'], + label_args={'style': 'EnvironmentInfo.TLabel'} + ).grid(row=0, column=2) + w.LabelInput( + e_info, "Equipment Fault", + field_spec=fields['Equipment Fault'], + var=self._vars['Equipment Fault'], + label_args={'style': 'EnvironmentInfo.TLabel'}, + input_args={'style': 'EnvironmentInfo.TCheckbutton'} + ).grid(row=1, column=0, columnspan=3) + + # Plant Data section + p_info = self._add_frame("Plant Data", 'PlantInfo.TLabelframe') + + w.LabelInput( + p_info, "Plants", + field_spec=fields['Plants'], + var=self._vars['Plants'], + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=0, column=0) + w.LabelInput( + p_info, "Blossoms", + field_spec=fields['Blossoms'], + var=self._vars['Blossoms'], + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=0, column=1) + w.LabelInput( + p_info, "Fruit", + field_spec=fields['Fruit'], + var=self._vars['Fruit'], + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=0, column=2) + # Height data + # create variables to be updated for min/max height + # they can be referenced for min/max variables + min_height_var = tk.DoubleVar(value='-infinity') + max_height_var = tk.DoubleVar(value='infinity') + + w.LabelInput( + p_info, "Min Height (cm)", + field_spec=fields['Min Height'], + var=self._vars['Min Height'], + input_args={"max_var": max_height_var, + "focus_update_var": min_height_var}, + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=1, column=0) + w.LabelInput( + p_info, "Max Height (cm)", + field_spec=fields['Max Height'], + var=self._vars['Max Height'], + input_args={"min_var": min_height_var, + "focus_update_var": max_height_var}, + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=1, column=1) + w.LabelInput( + p_info, "Median Height (cm)", + field_spec=fields['Med Height'], + var=self._vars['Med Height'], + input_args={"min_var": min_height_var, + "max_var": max_height_var}, + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=1, column=2) + + + # Notes section -- Update grid row value for ch8 + w.LabelInput( + self, "Notes", field_spec=fields['Notes'], + var=self._vars['Notes'], input_args={"width": 85, "height": 10} + ).grid(sticky="nsew", row=4, column=0, padx=10, pady=10) + + # buttons + buttons = tk.Frame(self) + buttons.grid(sticky=tk.W + tk.E, row=5) + self.save_button_logo = tk.PhotoImage(file=images.SAVE_ICON) + self.savebutton = ttk.Button( + buttons, text="Save", command=self._on_save, + image=self.save_button_logo, compound=tk.LEFT + ) + self.savebutton.pack(side=tk.RIGHT) + + self.reset_button_logo = tk.PhotoImage(file=images.RESET_ICON) + self.resetbutton = ttk.Button( + buttons, text="Reset", command=self.reset, + image=self.reset_button_logo, compound=tk.LEFT + ) + self.resetbutton.pack(side=tk.RIGHT) + + # new for ch12 + # Triggers + for field in ('Lab', 'Plot'): + self._vars[field].trace_add( + 'write', self._populate_current_seed_sample) + + for field in ('Date', 'Time', 'Lab'): + self._vars[field].trace_add( + 'write', self._populate_tech_for_lab_check) + + # default the form + self.reset() + + def _on_save(self): + self.event_generate('<>') + + @staticmethod + def tclerror_is_blank_value(exception): + blank_value_errors = ( + 'expected integer but got ""', + 'expected floating-point number but got ""', + 'expected boolean value but got ""' + ) + is_bve = str(exception).strip() in blank_value_errors + return is_bve + + def get(self): + """Retrieve data from form as a dict""" + + # We need to retrieve the data from Tkinter variables + # and place it in regular Python objects + data = dict() + for key, var in self._vars.items(): + try: + data[key] = var.get() + except tk.TclError as e: + if self.tclerror_is_blank_value(e): + data[key] = None + else: + raise e + return data + + def reset(self): + """Resets the form entries""" + + lab = self._vars['Lab'].get() + time = self._vars['Time'].get() + technician = self._vars['Technician'].get() + try: + plot = self._vars['Plot'].get() + except tk.TclError: + plot = '' + plot_values = self._vars['Plot'].label_widget.input.cget('values') + + # clear all values + for var in self._vars.values(): + if isinstance(var, tk.BooleanVar): + var.set(False) + else: + var.set('') + + # Autofill Date + if self.settings['autofill date'].get(): + current_date = datetime.today().strftime('%Y-%m-%d') + self._vars['Date'].set(current_date) + self._vars['Time'].label_widget.input.focus() + + # check if we need to put our values back, then do it. + if ( + self.settings['autofill sheet data'].get() and + plot not in ('', 0, plot_values[-1]) + ): + self._vars['Lab'].set(lab) + self._vars['Time'].set(time) + self._vars['Technician'].set(technician) + next_plot_index = plot_values.index(plot) + 1 + self._vars['Plot'].set(plot_values[next_plot_index]) + self._vars['Seed Sample'].label_widget.input.focus() + + def get_errors(self): + """Get a list of field errors in the form""" + + errors = dict() + for key, var in self._vars.items(): + inp = var.label_widget.input + error = var.label_widget.error + + if hasattr(inp, 'trigger_focusout_validation'): + inp.trigger_focusout_validation() + if error.get(): + errors[key] = error.get() + + return errors + + # rewrite for ch12 + def load_record(self, rowkey, data=None): + """Load a record's data into the form""" + self.current_record = rowkey + if rowkey is None: + self.reset() + self.record_label.config(text='New Record') + else: + date, time, lab, plot = rowkey + title = f'Record for Lab {lab}, Plot {plot} at {date} {time}' + self.record_label.config(text=title) + for key, var in self._vars.items(): + var.set(data.get(key, '')) + try: + var.label_widget.input.trigger_focusout_validation() + except AttributeError: + pass + + # new for ch12 + + def _populate_current_seed_sample(self, *_): + """Auto-populate the current seed sample for Lab and Plot""" + if not self.settings['autofill sheet data'].get(): + return + plot = self._vars['Plot'].get() + lab = self._vars['Lab'].get() + + if plot and lab: + seed = self.model.get_current_seed_sample(lab, plot) + self._vars['Seed Sample'].set(seed) + + def _populate_tech_for_lab_check(self, *_): + """Populate technician based on the current lab check""" + if not self.settings['autofill sheet data'].get(): + return + date = self._vars['Date'].get() + try: + datetime.fromisoformat(date) + except ValueError: + return + time = self._vars['Time'].get() + lab = self._vars['Lab'].get() + + if all([date, time, lab]): + check = self.model.get_lab_check(date, time, lab) + tech = check['lab_tech'] if check else '' + self._vars['Technician'].set(tech) + + +class LoginDialog(Dialog): + """A dialog that asks for username and password""" + + def __init__(self, parent, title, error=''): + + self._pw = tk.StringVar() + self._user = tk.StringVar() + self._error = tk.StringVar(value=error) + super().__init__(parent, title=title) + + def body(self, frame): + """Construct the interface and return the widget for initial focus + + Overridden from Dialog + """ + ttk.Label(frame, text='Login to ABQ').grid(row=0) + + if self._error.get(): + ttk.Label(frame, textvariable=self._error).grid(row=1) + user_inp = w.LabelInput( + frame, 'User name:', input_class=w.RequiredEntry, + var=self._user + ) + user_inp.grid() + w.LabelInput( + frame, 'Password:', input_class=w.RequiredEntry, + input_args={'show': '*'}, var=self._pw + ).grid() + return user_inp.input + + def buttonbox(self): + box = ttk.Frame(self) + ttk.Button( + box, text="Login", command=self.ok, default=tk.ACTIVE + ).grid(padx=5, pady=5) + ttk.Button( + box, text="Cancel", command=self.cancel + ).grid(row=0, column=1, padx=5, pady=5) + self.bind("", self.ok) + self.bind("", self.cancel) + box.pack() + + + def apply(self): + self.result = (self._user.get(), self._pw.get()) + + +class RecordList(tk.Frame): + """Display for CSV file contents""" + + column_defs = { + '#0': {'label': 'Row', 'anchor': tk.W}, + 'Date': {'label': 'Date', 'width': 150, 'stretch': True}, + 'Time': {'label': 'Time'}, + 'Lab': {'label': 'Lab', 'width': 40}, + 'Plot': {'label': 'Plot', 'width': 80} + } + default_width = 100 + default_minwidth = 10 + default_anchor = tk.CENTER + + def __init__(self, parent, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + self._inserted = list() + self._updated = list() + self.columnconfigure(0, weight=1) + self.rowconfigure(0, weight=1) + + # create treeview + self.treeview = ttk.Treeview( + self, + columns=list(self.column_defs.keys())[1:], + selectmode='browse' + ) + self.treeview.grid(row=0, column=0, sticky='NSEW') + + # Configure treeview columns + for name, definition in self.column_defs.items(): + label = definition.get('label', '') + anchor = definition.get('anchor', self.default_anchor) + minwidth = definition.get('minwidth', self.default_minwidth) + width = definition.get('width', self.default_width) + stretch = definition.get('stretch', False) + self.treeview.heading(name, text=label, anchor=anchor) + self.treeview.column( + name, anchor=anchor, minwidth=minwidth, + width=width, stretch=stretch + ) + + self.treeview.bind('', self._on_open_record) + self.treeview.bind('', self._on_open_record) + + # configure scrollbar for the treeview + self.scrollbar = ttk.Scrollbar( + self, + orient=tk.VERTICAL, + command=self.treeview.yview + ) + self.treeview.configure(yscrollcommand=self.scrollbar.set) + self.scrollbar.grid(row=0, column=1, sticky='NSW') + + # configure tagging + self.treeview.tag_configure('inserted', background='lightgreen') + self.treeview.tag_configure('updated', background='lightblue') + + # For ch12, hide first column since row # is no longer meaningful + self.treeview.config(show='headings') + + # new for ch12 + + @staticmethod + def _rowkey_to_iid(rowkey): + """Convert a rowkey tuple to an IID string""" + return '{}|{}|{}|{}'.format(*rowkey) + + @staticmethod + def _iid_to_rowkey(iid): + """Convert an IID string to a rowkey tuple""" + return tuple(iid.split("|")) + + # update for ch12 + def populate(self, rows): + """Clear the treeview and write the supplied data rows to it.""" + + for row in self.treeview.get_children(): + self.treeview.delete(row) + + cids = list(self.column_defs.keys())[1:] + for rowdata in rows: + values = [rowdata[key] for key in cids] + rowkey = tuple([str(v) for v in values]) + if rowkey in self._inserted: + tag = 'inserted' + elif rowkey in self._updated: + tag = 'updated' + else: + tag = '' + iid = self._rowkey_to_iid(rowkey) + self.treeview.insert( + '', 'end', iid=iid, text=iid, values=values, tag=tag) + + if len(rows) > 0: + firstrow = self.treeview.identify_row(0) + self.treeview.focus_set() + self.treeview.selection_set(firstrow) + self.treeview.focus(firstrow) + + # update for ch12 + def _on_open_record(self, *_): + """Handle record open request""" + self.event_generate('<>') + + @property + def selected_id(self): + selection = self.treeview.selection() + return self._iid_to_rowkey(selection[0]) if selection else None + + + def add_updated_row(self, row): + if row not in self._updated: + self._updated.append(row) + + def add_inserted_row(self, row): + if row not in self._inserted: + self._inserted.append(row) + + def clear_tags(self): + self._inserted.clear() + self._updated.clear() diff --git a/Chapter12/ABQ_Data_Entry/abq_data_entry/widgets.py b/Chapter12/ABQ_Data_Entry/abq_data_entry/widgets.py new file mode 100644 index 0000000..ec72ed4 --- /dev/null +++ b/Chapter12/ABQ_Data_Entry/abq_data_entry/widgets.py @@ -0,0 +1,441 @@ +import tkinter as tk +from tkinter import ttk +from datetime import datetime +from decimal import Decimal, InvalidOperation +from .constants import FieldTypes as FT + + +################## +# Widget Classes # +################## + +class ValidatedMixin: + """Adds a validation functionality to an input widget""" + + def __init__(self, *args, error_var=None, **kwargs): + self.error = error_var or tk.StringVar() + super().__init__(*args, **kwargs) + + vcmd = self.register(self._validate) + invcmd = self.register(self._invalid) + + style = ttk.Style() + widget_class = self.winfo_class() + validated_style = 'ValidatedInput.' + widget_class + style.map( + validated_style, + foreground=[('invalid', 'white'), ('!invalid', 'black')], + fieldbackground=[('invalid', 'darkred'), ('!invalid', 'white')] + ) + self.configure(style=validated_style) + + self.configure( + validate='all', + validatecommand=(vcmd, '%P', '%s', '%S', '%V', '%i', '%d'), + invalidcommand=(invcmd, '%P', '%s', '%S', '%V', '%i', '%d') + ) + + def _toggle_error(self, on=False): + self.configure(foreground=('red' if on else 'black')) + + def _validate(self, proposed, current, char, event, index, action): + """The validation method. + + Don't override this, override _key_validate, and _focus_validate + """ + self.error.set('') + + valid = True + # if the widget is disabled, don't validate + state = str(self.configure('state')[-1]) + if state == tk.DISABLED: + return valid + + if event == 'focusout': + valid = self._focusout_validate(event=event) + elif event == 'key': + valid = self._key_validate( + proposed=proposed, + current=current, + char=char, + event=event, + index=index, + action=action + ) + return valid + + def _focusout_validate(self, **kwargs): + return True + + def _key_validate(self, **kwargs): + return True + + def _invalid(self, proposed, current, char, event, index, action): + if event == 'focusout': + self._focusout_invalid(event=event) + elif event == 'key': + self._key_invalid( + proposed=proposed, + current=current, + char=char, + event=event, + index=index, + action=action + ) + + def _focusout_invalid(self, **kwargs): + """Handle invalid data on a focus event""" + pass + + def _key_invalid(self, **kwargs): + """Handle invalid data on a key event. By default we want to do nothing""" + pass + + def trigger_focusout_validation(self): + valid = self._validate('', '', '', 'focusout', '', '') + if not valid: + self._focusout_invalid(event='focusout') + return valid + + +class DateEntry(ValidatedMixin, ttk.Entry): + + def _key_validate(self, action, index, char, **kwargs): + valid = True + + if action == '0': # This is a delete action + valid = True + elif index in ('0', '1', '2', '3', '5', '6', '8', '9'): + valid = char.isdigit() + elif index in ('4', '7'): + valid = char == '-' + else: + valid = False + return valid + + def _focusout_validate(self, event): + valid = True + if not self.get(): + self.error.set('A value is required') + valid = False + try: + datetime.strptime(self.get(), '%Y-%m-%d') + except ValueError: + self.error.set('Invalid date') + valid = False + return valid + + +class RequiredEntry(ValidatedMixin, ttk.Entry): + + def _focusout_validate(self, event): + valid = True + if not self.get(): + valid = False + self.error.set('A value is required') + return valid + + +class ValidatedCombobox(ValidatedMixin, ttk.Combobox): + + def _key_validate(self, proposed, action, **kwargs): + valid = True + # if the user tries to delete, + # just clear the field + if action == '0': + self.set('') + return True + + # get our values list + values = self.cget('values') + # Do a case-insensitve match against the entered text + matching = [ + x for x in values + if x.lower().startswith(proposed.lower()) + ] + if len(matching) == 0: + valid = False + elif len(matching) == 1: + self.set(matching[0]) + self.icursor(tk.END) + valid = False + return valid + + def _focusout_validate(self, **kwargs): + valid = True + if not self.get(): + valid = False + self.error.set('A value is required') + return valid + + +class ValidatedSpinbox(ValidatedMixin, ttk.Spinbox): + """A Spinbox that only accepts Numbers""" + + def __init__(self, *args, min_var=None, max_var=None, + focus_update_var=None, from_='-Infinity', to='Infinity', **kwargs + ): + super().__init__(*args, from_=from_, to=to, **kwargs) + increment = Decimal(str(kwargs.get('increment', '1.0'))) + self.precision = increment.normalize().as_tuple().exponent + # there should always be a variable, + # or some of our code will fail + self.variable = kwargs.get('textvariable') + if not self.variable: + self.variable = tk.DoubleVar() + self.configure(textvariable=self.variable) + + if min_var: + self.min_var = min_var + self.min_var.trace_add('write', self._set_minimum) + if max_var: + self.max_var = max_var + self.max_var.trace_add('write', self._set_maximum) + self.focus_update_var = focus_update_var + self.bind('', self._set_focus_update_var) + + def _set_focus_update_var(self, event): + value = self.get() + if self.focus_update_var and not self.error.get(): + self.focus_update_var.set(value) + + def _set_minimum(self, *_): + current = self.get() + try: + new_min = self.min_var.get() + self.config(from_=new_min) + except (tk.TclError, ValueError): + pass + if not current: + self.delete(0, tk.END) + else: + self.variable.set(current) + self.trigger_focusout_validation() + + def _set_maximum(self, *_): + current = self.get() + try: + new_max = self.max_var.get() + self.config(to=new_max) + except (tk.TclError, ValueError): + pass + if not current: + self.delete(0, tk.END) + else: + self.variable.set(current) + self.trigger_focusout_validation() + + def _key_validate( + self, char, index, current, proposed, action, **kwargs + ): + if action == '0': + return True + valid = True + min_val = self.cget('from') + max_val = self.cget('to') + no_negative = min_val >= 0 + no_decimal = self.precision >= 0 + + # First, filter out obviously invalid keystrokes + if any([ + (char not in '-1234567890.'), + (char == '-' and (no_negative or index != '0')), + (char == '.' and (no_decimal or '.' in current)) + ]): + return False + + # At this point, proposed is either '-', '.', '-.', + # or a valid Decimal string + if proposed in '-.': + return True + + # Proposed is a valid Decimal string + # convert to Decimal and check more: + proposed = Decimal(proposed) + proposed_precision = proposed.as_tuple().exponent + + if any([ + (proposed > max_val), + (proposed_precision < self.precision) + ]): + return False + + return valid + + def _focusout_validate(self, **kwargs): + valid = True + value = self.get() + min_val = self.cget('from') + max_val = self.cget('to') + + try: + d_value = Decimal(value) + except InvalidOperation: + self.error.set(f'Invalid number string: {value}') + return False + + if d_value < min_val: + self.error.set(f'Value is too low (min {min_val})') + valid = False + if d_value > max_val: + self.error.set(f'Value is too high (max {max_val})') + valid = False + + return valid + +class ValidatedRadioGroup(ttk.Frame): + """A validated radio button group""" + + def __init__( + self, *args, variable=None, error_var=None, + values=None, button_args=None, **kwargs + ): + super().__init__(*args, **kwargs) + self.variable = variable or tk.StringVar() + self.error = error_var or tk.StringVar() + self.values = values or list() + button_args = button_args or dict() + + for v in self.values: + button = ttk.Radiobutton( + self, value=v, text=v, variable=self.variable, **button_args + ) + button.pack(side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x') + self.bind('', self.trigger_focusout_validation) + + def trigger_focusout_validation(self, *_): + self.error.set('') + if not self.variable.get(): + self.error.set('A value is required') + + +class BoundText(tk.Text): + """A Text widget with a bound variable.""" + + def __init__(self, *args, textvariable=None, **kwargs): + super().__init__(*args, **kwargs) + self._variable = textvariable + if self._variable: + # insert any default value + self.insert('1.0', self._variable.get()) + self._variable.trace_add('write', self._set_content) + self.bind('<>', self._set_var) + + def _set_var(self, *_): + """Set the variable to the text contents""" + if self.edit_modified(): + content = self.get('1.0', 'end-1chars') + self._variable.set(content) + self.edit_modified(False) + + def _set_content(self, *_): + """Set the text contents to the variable""" + self.delete('1.0', tk.END) + self.insert('1.0', self._variable.get()) + + +########################### +# Compound Widget Classes # +########################### + + +class LabelInput(ttk.Frame): + """A widget containing a label and input together.""" + + field_types = { + FT.string: RequiredEntry, + FT.string_list: ValidatedCombobox, + FT.short_string_list: ValidatedRadioGroup, + FT.iso_date_string: DateEntry, + FT.long_string: BoundText, + FT.decimal: ValidatedSpinbox, + FT.integer: ValidatedSpinbox, + FT.boolean: ttk.Checkbutton + } + + def __init__( + self, parent, label, var, input_class=None, + input_args=None, label_args=None, field_spec=None, + disable_var=None, **kwargs + ): + super().__init__(parent, **kwargs) + input_args = input_args or {} + label_args = label_args or {} + self.variable = var + self.variable.label_widget = self + + # Process the field spec to determine input_class and validation + if field_spec: + field_type = field_spec.get('type', FT.string) + input_class = input_class or self.field_types.get(field_type) + # min, max, increment + if 'min' in field_spec and 'from_' not in input_args: + input_args['from_'] = field_spec.get('min') + if 'max' in field_spec and 'to' not in input_args: + input_args['to'] = field_spec.get('max') + if 'inc' in field_spec and 'increment' not in input_args: + input_args['increment'] = field_spec.get('inc') + # values + if 'values' in field_spec and 'values' not in input_args: + input_args['values'] = field_spec.get('values') + + # setup the label + if input_class in (ttk.Checkbutton, ttk.Button): + # Buttons don't need labels, they're built-in + input_args["text"] = label + else: + self.label = ttk.Label(self, text=label, **label_args) + self.label.grid(row=0, column=0, sticky=(tk.W + tk.E)) + + # setup the variable + if input_class in ( + ttk.Checkbutton, ttk.Button, ttk.Radiobutton, ValidatedRadioGroup + ): + input_args["variable"] = self.variable + else: + input_args["textvariable"] = self.variable + + # Setup the input + if input_class == ttk.Radiobutton: + # for Radiobutton, create one input per value + self.input = tk.Frame(self) + for v in input_args.pop('values', []): + button = input_class( + self.input, value=v, text=v, **input_args) + button.pack(side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x') + self.input.error = getattr(button, 'error', None) + self.input.trigger_focusout_validation = \ + button._focusout_validate + else: + self.input = input_class(self, **input_args) + + self.input.grid(row=1, column=0, sticky=(tk.W + tk.E)) + self.columnconfigure(0, weight=1) + + # Set up error handling & display + error_style = 'Error.' + label_args.get('style', 'TLabel') + ttk.Style().configure(error_style, foreground='darkred') + self.error = getattr(self.input, 'error', tk.StringVar()) + ttk.Label(self, textvariable=self.error, style=error_style).grid( + row=2, column=0, sticky=(tk.W + tk.E) + ) + + # Set up disable variable + if disable_var: + self.disable_var = disable_var + self.disable_var.trace_add('write', self._check_disable) + + def _check_disable(self, *_): + if not hasattr(self, 'disable_var'): + return + + if self.disable_var.get(): + self.input.configure(state=tk.DISABLED) + self.variable.set('') + self.error.set('') + else: + self.input.configure(state=tk.NORMAL) + + def grid(self, sticky=(tk.E + tk.W), **kwargs): + """Override grid to add default sticky values""" + super().grid(sticky=sticky, **kwargs) diff --git a/Chapter12/ABQ_Data_Entry/docs/Application_layout.png b/Chapter12/ABQ_Data_Entry/docs/Application_layout.png new file mode 100644 index 0000000000000000000000000000000000000000..93990f232d19518ca465e7715bbf4f0f10cabce9 GIT binary patch literal 9117 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 mufwd~J*Rr~aor9m$$ITZW{ 9 # 10% chance of failure + humidity = (random.random() * 4 + 21) if not e_fault else None + light = (random.random() * .1 + .95) if not e_fault else None + temperature = ((light ** 3) * 8 + 21) if light and not e_fault else None + + notes = random.choice([ + 'Check Hydration system', 'Dry leaves', 'Roots exposed', + 'Check delayed', 'Skylight obscured' + ]) if random.randint(1, 10) > 9 else '' + pc_data = { + 'time': time, + 'lab_id': lab, + 'plot': plot, + 'equipment_fault': e_fault, + 'light': light, + 'humidity': humidity, + 'temperature': temperature, + 'plants': plants, + 'blossoms': blossoms, + 'fruit': fruit, + 'max_height': max_height, + 'min_height': min_height, + 'median_height': med_height, + 'notes': notes + } + cursor.execute(plot_check_insert, pc_data) +cx.commit() diff --git a/Chapter12/psycopg2_demo.py b/Chapter12/psycopg2_demo.py new file mode 100644 index 0000000..7f7d01b --- /dev/null +++ b/Chapter12/psycopg2_demo.py @@ -0,0 +1,67 @@ +import psycopg2 as pg +from psycopg2.extras import DictCursor +from getpass import getpass + +############## +# Connecting # +############## + +cx = pg.connect( + host='localhost', database='abq', + user=input('Username: '), + password=getpass('Password: '), + cursor_factory=DictCursor +) + +cur = cx.cursor() + +##################### +# Executing Queries # +##################### + +cur.execute(""" + CREATE TABLE test + (id SERIAL PRIMARY KEY, val TEXT) +""") +cur.execute(""" + INSERT INTO test (val) + VALUES ('Banana'), ('Orange'), ('Apple'); +""") + +################### +# Retrieving Data # +################### + +cur.execute("SELECT * FROM test") +num_rows = cur.rowcount +data = cur.fetchall() + +print(f'Got {num_rows} rows from database:') +#print(data) +for row in data: + # DictCursor rows can use string indexes + print(row['val']) + +######################### +# Parameterized Queries # +######################### + +new_item = input('Enter new item: ') + +#Never do this: +#cur.execute(f"INSERT INTO test (val) VALUES ('{new_item}')") +cur.execute("INSERT INTO test (val) VALUES (%s)", (new_item,)) +# or: +# cur.execute("INSERT INTO test (val) VALUES (%(item)s)", {'item': new_item}) +cur.execute('SELECT * FROM test') +print(cur.fetchall()) + +############### +# Cleaning Up # +############### + +# Call this to actually save the data before leaving +# cx.commit() + +# This is usually not necessary, but you can do it if you wish. +#cx.close() From 5e947c6c2a0b8554a7b58c7ec06bc2c54ef605d5 Mon Sep 17 00:00:00 2001 From: Alan Moore Date: Thu, 12 Aug 2021 19:08:00 -0500 Subject: [PATCH 31/32] added ch13 code --- Chapter13/ABQ_Data_Entry/.gitignore | 2 + Chapter13/ABQ_Data_Entry/README.rst | 43 ++ Chapter13/ABQ_Data_Entry/abq_data_entry.py | 4 + .../abq_data_entry/#application.py# | 377 ++++++++++++ .../abq_data_entry/.#application.py | 1 + .../ABQ_Data_Entry/abq_data_entry/__init__.py | 0 .../abq_data_entry/application.py | 377 ++++++++++++ .../abq_data_entry/constants.py | 12 + .../abq_data_entry/images/__init__.py | 20 + .../abq_data_entry/images/abq_logo-16x10.png | Bin 0 -> 1346 bytes .../abq_data_entry/images/abq_logo-32x20.png | Bin 0 -> 2637 bytes .../abq_data_entry/images/abq_logo-64x40.png | Bin 0 -> 5421 bytes .../abq_data_entry/images/browser-2x.png | Bin 0 -> 174 bytes .../abq_data_entry/images/file-2x.png | Bin 0 -> 167 bytes .../abq_data_entry/images/list-2x.png | Bin 0 -> 160 bytes .../images/question-mark-2x.xbm | 6 + .../abq_data_entry/images/reload-2x.png | Bin 0 -> 336 bytes .../abq_data_entry/images/x-2x.xbm | 6 + .../ABQ_Data_Entry/abq_data_entry/mainmenu.py | 408 +++++++++++++ .../ABQ_Data_Entry/abq_data_entry/models.py | 374 ++++++++++++ .../ABQ_Data_Entry/abq_data_entry/network.py | 82 +++ .../abq_data_entry/test/__init__.py | 0 .../abq_data_entry/test/test_application.py | 72 +++ .../abq_data_entry/test/test_models.py | 137 +++++ .../abq_data_entry/test/test_widgets.py | 219 +++++++ .../ABQ_Data_Entry/abq_data_entry/views.py | 556 ++++++++++++++++++ .../ABQ_Data_Entry/abq_data_entry/widgets.py | 441 ++++++++++++++ .../docs/Application_layout.png | Bin 0 -> 9117 bytes .../docs/abq_data_entry_spec.rst | 97 +++ .../docs/lab-tech-paper-form.png | Bin 0 -> 22018 bytes Chapter13/ABQ_Data_Entry/sql/create_db.sql | 82 +++ Chapter13/ABQ_Data_Entry/sql/populate_db.sql | 20 + Chapter13/basic_ftp_server.py | 14 + Chapter13/ftp_retr_demo.py | 11 + Chapter13/requests_example.py | 41 ++ Chapter13/sample_http_server.py | 33 ++ Chapter13/urllib_examples.py | 21 + 37 files changed, 3456 insertions(+) create mode 100644 Chapter13/ABQ_Data_Entry/.gitignore create mode 100644 Chapter13/ABQ_Data_Entry/README.rst create mode 100644 Chapter13/ABQ_Data_Entry/abq_data_entry.py create mode 100644 Chapter13/ABQ_Data_Entry/abq_data_entry/#application.py# create mode 120000 Chapter13/ABQ_Data_Entry/abq_data_entry/.#application.py create mode 100644 Chapter13/ABQ_Data_Entry/abq_data_entry/__init__.py create mode 100644 Chapter13/ABQ_Data_Entry/abq_data_entry/application.py create mode 100644 Chapter13/ABQ_Data_Entry/abq_data_entry/constants.py create mode 100644 Chapter13/ABQ_Data_Entry/abq_data_entry/images/__init__.py create mode 100644 Chapter13/ABQ_Data_Entry/abq_data_entry/images/abq_logo-16x10.png create mode 100644 Chapter13/ABQ_Data_Entry/abq_data_entry/images/abq_logo-32x20.png create mode 100644 Chapter13/ABQ_Data_Entry/abq_data_entry/images/abq_logo-64x40.png create mode 100644 Chapter13/ABQ_Data_Entry/abq_data_entry/images/browser-2x.png create mode 100644 Chapter13/ABQ_Data_Entry/abq_data_entry/images/file-2x.png create mode 100644 Chapter13/ABQ_Data_Entry/abq_data_entry/images/list-2x.png create mode 100644 Chapter13/ABQ_Data_Entry/abq_data_entry/images/question-mark-2x.xbm create mode 100644 Chapter13/ABQ_Data_Entry/abq_data_entry/images/reload-2x.png create mode 100644 Chapter13/ABQ_Data_Entry/abq_data_entry/images/x-2x.xbm create mode 100644 Chapter13/ABQ_Data_Entry/abq_data_entry/mainmenu.py create mode 100644 Chapter13/ABQ_Data_Entry/abq_data_entry/models.py create mode 100644 Chapter13/ABQ_Data_Entry/abq_data_entry/network.py create mode 100644 Chapter13/ABQ_Data_Entry/abq_data_entry/test/__init__.py create mode 100644 Chapter13/ABQ_Data_Entry/abq_data_entry/test/test_application.py create mode 100644 Chapter13/ABQ_Data_Entry/abq_data_entry/test/test_models.py create mode 100644 Chapter13/ABQ_Data_Entry/abq_data_entry/test/test_widgets.py create mode 100644 Chapter13/ABQ_Data_Entry/abq_data_entry/views.py create mode 100644 Chapter13/ABQ_Data_Entry/abq_data_entry/widgets.py create mode 100644 Chapter13/ABQ_Data_Entry/docs/Application_layout.png create mode 100644 Chapter13/ABQ_Data_Entry/docs/abq_data_entry_spec.rst create mode 100644 Chapter13/ABQ_Data_Entry/docs/lab-tech-paper-form.png create mode 100644 Chapter13/ABQ_Data_Entry/sql/create_db.sql create mode 100644 Chapter13/ABQ_Data_Entry/sql/populate_db.sql create mode 100644 Chapter13/basic_ftp_server.py create mode 100644 Chapter13/ftp_retr_demo.py create mode 100644 Chapter13/requests_example.py create mode 100644 Chapter13/sample_http_server.py create mode 100644 Chapter13/urllib_examples.py diff --git a/Chapter13/ABQ_Data_Entry/.gitignore b/Chapter13/ABQ_Data_Entry/.gitignore new file mode 100644 index 0000000..d646835 --- /dev/null +++ b/Chapter13/ABQ_Data_Entry/.gitignore @@ -0,0 +1,2 @@ +*.pyc +__pycache__/ diff --git a/Chapter13/ABQ_Data_Entry/README.rst b/Chapter13/ABQ_Data_Entry/README.rst new file mode 100644 index 0000000..5a47dd7 --- /dev/null +++ b/Chapter13/ABQ_Data_Entry/README.rst @@ -0,0 +1,43 @@ +============================ + ABQ Data Entry Application +============================ + +Description +=========== + +This program provides a data entry form for ABQ Agrilabs laboratory data. + +Features +-------- + + * Provides a validated entry form to ensure correct data + * Stores data to ABQ-format CSV files + * Auto-fills form fields whenever possible + +Authors +======= + +Alan D Moore, 2021 + +Requirements +============ + + * Python 3.7 or higher + * Tkinter + +Usage +===== + +To start the application, run:: + + python3 ABQ_Data_Entry/abq_data_entry.py + + +General Notes +============= + +The CSV file will be saved to your current directory in the format +``abq_data_record_CURRENTDATE.csv``, where CURRENTDATE is today's date in ISO format. + +This program only appends to the CSV file. You should have a spreadsheet program +installed in case you need to edit or check the file. diff --git a/Chapter13/ABQ_Data_Entry/abq_data_entry.py b/Chapter13/ABQ_Data_Entry/abq_data_entry.py new file mode 100644 index 0000000..a3b3a0d --- /dev/null +++ b/Chapter13/ABQ_Data_Entry/abq_data_entry.py @@ -0,0 +1,4 @@ +from abq_data_entry.application import Application + +app = Application() +app.mainloop() diff --git a/Chapter13/ABQ_Data_Entry/abq_data_entry/#application.py# b/Chapter13/ABQ_Data_Entry/abq_data_entry/#application.py# new file mode 100644 index 0000000..9c8836d --- /dev/null +++ b/Chapter13/ABQ_Data_Entry/abq_data_entry/#application.py# @@ -0,0 +1,377 @@ +"""The application/controller class for ABQ Data Entry""" + +import tkinter as tk +from tkinter import ttk +from tkinter import messagebox +from tkinter import filedialog +from tkinter import font +import platform + +from . import views as v +from . import models as m +from .mainmenu import get_main_menu_for_os +from . import images +from . import network as n + + +class Application(tk.Tk): + """Application root window""" + + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # move here for ch12 because we need some settings data to authenticate + self.settings_model = m.SettingsModel() + self._load_settings() + + # Hide window while GUI is built + self.withdraw() + + # Authenticate + if not self._show_login(): + self.destroy() + return + + # show the window + self.deiconify() + + # Create model + # remove for ch12 + # self.model = m.CSVModel() + + self.inserted_rows = [] + self.updated_rows = [] + + # Begin building GUI + self.title("ABQ Data Entry Application") + self.columnconfigure(0, weight=1) + + # Set taskbar icon + self.taskbar_icon = tk.PhotoImage(file=images.ABQ_LOGO_64) + self.call('wm', 'iconphoto', self._w, self.taskbar_icon) + + # Create the menu + #menu = MainMenu(self, self.settings) + menu_class = get_main_menu_for_os(platform.system()) + menu = menu_class(self, self.settings) + + self.config(menu=menu) + event_callbacks = { + '<>': lambda _: self.quit(), + '<>': self._show_recordlist, + '<>': self._new_record, + '<>': self._update_weather_data, + '<>': self._upload_to_corporate_rest, + '<>': self._upload_to_corporate_ftp, + } + for sequence, callback in event_callbacks.items(): + self.bind(sequence, callback) + + # new for ch9 + self.logo = tk.PhotoImage(file=images.ABQ_LOGO_32) + ttk.Label( + self, + text="ABQ Data Entry Application", + font=("TkDefaultFont", 16), + image=self.logo, + compound=tk.LEFT + ).grid(row=0) + + # The notebook + self.notebook = ttk.Notebook(self) + self.notebook.enable_traversal() + self.notebook.grid(row=1, padx=10, sticky='NSEW') + + # The data record form + self.recordform_icon = tk.PhotoImage(file=images.FORM_ICON) + self.recordform = v.DataRecordForm(self, self.model, self.settings) + self.notebook.add( + self.recordform, text='Entry Form', + image=self.recordform_icon, compound=tk.LEFT + ) + self.recordform.bind('<>', self._on_save) + + + # The data record list + self.recordlist_icon = tk.PhotoImage(file=images.LIST_ICON) + self.recordlist = v.RecordList( + self, self.inserted_rows, self.updated_rows + ) + self.notebook.insert( + 0, self.recordlist, text='Records', + image=self.recordlist_icon, compound=tk.LEFT + ) + self._populate_recordlist() + self.recordlist.bind('<>', self._open_record) + + + self._show_recordlist() + + # status bar + self.status = tk.StringVar() + self.statusbar = ttk.Label(self, textvariable=self.status) + self.statusbar.grid(sticky=(tk.W + tk.E), row=3, padx=10) + + + self.records_saved = 0 + + + def _on_save(self, *_): + """Handles file-save requests""" + + # Check for errors first + + errors = self.recordform.get_errors() + if errors: + self.status.set( + "Cannot save, error in fields: {}" + .format(', '.join(errors.keys())) + ) + message = "Cannot save record" + detail = "The following fields have errors: \n * {}".format( + '\n * '.join(errors.keys()) + ) + messagebox.showerror( + title='Error', + message=message, + detail=detail + ) + return False + + data = self.recordform.get() + rowkey = self.recordform.current_record + self.model.save_record(data, rowkey) + if rowkey is not None: + self.updated_rows.append(rowkey) + else: + rowkey = (data['Date'], data['Time'], data['Lab'], data['Plot']) + self.inserted_rows.append(rowkey) + self.records_saved += 1 + self.status.set( + "{} records saved this session".format(self.records_saved) + ) + self.recordform.reset() + self._populate_recordlist() + +# Remove for ch12 +# def _on_file_select(self, *_): +# """Handle the file->select action""" +# +# filename = filedialog.asksaveasfilename( +# title='Select the target file for saving records', +# defaultextension='.csv', +# filetypes=[('CSV', '*.csv *.CSV')] +# ) +# if filename: +# self.model = m.CSVModel(filename=filename) +# self.inserted_rows.clear() +# self.updated_rows.clear() +# self._populate_recordlist() + + @staticmethod + def _simple_login(username, password): + """A basic authentication backend with a hardcoded user and password""" + return username == 'abq' and password == 'Flowers' + + # new ch12 + def _database_login(self, username, password): + """Try to login to the database and create self.data_model""" + db_host = self.settings['db_host'].get() + db_name = self.settings['db_name'].get() + try: + self.model = m.SQLModel( + db_host, db_name, username, password) + except m.pg.OperationalError as e: + print(e) + return False + return True + + def _show_login(self): + """Show login dialog and attempt to login""" + error = '' + title = "Login to ABQ Data Entry" + while True: + login = v.LoginDialog(self, title, error) + if not login.result: # User canceled + return False + username, password = login.result + if self._database_login(username, password): + return True + error = 'Login Failed' # loop and redisplay + + def _load_settings(self): + """Load settings into our self.settings dict.""" + + vartypes = { + 'bool': tk.BooleanVar, + 'str': tk.StringVar, + 'int': tk.IntVar, + 'float': tk.DoubleVar + } + + # create our dict of settings variables from the model's settings. + self.settings = dict() + for key, data in self.settings_model.fields.items(): + vartype = vartypes.get(data['type'], tk.StringVar) + self.settings[key] = vartype(value=data['value']) + + # put a trace on the variables so they get stored when changed. + for var in self.settings.values(): + var.trace_add('write', self._save_settings) + + # update font settings after loading them + self._set_font() + self.settings['font size'].trace_add('write', self._set_font) + self.settings['font family'].trace_add('write', self._set_font) + + # process theme + style = ttk.Style() + theme = self.settings.get('theme').get() + if theme in style.theme_names(): + style.theme_use(theme) + + def _save_settings(self, *_): + """Save the current settings to a preferences file""" + + for key, variable in self.settings.items(): + self.settings_model.set(key, variable.get()) + self.settings_model.save() + + def _show_recordlist(self, *_): + """Show the recordform""" + self.notebook.select(self.recordlist) + + def _populate_recordlist(self): + try: + rows = self.model.get_all_records() + except Exception as e: + messagebox.showerror( + title='Error', + message='Problem reading file', + detail=str(e) + ) + else: + self.recordlist.populate(rows) + + def _new_record(self, *_): + """Open the record form with a blank record""" + self.recordform.load_record(None, None) + self.notebook.select(self.recordform) + + + def _open_record(self, *_): + """Open the Record selected recordlist id in the recordform""" + rowkey = self.recordlist.selected_id + try: + record = self.model.get_record(rowkey) + except Exception as e: + messagebox.showerror( + title='Error', message='Problem reading file', detail=str(e) + ) + return + self.recordform.load_record(rowkey, record) + self.notebook.select(self.recordform) + + # new chapter 9 + def _set_font(self, *_): + """Set the application's font""" + font_size = self.settings['font size'].get() + font_family = self.settings['font family'].get() + font_names = ('TkDefaultFont', 'TkMenuFont', 'TkTextFont') + for font_name in font_names: + tk_font = font.nametofont(font_name) + tk_font.config(size=font_size, family=font_family) + + # new chapter 13 + def _update_weather_data(self, *_): + """Initiate retrieval and storage of weather data""" + try: + weather_data = n.get_local_weather( + self.settings['weather_station'].get() + ) + except Exception as e: + messagebox.showerror( + title='Error', + message='Problem retrieving weather data', + detail=str(e) + ) + self.status.set('Problem retrieving weather data') + else: + self.model.add_weather_data(weather_data) + time = weather_data['observation_time_rfc822'] + self.status.set(f"Weather data recorded for {time}") + + def _create_csv_extract(self): + csvmodel = m.CSVModel() + records = self.model.get_all_records() + if not records: + return None + for record in records: + csvmodel.save_record(record) + return csvmodel.file + + def _upload_to_corporate_ftp(self, *_): + + csvfile = self._create_csv_extract() + d = v.LoginDialog(self, 'Login to ABQ Corporate FTP') + if d.result is None: + return + username, password = d.result + host = self.settings['abq_ftp_host'].get() + port = self.settings['abq_ftp_port'].get() + try: + n.upload_to_corporate_ftp( + csvfile, host, port, username, password + ) + except n.ftp.all_errors as e: + messagebox.showerror('Error Uploading File.', str(e)) + return + try: + files = n.get_corporate_ftp_files( + host, port, username, password + ) + except n.ftp.all_errors as e: + messagebox.showerror( + 'Error listing Files (file uploaded OK)', + str(e) + ) + return + filestring = '\n'.join(f'* {f}' for f in files) + messagebox.showinfo( + 'Success', f'{csvfile} successfully uploaded to FTP \n\n' + f'Current files on the server are: \n\n {filestring}' + ) + + def _upload_to_corporate_rest(self, *_): + csvfile = self._create_csv_extract() + if csvfile is None: + messagebox.showwarning( + title='No records', + message='There are no records to upload' + ) + return + d = v.LoginDialog( + self, 'Login to ABQ Corporate REST API' + ) + if d.result is not None: + username, password = d.result + else: + return + try: + n.upload_to_corporate_rest( + csvfile, + self.settings['abq_upload_url'].get(), + self.settings['abq_auth_url'].get(), + username, + password + ) + except n.requests.ConnectionError as e: + messagebox.showerror('Error connecting', str(e)) + except Exception as e: + messagebox.showerror('General Exception', str(e)) + else: + messagebox.showinfo( + 'Success', + f'{csvfile} successfully uploaded to REST API.' + ) diff --git a/Chapter13/ABQ_Data_Entry/abq_data_entry/.#application.py b/Chapter13/ABQ_Data_Entry/abq_data_entry/.#application.py new file mode 120000 index 0000000..0470db7 --- /dev/null +++ b/Chapter13/ABQ_Data_Entry/abq_data_entry/.#application.py @@ -0,0 +1 @@ +alanm@it-alanmlap.839:1628650470 \ No newline at end of file diff --git a/Chapter13/ABQ_Data_Entry/abq_data_entry/__init__.py b/Chapter13/ABQ_Data_Entry/abq_data_entry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Chapter13/ABQ_Data_Entry/abq_data_entry/application.py b/Chapter13/ABQ_Data_Entry/abq_data_entry/application.py new file mode 100644 index 0000000..9c8836d --- /dev/null +++ b/Chapter13/ABQ_Data_Entry/abq_data_entry/application.py @@ -0,0 +1,377 @@ +"""The application/controller class for ABQ Data Entry""" + +import tkinter as tk +from tkinter import ttk +from tkinter import messagebox +from tkinter import filedialog +from tkinter import font +import platform + +from . import views as v +from . import models as m +from .mainmenu import get_main_menu_for_os +from . import images +from . import network as n + + +class Application(tk.Tk): + """Application root window""" + + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # move here for ch12 because we need some settings data to authenticate + self.settings_model = m.SettingsModel() + self._load_settings() + + # Hide window while GUI is built + self.withdraw() + + # Authenticate + if not self._show_login(): + self.destroy() + return + + # show the window + self.deiconify() + + # Create model + # remove for ch12 + # self.model = m.CSVModel() + + self.inserted_rows = [] + self.updated_rows = [] + + # Begin building GUI + self.title("ABQ Data Entry Application") + self.columnconfigure(0, weight=1) + + # Set taskbar icon + self.taskbar_icon = tk.PhotoImage(file=images.ABQ_LOGO_64) + self.call('wm', 'iconphoto', self._w, self.taskbar_icon) + + # Create the menu + #menu = MainMenu(self, self.settings) + menu_class = get_main_menu_for_os(platform.system()) + menu = menu_class(self, self.settings) + + self.config(menu=menu) + event_callbacks = { + '<>': lambda _: self.quit(), + '<>': self._show_recordlist, + '<>': self._new_record, + '<>': self._update_weather_data, + '<>': self._upload_to_corporate_rest, + '<>': self._upload_to_corporate_ftp, + } + for sequence, callback in event_callbacks.items(): + self.bind(sequence, callback) + + # new for ch9 + self.logo = tk.PhotoImage(file=images.ABQ_LOGO_32) + ttk.Label( + self, + text="ABQ Data Entry Application", + font=("TkDefaultFont", 16), + image=self.logo, + compound=tk.LEFT + ).grid(row=0) + + # The notebook + self.notebook = ttk.Notebook(self) + self.notebook.enable_traversal() + self.notebook.grid(row=1, padx=10, sticky='NSEW') + + # The data record form + self.recordform_icon = tk.PhotoImage(file=images.FORM_ICON) + self.recordform = v.DataRecordForm(self, self.model, self.settings) + self.notebook.add( + self.recordform, text='Entry Form', + image=self.recordform_icon, compound=tk.LEFT + ) + self.recordform.bind('<>', self._on_save) + + + # The data record list + self.recordlist_icon = tk.PhotoImage(file=images.LIST_ICON) + self.recordlist = v.RecordList( + self, self.inserted_rows, self.updated_rows + ) + self.notebook.insert( + 0, self.recordlist, text='Records', + image=self.recordlist_icon, compound=tk.LEFT + ) + self._populate_recordlist() + self.recordlist.bind('<>', self._open_record) + + + self._show_recordlist() + + # status bar + self.status = tk.StringVar() + self.statusbar = ttk.Label(self, textvariable=self.status) + self.statusbar.grid(sticky=(tk.W + tk.E), row=3, padx=10) + + + self.records_saved = 0 + + + def _on_save(self, *_): + """Handles file-save requests""" + + # Check for errors first + + errors = self.recordform.get_errors() + if errors: + self.status.set( + "Cannot save, error in fields: {}" + .format(', '.join(errors.keys())) + ) + message = "Cannot save record" + detail = "The following fields have errors: \n * {}".format( + '\n * '.join(errors.keys()) + ) + messagebox.showerror( + title='Error', + message=message, + detail=detail + ) + return False + + data = self.recordform.get() + rowkey = self.recordform.current_record + self.model.save_record(data, rowkey) + if rowkey is not None: + self.updated_rows.append(rowkey) + else: + rowkey = (data['Date'], data['Time'], data['Lab'], data['Plot']) + self.inserted_rows.append(rowkey) + self.records_saved += 1 + self.status.set( + "{} records saved this session".format(self.records_saved) + ) + self.recordform.reset() + self._populate_recordlist() + +# Remove for ch12 +# def _on_file_select(self, *_): +# """Handle the file->select action""" +# +# filename = filedialog.asksaveasfilename( +# title='Select the target file for saving records', +# defaultextension='.csv', +# filetypes=[('CSV', '*.csv *.CSV')] +# ) +# if filename: +# self.model = m.CSVModel(filename=filename) +# self.inserted_rows.clear() +# self.updated_rows.clear() +# self._populate_recordlist() + + @staticmethod + def _simple_login(username, password): + """A basic authentication backend with a hardcoded user and password""" + return username == 'abq' and password == 'Flowers' + + # new ch12 + def _database_login(self, username, password): + """Try to login to the database and create self.data_model""" + db_host = self.settings['db_host'].get() + db_name = self.settings['db_name'].get() + try: + self.model = m.SQLModel( + db_host, db_name, username, password) + except m.pg.OperationalError as e: + print(e) + return False + return True + + def _show_login(self): + """Show login dialog and attempt to login""" + error = '' + title = "Login to ABQ Data Entry" + while True: + login = v.LoginDialog(self, title, error) + if not login.result: # User canceled + return False + username, password = login.result + if self._database_login(username, password): + return True + error = 'Login Failed' # loop and redisplay + + def _load_settings(self): + """Load settings into our self.settings dict.""" + + vartypes = { + 'bool': tk.BooleanVar, + 'str': tk.StringVar, + 'int': tk.IntVar, + 'float': tk.DoubleVar + } + + # create our dict of settings variables from the model's settings. + self.settings = dict() + for key, data in self.settings_model.fields.items(): + vartype = vartypes.get(data['type'], tk.StringVar) + self.settings[key] = vartype(value=data['value']) + + # put a trace on the variables so they get stored when changed. + for var in self.settings.values(): + var.trace_add('write', self._save_settings) + + # update font settings after loading them + self._set_font() + self.settings['font size'].trace_add('write', self._set_font) + self.settings['font family'].trace_add('write', self._set_font) + + # process theme + style = ttk.Style() + theme = self.settings.get('theme').get() + if theme in style.theme_names(): + style.theme_use(theme) + + def _save_settings(self, *_): + """Save the current settings to a preferences file""" + + for key, variable in self.settings.items(): + self.settings_model.set(key, variable.get()) + self.settings_model.save() + + def _show_recordlist(self, *_): + """Show the recordform""" + self.notebook.select(self.recordlist) + + def _populate_recordlist(self): + try: + rows = self.model.get_all_records() + except Exception as e: + messagebox.showerror( + title='Error', + message='Problem reading file', + detail=str(e) + ) + else: + self.recordlist.populate(rows) + + def _new_record(self, *_): + """Open the record form with a blank record""" + self.recordform.load_record(None, None) + self.notebook.select(self.recordform) + + + def _open_record(self, *_): + """Open the Record selected recordlist id in the recordform""" + rowkey = self.recordlist.selected_id + try: + record = self.model.get_record(rowkey) + except Exception as e: + messagebox.showerror( + title='Error', message='Problem reading file', detail=str(e) + ) + return + self.recordform.load_record(rowkey, record) + self.notebook.select(self.recordform) + + # new chapter 9 + def _set_font(self, *_): + """Set the application's font""" + font_size = self.settings['font size'].get() + font_family = self.settings['font family'].get() + font_names = ('TkDefaultFont', 'TkMenuFont', 'TkTextFont') + for font_name in font_names: + tk_font = font.nametofont(font_name) + tk_font.config(size=font_size, family=font_family) + + # new chapter 13 + def _update_weather_data(self, *_): + """Initiate retrieval and storage of weather data""" + try: + weather_data = n.get_local_weather( + self.settings['weather_station'].get() + ) + except Exception as e: + messagebox.showerror( + title='Error', + message='Problem retrieving weather data', + detail=str(e) + ) + self.status.set('Problem retrieving weather data') + else: + self.model.add_weather_data(weather_data) + time = weather_data['observation_time_rfc822'] + self.status.set(f"Weather data recorded for {time}") + + def _create_csv_extract(self): + csvmodel = m.CSVModel() + records = self.model.get_all_records() + if not records: + return None + for record in records: + csvmodel.save_record(record) + return csvmodel.file + + def _upload_to_corporate_ftp(self, *_): + + csvfile = self._create_csv_extract() + d = v.LoginDialog(self, 'Login to ABQ Corporate FTP') + if d.result is None: + return + username, password = d.result + host = self.settings['abq_ftp_host'].get() + port = self.settings['abq_ftp_port'].get() + try: + n.upload_to_corporate_ftp( + csvfile, host, port, username, password + ) + except n.ftp.all_errors as e: + messagebox.showerror('Error Uploading File.', str(e)) + return + try: + files = n.get_corporate_ftp_files( + host, port, username, password + ) + except n.ftp.all_errors as e: + messagebox.showerror( + 'Error listing Files (file uploaded OK)', + str(e) + ) + return + filestring = '\n'.join(f'* {f}' for f in files) + messagebox.showinfo( + 'Success', f'{csvfile} successfully uploaded to FTP \n\n' + f'Current files on the server are: \n\n {filestring}' + ) + + def _upload_to_corporate_rest(self, *_): + csvfile = self._create_csv_extract() + if csvfile is None: + messagebox.showwarning( + title='No records', + message='There are no records to upload' + ) + return + d = v.LoginDialog( + self, 'Login to ABQ Corporate REST API' + ) + if d.result is not None: + username, password = d.result + else: + return + try: + n.upload_to_corporate_rest( + csvfile, + self.settings['abq_upload_url'].get(), + self.settings['abq_auth_url'].get(), + username, + password + ) + except n.requests.ConnectionError as e: + messagebox.showerror('Error connecting', str(e)) + except Exception as e: + messagebox.showerror('General Exception', str(e)) + else: + messagebox.showinfo( + 'Success', + f'{csvfile} successfully uploaded to REST API.' + ) diff --git a/Chapter13/ABQ_Data_Entry/abq_data_entry/constants.py b/Chapter13/ABQ_Data_Entry/abq_data_entry/constants.py new file mode 100644 index 0000000..e747dce --- /dev/null +++ b/Chapter13/ABQ_Data_Entry/abq_data_entry/constants.py @@ -0,0 +1,12 @@ +"""Global constants and classes needed by other modules in ABQ Data Entry""" +from enum import Enum, auto + +class FieldTypes(Enum): + string = auto() + string_list = auto() + short_string_list = auto() + iso_date_string = auto() + long_string = auto() + decimal = auto() + integer = auto() + boolean = auto() diff --git a/Chapter13/ABQ_Data_Entry/abq_data_entry/images/__init__.py b/Chapter13/ABQ_Data_Entry/abq_data_entry/images/__init__.py new file mode 100644 index 0000000..57538d9 --- /dev/null +++ b/Chapter13/ABQ_Data_Entry/abq_data_entry/images/__init__.py @@ -0,0 +1,20 @@ +from pathlib import Path + +# This gives us the parent directory of this file (__init__.py) +IMAGE_DIRECTORY = Path(__file__).parent + +ABQ_LOGO_16 = IMAGE_DIRECTORY / 'abq_logo-16x10.png' +ABQ_LOGO_32 = IMAGE_DIRECTORY / 'abq_logo-32x20.png' +ABQ_LOGO_64 = IMAGE_DIRECTORY / 'abq_logo-64x40.png' + +# PNG icons + +SAVE_ICON = IMAGE_DIRECTORY / 'file-2x.png' +RESET_ICON = IMAGE_DIRECTORY / 'reload-2x.png' +LIST_ICON = IMAGE_DIRECTORY / 'list-2x.png' +FORM_ICON = IMAGE_DIRECTORY / 'browser-2x.png' + + +# BMP icons +QUIT_BMP = IMAGE_DIRECTORY / 'x-2x.xbm' +ABOUT_BMP = IMAGE_DIRECTORY / 'question-mark-2x.xbm' diff --git a/Chapter13/ABQ_Data_Entry/abq_data_entry/images/abq_logo-16x10.png b/Chapter13/ABQ_Data_Entry/abq_data_entry/images/abq_logo-16x10.png new file mode 100644 index 0000000000000000000000000000000000000000..255f2739e10827885fe11f4fece3f7e42bf73064 GIT binary patch literal 1346 zcmV-I1-<%-P) zaB^>EX>4U6ba`-PAZ2)IW&i+q+O3sqlI%7JM*nLSS%LuZ&~k(xRoOw7pHJ?dnLCx6 zls-|pyCOH&W)W)-9L)_LG2>T9&;O3zC5{ZKz{zR61+Z#hFG zKWM&JxgRhd?ftx8tG=96+jeXj6%!Y(ycqFZfw?K}ryW-);pu+;{JuM~PltRXD<3cX z?FnN3F=Rw6^@nlJigWgB$DY+x91|8bZI%y)T#+w~0^JIBsAk;L*(mi04aiRMKB~FP>n>%s5-L~A&&t-1Cg^d zP7okfUI>y~5i!6CzP|B|)1%AEFELsMAXIMM1_%wnYE4l;-U2l=RJ5t86?F~mI!vsY znxU3&?+q7ku5Rug-hG5b3k?g8h#sSJ7qq5!>)xaH(#L?)0n-Ct4`_^$oRTdyEj=T9 zj*0S_ZR)h?GiIM-@sib+E?d50^|HpMjZ)fe>$dGXcHiTm){dNZ^q}cZoPNe9HF|g6 zw^_cMK9 zTUyDFvfH6;> z;~-~iW{-1^-pYHSk<)4AKw}QH$br5kDg*Fx4^(_zJxyt>Eg4`sM^@G#t*8sef1fXB zOYMt6ZbS!*k_>;?0AjQ*a*)zq{shn6wOaaUK zNlSf$=-Pryrn)FpkWy%ilEl9#)q*v3Dt34jBAQSPmiLmpd=4fmDD=$to^#K+M*y{8 ztiWGiXGa}1wUVR5B2scKgn%E(Bfz@{qd@^VdQWK zGQcgs1=ye_6672W-4nB2VCfZZg|Fiq%IO1jreqjN=0BQn1Jq;!5CD8P_xXKzKx=&i zVC(X1!%a480TP5H^(W2fo0rUvf1!69ge!!VvpCM55P1KB*2b2SAw|ilglAh!Cq6T` z7GvP`6QUm`$aZ^)1z`DSl=epvlBP-g@ms{;{qsyp1Vxw)@cdzfr@>wt*WrQh4t1F} z0D63PXiV;m4}np?aK zy}C`Te~OJ?i);c(b02{~MVHD?MZlP4+hN_U6)yxe5A!phm>e+hNBnGk-DO*?g5#Wz z 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 literal 0 HcmV?d00001 diff --git a/Chapter13/ABQ_Data_Entry/abq_data_entry/images/abq_logo-64x40.png b/Chapter13/ABQ_Data_Entry/abq_data_entry/images/abq_logo-64x40.png new file mode 100644 index 0000000000000000000000000000000000000000..be9458ff1799dcb20cdab36eb100511f1b213606 GIT binary patch literal 5421 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 z zqn~~&0hgPPx9?`d^Dpxszx;%WRdC6mPGj$W8rA|+a3PD*Pyjc%HSQRD8x6o^<#5ZY z#mu~FDw^T$T@gZn=4SERt1)bilx9N`W{jE8Z$o{KL|vC4!_fPrbRyCm;jY2oB|Qk- z0T|{&*&~UB}N}KMDmVXh{yI8mZr(VYC2R z3ZSsm&27f3utX@o$mHPD>K}x+QTEVK$()N^SV?O(NSIUm9VQIB2&Cwl7WV-GPh_~T z+4|mizA^ng0NT@Wf{$JW#p1=hKW9Bew`Vut6^-_kExX6??{7Q9Wivm{G2G`2@VUKezzoBZOrLDj?le%>g0IL6GLfP4!pVeE zTA72^LGO2|6c!+eho~>9K6C(<2U1BzZ^6{c3vmYvC^HJtg-+5=aojx32d57~7zhEy z@W|Fx-1qy{q!w&OmwhjEc@>}uH#{>JN(HoQ*h$Ij?_A8p32I7sbOVIIrD+(tjsy$~ zrUtHNj9W4Msg+<)Yzq{Gc=pLd%zyB+-1zHX8P3hO)gYc8ul8_&Xj>Bbcs3RGGDm!k zXrbaV*#ygXF5tq+Bd63V!u{0NaJ@hwJNXz2G8(u=15(?qK;l{l5XH@}z4G_Ti%8|;g1nfF-0JOdryK_zf z0J`SSxmGydOupjfoK!$_>{PDg9~rzLZ(4lrDL~WN{K+o0B-$`x$f&$Y(Yd1lAwVx+ z^TB#3W99f@%K z)EREr`h-fL{6Gvg-}NMhaOJHi&$sY$1cnR0NGgY<#}_<*?xo z(CTf=U>O$(Q0_;BzbWmjSI?Ai#`M8RFqIwejdJ109*xNCuu!(hKH}em!{(~kj_M`Ndi01WuaY6#q}C@ogK zSj`XT4gc~=0MI>PB{;nIQzES~T3g#Wee49WwszW^S||tv@D&9qDm#xs=a;c}=N{hp z=U4C#b1|r@if7+kjnH-FE?+|UmH*bj-S_^H&co66HSpBknNe$js}F&Bp?bG?;Qk8! zW!X4fZa&}l2Ld5L>%O%lRWP${&^`S67aRE5txr*2?dG#jO}0edrXb)S`2W%bsU$qI zXfG?C3F9~(KL~G)h5BqXa0?hK;f8%+)~c-+ei-*%NS6TRKp}tK*W_A(FzC&&|G(g~XJ9*hU6cEN XhPBd*{i(!N00000NkvXXu0mjfWYLOi literal 0 HcmV?d00001 diff --git a/Chapter13/ABQ_Data_Entry/abq_data_entry/images/browser-2x.png b/Chapter13/ABQ_Data_Entry/abq_data_entry/images/browser-2x.png new file mode 100644 index 0000000000000000000000000000000000000000..2a6efb0d3ad404d3e6df2c4f7e2c9441945b9377 GIT binary patch literal 174 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`EX7WqAsj$Z!;#Vf4nJ za0`PlBg3pY50TY@YDj113tz;u?-H7A2U5ODE0AZ z@(JiUbDVWXB2U-ehVx>J7`TOIPjuP)f90d5KxVdP#t_fW4nJ za0`PlBg3pY54nJ za0`PlBg3pY5<39QJlxHc{CLc#vuuf5Bd*}mNt{%q1HX$~}v!PC{xWt~$(695?x BFjxQp literal 0 HcmV?d00001 diff --git a/Chapter13/ABQ_Data_Entry/abq_data_entry/images/question-mark-2x.xbm b/Chapter13/ABQ_Data_Entry/abq_data_entry/images/question-mark-2x.xbm new file mode 100644 index 0000000..8981c9b --- /dev/null +++ b/Chapter13/ABQ_Data_Entry/abq_data_entry/images/question-mark-2x.xbm @@ -0,0 +1,6 @@ +#define question_mark_2x_width 16 +#define question_mark_2x_height 16 +static unsigned char question_mark_2x_bits[] = { + 0xc0, 0x0f, 0xe0, 0x1f, 0x70, 0x38, 0x30, 0x30, 0x00, 0x30, 0x00, 0x30, + 0x00, 0x18, 0x00, 0x1c, 0x00, 0x0e, 0x00, 0x07, 0x00, 0x03, 0x00, 0x03, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x03 }; diff --git a/Chapter13/ABQ_Data_Entry/abq_data_entry/images/reload-2x.png b/Chapter13/ABQ_Data_Entry/abq_data_entry/images/reload-2x.png new file mode 100644 index 0000000000000000000000000000000000000000..2a79f1665d1156d2e140a10f7902bc69f8bb26bb GIT binary patch literal 336 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`EX7WqAsj$Z!;#Vf4nJ za0`PlBg3pY5>Y*U9P$-MRL!(@9^X8fhq)(E zFJr#NVI#l5GfnbJnH`@4HZ}2mE9hQ-qOz;b%b=q*ea-b7vyRl=n5ChmHt%@F;V9;a z6Z5u)Owe0%kz&bqH;`{=)pW(4FHy7Od@(eJR=g%=_#4 zi__yCxo)1S*Z0cIYSL7p_d6W?wLbFf-tj58wY^j&QX})1kLtS(w^GujuU`_^dvqmc e)r~mQpVIIC_*-03aXkd|J%gvKpUXO@geCw(qlWDO literal 0 HcmV?d00001 diff --git a/Chapter13/ABQ_Data_Entry/abq_data_entry/images/x-2x.xbm b/Chapter13/ABQ_Data_Entry/abq_data_entry/images/x-2x.xbm new file mode 100644 index 0000000..940af96 --- /dev/null +++ b/Chapter13/ABQ_Data_Entry/abq_data_entry/images/x-2x.xbm @@ -0,0 +1,6 @@ +#define x_2x_width 16 +#define x_2x_height 16 +static unsigned char x_2x_bits[] = { + 0x04, 0x10, 0x0e, 0x38, 0x1f, 0x7c, 0x3e, 0x7e, 0x7c, 0x3f, 0xf8, 0x1f, + 0xf0, 0x0f, 0xe0, 0x07, 0xf0, 0x07, 0xf8, 0x0f, 0xfc, 0x1f, 0x7e, 0x3e, + 0x3f, 0x7c, 0x1e, 0x78, 0x0c, 0x30, 0x00, 0x00 }; diff --git a/Chapter13/ABQ_Data_Entry/abq_data_entry/mainmenu.py b/Chapter13/ABQ_Data_Entry/abq_data_entry/mainmenu.py new file mode 100644 index 0000000..83e3ef4 --- /dev/null +++ b/Chapter13/ABQ_Data_Entry/abq_data_entry/mainmenu.py @@ -0,0 +1,408 @@ +"""The Main Menu class for ABQ Data Entry""" + +import tkinter as tk +from tkinter import ttk +from tkinter import messagebox +from tkinter import font + +from . import images + +class GenericMainMenu(tk.Menu): + """The Application's main menu""" + + accelerators = { + 'file_open': 'Ctrl+O', + 'quit': 'Ctrl+Q', + 'record_list': 'Ctrl+L', + 'new_record': 'Ctrl+R', + } + + keybinds = { + '': '<>', + '': '<>', + '': '<>', + '': '<>' + } + + styles = {} + + def _event(self, sequence): + """Return a callback function that generates the sequence""" + def callback(*_): + root = self.master.winfo_toplevel() + root.event_generate(sequence) + + return callback + + def _create_icons(self): + + # must be done in a method because PhotoImage can't be created + # until there is a Tk instance. + # There isn't one when the class is defined, but there is when + # the instance is created. + self.icons = { + # 'file_open': tk.PhotoImage(file=images.SAVE_ICON), + 'record_list': tk.PhotoImage(file=images.LIST_ICON), + 'new_record': tk.PhotoImage(file=images.FORM_ICON), + 'quit': tk.BitmapImage(file=images.QUIT_BMP, foreground='red'), + 'about': tk.BitmapImage( + file=images.ABOUT_BMP, foreground='#CC0', background='#A09' + ), + } + + def _add_file_open(self, menu): + + menu.add_command( + label='Select file…', command=self._event('<>'), + image=self.icons.get('file'), compound=tk.LEFT + ) + + def _add_quit(self, menu): + menu.add_command( + label='Quit', command=self._event('<>'), + image=self.icons.get('quit'), compound=tk.LEFT + ) + + def _add_weather_download(self, menu): + menu.add_command( + label="Update Weather Data", + command=self._event('<>'), + accelerator=self.accelerators.get('weather'), + image=self.icons.get('weather'), + compound=tk.LEFT + ) + + def _add_rest_upload(self, menu): + menu.add_command( + label="Upload CSV to corporate REST", + command=self._event('<>'), + image=self.icons.get('upload_rest'), + compound=tk.LEFT + ) + + def _add_ftp_upload(self, menu): + menu.add_command( + label="Upload CSV to corporate FTP", + command=self._event('<>'), + image=self.icons.get('upload_ftp'), + compound=tk.LEFT + ) + + def _add_autofill_date(self, menu): + menu.add_checkbutton( + label='Autofill Date', variable=self.settings['autofill date'] + ) + + def _add_autofill_sheet(self, menu): + menu.add_checkbutton( + label='Autofill Sheet data', + variable=self.settings['autofill sheet data'] + ) + + def _add_font_size_menu(self, menu): + font_size_menu = tk.Menu(self, tearoff=False, **self.styles) + for size in range(6, 17, 1): + font_size_menu.add_radiobutton( + label=size, value=size, + variable=self.settings['font size'] + ) + menu.add_cascade(label='Font size', menu=font_size_menu) + + def _add_font_family_menu(self, menu): + font_family_menu = tk.Menu(self, tearoff=False, **self.styles) + for family in font.families(): + font_family_menu.add_radiobutton( + label=family, value=family, + variable=self.settings['font family'] + ) + menu.add_cascade(label='Font family', menu=font_family_menu) + + def _add_themes_menu(self, menu): + style = ttk.Style() + themes_menu = tk.Menu(self, tearoff=False, **self.styles) + for theme in style.theme_names(): + themes_menu.add_radiobutton( + label=theme, value=theme, + variable=self.settings['theme'] + ) + menu.add_cascade(label='Theme', menu=themes_menu) + self.settings['theme'].trace_add('write', self._on_theme_change) + + def _add_go_record_list(self, menu): + menu.add_command( + label="Record List", command=self._event('<>'), + image=self.icons.get('record_list'), compound=tk.LEFT + ) + + def _add_go_new_record(self, menu): + menu.add_command( + label="New Record", command=self._event('<>'), + image=self.icons.get('new_record'), compound=tk.LEFT + ) + + def _add_about(self, menu): + menu.add_command( + label='About…', command=self.show_about, + image=self.icons.get('about'), compound=tk.LEFT + ) + + def _build_menu(self): + # The file menu + self._menus['File'] = tk.Menu(self, tearoff=False, **self.styles) + #self._add_file_open(self._menus['File']) + self._menus['File'].add_separator() + self._add_quit(self._menus['File']) + + #Tools menu + self._menus['Tools'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_weather_download(self._menus['Tools']) + self._add_rest_upload(self._menus['Tools']) + self._add_ftp_upload(self._menus['Tools']) + + # The options menu + self._menus['Options'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_autofill_date(self._menus['Options']) + self._add_autofill_sheet(self._menus['Options']) + self._add_font_size_menu(self._menus['Options']) + self._add_font_family_menu(self._menus['Options']) + self._add_themes_menu(self._menus['Options']) + + # switch from recordlist to recordform + self._menus['Go'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_go_record_list(self._menus['Go']) + self._add_go_new_record(self._menus['Go']) + + # The help menu + self._menus['Help'] = tk.Menu(self, tearoff=False, **self.styles) + self.add_cascade(label='Help', menu=self._menus['Help']) + self._add_about(self._menus['Help']) + + for label, menu in self._menus.items(): + self.add_cascade(label=label, menu=menu) + self.configure(**self.styles) + + def __init__(self, parent, settings, **kwargs): + super().__init__(parent, **kwargs) + self.settings = settings + self._create_icons() + self._menus = dict() + self._build_menu() + self._bind_accelerators() + self.configure(**self.styles) + + def show_about(self): + """Show the about dialog""" + + about_message = 'ABQ Data Entry' + about_detail = ( + 'by Alan D Moore\n' + 'For assistance please contact the author.' + ) + + messagebox.showinfo( + title='About', message=about_message, detail=about_detail + ) + @staticmethod + def _on_theme_change(*_): + """Popup a message about theme changes""" + message = "Change requires restart" + detail = ( + "Theme changes do not take effect" + " until application restart" + ) + messagebox.showwarning( + title='Warning', + message=message, + detail=detail + ) + + def _bind_accelerators(self): + + for key, sequence in self.keybinds.items(): + self.bind_all(key, self._event(sequence)) + +class WindowsMainMenu(GenericMainMenu): + """ + Changes: + - Windows uses file->exit instead of file->quit, + and no accelerator is used. + - Windows can handle commands on the menubar, so + put 'Record List' / 'New Record' on the bar + - Windows can't handle icons on the menu bar, though + - Put 'options' under 'Tools' with separator + """ + + def _create_icons(self): + super()._create_icons() + del(self.icons['new_record']) + del(self.icons['record_list']) + + def __init__(self, *args, **kwargs): + del(self.keybinds['']) + super().__init__(*args, **kwargs) + + def _add_quit(self, menu): + menu.add_command( + label='Exit', + command=self._event('<>'), + image=self.icons.get('quit'), + compound=tk.LEFT + ) + + def _build_menu(self): + # File Menu + self._menus['File'] = tk.Menu(self, tearoff=False) +# self._add_file_open(self._menus['File']) + self._menus['File'].add_separator() + self._add_quit(self._menus['File']) + + #Tools menu + self._menus['Tools'] = tk.Menu(self, tearoff=False) + self._add_autofill_date(self._menus['Tools']) + self._add_autofill_sheet(self._menus['Tools']) + self._add_font_size_menu(self._menus['Tools']) + self._add_font_family_menu(self._menus['Tools']) + self._add_themes_menu(self._menus['Tools']) + self._add_weather_download(self._menus['Tools']) + self._add_rest_upload(self._menus['Tools']) + self._add_ftp_upload(self._menus['Tools']) + + # The help menu + self._menus['Help'] = tk.Menu(self, tearoff=False) + self._add_about(self._menus['Help']) + + # Build main menu + self.add_cascade(label='File', menu=self._menus['File']) + self.add_cascade(label='Tools', menu=self._menus['Tools']) + self._add_go_record_list(self) + self._add_go_new_record(self) + self.add_cascade(label='Help', menu=self._menus['Help']) + + +class LinuxMainMenu(GenericMainMenu): + """Differences for Linux: + + - Edit menu for autofill options + - View menu for font & theme options + - Use color theme for menu + """ + styles = { + 'background': '#333', + 'foreground': 'white', + 'activebackground': '#777', + 'activeforeground': 'white', + 'relief': tk.GROOVE + } + + + def _build_menu(self): + self._menus['File'] = tk.Menu(self, tearoff=False, **self.styles) +# self._add_file_open(self._menus['File']) + self._menus['File'].add_separator() + self._add_quit(self._menus['File']) + + # The edit menu + self._menus['Edit'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_autofill_date(self._menus['Edit']) + self._add_autofill_sheet(self._menus['Edit']) + + #Tools menu + self._menus['Tools'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_weather_download(self._menus['Tools']) + self._add_rest_upload(self._menus['Tools']) + self._add_ftp_upload(self._menus['Tools']) + + # The View menu + self._menus['View'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_font_size_menu(self._menus['View']) + self._add_font_family_menu(self._menus['View']) + self._add_themes_menu(self._menus['View']) + + # switch from recordlist to recordform + self._menus['Go'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_go_record_list(self._menus['Go']) + self._add_go_new_record(self._menus['Go']) + + # The help menu + self._menus['Help'] = tk.Menu(self, tearoff=False, **self.styles) + self._add_about(self._menus['Help']) + + for label, menu in self._menus.items(): + self.add_cascade(label=label, menu=menu) + + +class MacOsMainMenu(GenericMainMenu): + """ + Differences for MacOS: + + - Create App Menu + - Move about to app menu, remove 'help' + - Remove redundant quit command + - Change accelerators to Command-[] + - Add View menu for font & theme options + - Add Edit menu for autofill options + - Add Window menu for navigation commands + """ + keybinds = { + '': '<>', + '': '<>', + '': '<>' + } + accelerators = { + 'file_open': 'Cmd-O', + 'record_list': 'Cmd-L', + 'new_record': 'Cmd-R', + } + + def _add_about(self, menu): + menu.add_command( + label='About ABQ Data Entry', command=self.show_about, + image=self.icons.get('about'), compound=tk.LEFT + ) + + def _build_menu(self): + self._menus['ABQ Data Entry'] = tk.Menu( + self, tearoff=False, + name='apple' + ) + self._add_about(self._menus['ABQ Data Entry']) + self._menus['ABQ Data Entry'].add_separator() + + self._menus['File'] = tk.Menu(self, tearoff=False) +# self._add_file_open(self._menus['File']) + + self._menus['Edit'] = tk.Menu(self, tearoff=False) + self._add_autofill_date(self._menus['Edit']) + self._add_autofill_sheet(self._menus['Edit']) + + #Tools menu + self._menus['Tools'] = tk.Menu(self, tearoff=False) + self._add_weather_download(self._menus['Tools']) + self._add_rest_upload(self._menus['Tools']) + self._add_ftp_upload(self._menus['Tools']) + + # View menu + self._menus['View'] = tk.Menu(self, tearoff=False) + self._add_font_size_menu(self._menus['View']) + self._add_font_family_menu(self._menus['View']) + self._add_themes_menu(self._menus['View']) + + # Window Menu + self._menus['Window'] = tk.Menu(self, name='window', tearoff=False) + self._add_go_record_list(self._menus['Window']) + self._add_go_new_record(self._menus['Window']) + + for label, menu in self._menus.items(): + self.add_cascade(label=label, menu=menu) + + +def get_main_menu_for_os(os_name): + """Return the menu class appropriate to the given OS""" + menus = { + 'Linux': LinuxMainMenu, + 'Darwin': MacOsMainMenu, + 'freebsd7': LinuxMainMenu, + 'Windows': WindowsMainMenu + } + + return menus.get(os_name, GenericMainMenu) diff --git a/Chapter13/ABQ_Data_Entry/abq_data_entry/models.py b/Chapter13/ABQ_Data_Entry/abq_data_entry/models.py new file mode 100644 index 0000000..5efed16 --- /dev/null +++ b/Chapter13/ABQ_Data_Entry/abq_data_entry/models.py @@ -0,0 +1,374 @@ +import csv +from pathlib import Path +import os +import json +import platform +from datetime import datetime + +import psycopg2 as pg +from psycopg2.extras import DictCursor + +from .constants import FieldTypes as FT + + +class SQLModel: + """Data Model for SQL data storage""" + + fields = { + "Date": {'req': True, 'type': FT.iso_date_string}, + "Time": {'req': True, 'type': FT.string_list, + 'values': ['8:00', '12:00', '16:00', '20:00']}, + "Technician": {'req': True, 'type': FT.string_list, + 'values': []}, + "Lab": {'req': True, 'type': FT.short_string_list, + 'values': []}, + "Plot": {'req': True, 'type': FT.string_list, + 'values': []}, + "Seed Sample": {'req': True, 'type': FT.string}, + "Humidity": {'req': True, 'type': FT.decimal, + 'min': 0.5, 'max': 52.0, 'inc': .01}, + "Light": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 100.0, 'inc': .01}, + "Temperature": {'req': True, 'type': FT.decimal, + 'min': 4, 'max': 40, 'inc': .01}, + "Equipment Fault": {'req': False, 'type': FT.boolean}, + "Plants": {'req': True, 'type': FT.integer, + 'min': 0, 'max': 20}, + "Blossoms": {'req': True, 'type': FT.integer, + 'min': 0, 'max': 1000}, + "Fruit": {'req': True, 'type': FT.integer, + 'min': 0, 'max': 1000}, + "Min Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Max Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Med Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Notes": {'req': False, 'type': FT.long_string} + } + lc_update_query = ( + 'UPDATE lab_checks SET lab_tech_id = ' + '(SELECT id FROM lab_techs WHERE name = %(Technician)s) ' + 'WHERE date=%(Date)s AND time=%(Time)s AND lab_id=%(Lab)s' + ) + + lc_insert_query = ( + 'INSERT INTO lab_checks VALUES (%(Date)s, %(Time)s, %(Lab)s, ' + '(SELECT id FROM lab_techs WHERE name LIKE %(Technician)s))' + ) + + pc_update_query = ( + 'UPDATE plot_checks SET date=%(Date)s, time=%(Time)s, ' + 'lab_id=%(Lab)s, plot=%(Plot)s, seed_sample = %(Seed Sample)s, ' + 'humidity = %(Humidity)s, light = %(Light)s, ' + 'temperature = %(Temperature)s, ' + 'equipment_fault = %(Equipment Fault)s, ' + 'blossoms = %(Blossoms)s, plants = %(Plants)s, ' + 'fruit = %(Fruit)s, max_height = %(Max Height)s, ' + 'min_height = %(Min Height)s, median_height = %(Med Height)s, ' + 'notes = %(Notes)s WHERE date=%(key_date)s AND time=%(key_time)s ' + 'AND lab_id=%(key_lab)s AND plot=%(key_plot)s') + + pc_insert_query = ( + 'INSERT INTO plot_checks VALUES (%(Date)s, %(Time)s, %(Lab)s,' + ' %(Plot)s, %(Seed Sample)s, %(Humidity)s, %(Light)s,' + ' %(Temperature)s, %(Equipment Fault)s, %(Blossoms)s, %(Plants)s,' + ' %(Fruit)s, %(Max Height)s, %(Min Height)s,' + ' %(Med Height)s, %(Notes)s)') + + def __init__(self, host, database, user, password): + self.connection = pg.connect(host=host, database=database, + user=user, password=password, cursor_factory=DictCursor) + + techs = self.query("SELECT * FROM lab_techs ORDER BY name") + labs = self.query("SELECT id FROM labs ORDER BY id") + plots = self.query("SELECT DISTINCT plot FROM plots ORDER BY plot") + self.fields['Technician']['values'] = [x['name'] for x in techs] + self.fields['Lab']['values'] = [x['id'] for x in labs] + self.fields['Plot']['values'] = [str(x['plot']) for x in plots] + + def query(self, query, parameters=None): + cursor = self.connection.cursor() + try: + cursor.execute(query, parameters) + except (pg.Error) as e: + self.connection.rollback() + raise e + else: + self.connection.commit() + # cursor.description is None when + # no rows are returned + if cursor.description is not None: + return cursor.fetchall() + + def get_all_records(self, all_dates=False): + """Return all records. + + By default, only return today's records, unless + all_dates is True. + """ + query = ('SELECT * FROM data_record_view ' + 'WHERE %(all_dates)s OR "Date" = CURRENT_DATE ' + 'ORDER BY "Date" DESC, "Time", "Lab", "Plot"') + return self.query(query, {'all_dates': all_dates}) + + def get_record(self, rowkey): + """Return a single record + + rowkey must be a tuple of date, time, lab, and plot + """ + date, time, lab, plot = rowkey + query = ( + 'SELECT * FROM data_record_view ' + 'WHERE "Date" = %(date)s AND "Time" = %(time)s ' + 'AND "Lab" = %(lab)s AND "Plot" = %(plot)s') + result = self.query( + query, + {"date": date, "time": time, "lab": lab, "plot": plot} + ) + return result[0] if result else {} + + def save_record(self, record, rowkey): + """Save a record to the database + + rowkey must be a tuple of date, time, lab, and plot. + Or None if this is a new record. + """ + if rowkey: + key_date, key_time, key_lab, key_plot = rowkey + record.update({ + "key_date": key_date, + "key_time": key_time, + "key_lab": key_lab, + "key_plot": key_plot + }) + + # Lab check is based on the entered date/time/lab + if self.get_lab_check( + record['Date'], record['Time'], record['Lab'] + ): + lc_query = self.lc_update_query + else: + lc_query = self.lc_insert_query + # Plot check is based on the key values + if rowkey: + pc_query = self.pc_update_query + else: + pc_query = self.pc_insert_query + + self.query(lc_query, record) + self.query(pc_query, record) + + def get_lab_check(self, date, time, lab): + """Retrieve the lab check record for the given date, time, and lab""" + query = ('SELECT date, time, lab_id, lab_tech_id, ' + 'lt.name as lab_tech FROM lab_checks JOIN lab_techs lt ' + 'ON lab_checks.lab_tech_id = lt.id WHERE ' + 'lab_id = %(lab)s AND date = %(date)s AND time = %(time)s') + results = self.query( + query, {'date': date, 'time': time, 'lab': lab}) + return results[0] if results else {} + + def get_current_seed_sample(self, lab, plot): + """Get the seed sample currently planted in the given lab and plot""" + result = self.query('SELECT current_seed_sample FROM plots ' + 'WHERE lab_id=%(lab)s AND plot=%(plot)s', + {'lab': lab, 'plot': plot}) + return result[0]['current_seed_sample'] if result else '' + + def add_weather_data(self, data): + query = ( + 'INSERT INTO local_weather VALUES ' + '(%(observation_time_rfc822)s, %(temp_c)s, ' + '%(relative_humidity)s, %(pressure_mb)s, ' + '%(weather)s)' + ) + try: + self.query(query, data) + except pg.IntegrityError: + # already have weather for this datetime + pass + +class CSVModel: + """CSV file storage""" + + fields = { + "Date": {'req': True, 'type': FT.iso_date_string}, + "Time": {'req': True, 'type': FT.string_list, + 'values': ['8:00', '12:00', '16:00', '20:00']}, + "Technician": {'req': True, 'type': FT.string}, + "Lab": {'req': True, 'type': FT.short_string_list, + 'values': ['A', 'B', 'C']}, + "Plot": {'req': True, 'type': FT.string_list, + 'values': [str(x) for x in range(1, 21)]}, + "Seed Sample": {'req': True, 'type': FT.string}, + "Humidity": {'req': True, 'type': FT.decimal, + 'min': 0.5, 'max': 52.0, 'inc': .01}, + "Light": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 100.0, 'inc': .01}, + "Temperature": {'req': True, 'type': FT.decimal, + 'min': 4, 'max': 40, 'inc': .01}, + "Equipment Fault": {'req': False, 'type': FT.boolean}, + "Plants": {'req': True, 'type': FT.integer, 'min': 0, 'max': 20}, + "Blossoms": {'req': True, 'type': FT.integer, 'min': 0, 'max': 1000}, + "Fruit": {'req': True, 'type': FT.integer, 'min': 0, 'max': 1000}, + "Min Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Max Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Med Height": {'req': True, 'type': FT.decimal, + 'min': 0, 'max': 1000, 'inc': .01}, + "Notes": {'req': False, 'type': FT.long_string} + } + + + def __init__(self, filename=None): + + if not filename: + datestring = datetime.today().strftime("%Y-%m-%d") + filename = "abq_data_record_{}.csv".format(datestring) + self.file = Path(filename) + + # Check for append permissions: + file_exists = os.access(self.file, os.F_OK) + parent_writeable = os.access(self.file.parent, os.W_OK) + file_writeable = os.access(self.file, os.W_OK) + if ( + (not file_exists and not parent_writeable) or + (file_exists and not file_writeable) + ): + msg = f'Permission denied accessing file: {filename}' + raise PermissionError(msg) + + + def save_record(self, data, rownum=None): + """Save a dict of data to the CSV file""" + + if rownum is None: + # This is a new record + newfile = not self.file.exists() + + with open(self.file, 'a', newline='') as fh: + csvwriter = csv.DictWriter(fh, fieldnames=self.fields.keys()) + if newfile: + csvwriter.writeheader() + csvwriter.writerow(data) + else: + # This is an update + records = self.get_all_records() + records[rownum] = data + with open(self.file, 'w', encoding='utf-8', newline='') as fh: + csvwriter = csv.DictWriter(fh, fieldnames=self.fields.keys()) + csvwriter.writeheader() + csvwriter.writerows(records) + + def get_all_records(self): + """Read in all records from the CSV and return a list""" + if not self.file.exists(): + return [] + + with open(self.file, 'r', encoding='utf-8') as fh: + # Casting to list is necessary for unit tests to work + csvreader = csv.DictReader(list(fh.readlines())) + missing_fields = set(self.fields.keys()) - set(csvreader.fieldnames) + if len(missing_fields) > 0: + fields_string = ', '.join(missing_fields) + raise Exception( + f"File is missing fields: {fields_string}" + ) + records = list(csvreader) + + # Correct issue with boolean fields + trues = ('true', 'yes', '1') + bool_fields = [ + key for key, meta + in self.fields.items() + if meta['type'] == FT.boolean + ] + for record in records: + for key in bool_fields: + record[key] = record[key].lower() in trues + return records + + def get_record(self, rownum): + """Get a single record by row number + + Callling code should catch IndexError + in case of a bad rownum. + """ + + return self.get_all_records()[rownum] + + +class SettingsModel: + """A model for saving settings""" + + fields = { + 'autofill date': {'type': 'bool', 'value': True}, + 'autofill sheet data': {'type': 'bool', 'value': True}, + 'font size': {'type': 'int', 'value': 9}, + 'font family': {'type': 'str', 'value': ''}, + 'theme': {'type': 'str', 'value': 'default'}, + 'db_host': {'type': 'str', 'value': 'localhost'}, + 'db_name': {'type': 'str', 'value': 'abq'}, + 'weather_station': {'type': 'str', 'value': 'KBMG'}, + 'abq_auth_url': { + 'type': 'str', + 'value': 'http://localhost:8000/auth' + }, + 'abq_upload_url': { + 'type': 'str', + 'value': 'http://localhost:8000/upload' + }, + 'abq_ftp_host': {'type': 'str', 'value': 'localhost'}, + 'abq_ftp_port': {'type': 'int', 'value': 2100} + } + + config_dirs = { + "Linux": Path(os.environ.get('$XDG_CONFIG_HOME', Path.home() / '.config')), + "freebsd7": Path(os.environ.get('$XDG_CONFIG_HOME', Path.home() / '.config')), + 'Darwin': Path.home() / 'Library' / 'Application Support', + 'Windows': Path.home() / 'AppData' / 'Local' + } + + def __init__(self): + # determine the file path + filename = 'abq_settings.json' + filedir = self.config_dirs.get(platform.system(), Path.home()) + self.filepath = filedir / filename + + # load in saved values + self.load() + + def set(self, key, value): + """Set a variable value""" + if ( + key in self.fields and + type(value).__name__ == self.fields[key]['type'] + ): + self.fields[key]['value'] = value + else: + raise ValueError("Bad key or wrong variable type") + + def save(self): + """Save the current settings to the file""" + json_string = json.dumps(self.fields) + with open(self.filepath, 'w', encoding='utf-8') as fh: + fh.write(json_string) + + def load(self): + """Load the settings from the file""" + + # if the file doesn't exist, return + if not self.filepath.exists(): + return + + # open the file and read in the raw values + with open(self.filepath, 'r') as fh: + raw_values = json.loads(fh.read()) + + # don't implicitly trust the raw values, but only get known keys + for key in self.fields: + if key in raw_values and 'value' in raw_values[key]: + raw_value = raw_values[key]['value'] + self.fields[key]['value'] = raw_value diff --git a/Chapter13/ABQ_Data_Entry/abq_data_entry/network.py b/Chapter13/ABQ_Data_Entry/abq_data_entry/network.py new file mode 100644 index 0000000..65fdd35 --- /dev/null +++ b/Chapter13/ABQ_Data_Entry/abq_data_entry/network.py @@ -0,0 +1,82 @@ +from urllib.request import urlopen +from xml.etree import ElementTree +import requests +import ftplib as ftp +from os import path + +def get_local_weather(station): + """Retrieve local weather data from the web + + Returns a dictionary containing: + - observation_time_rfc822: timestamp of observation in rfc822 format + - temp_c: The temperature in degrees C + - relative_humidity: Relative Humidity percentage + - pressure_mb: Air pressure in mb + - weather: A string describing weather conditions + """ + url = f'http://w1.weather.gov/xml/current_obs/%7Bstation%7D.xml' + response = urlopen(url) + + xmlroot = ElementTree.fromstring(response.read()) + weatherdata = { + 'observation_time_rfc822': None, + 'temp_c': None, + 'relative_humidity': None, + 'pressure_mb': None, + 'weather': None + } + + for tag in weatherdata: + element = xmlroot.find(tag) + if element is not None: + weatherdata[tag] = element.text + + return weatherdata + + +def upload_to_corporate_rest( + filepath, upload_url, auth_url, + username, password): + """Upload a file to the corporate server using REST""" + + session = requests.session() + + response = session.post( + auth_url, + data={'username': username, 'password': password} + ) + response.raise_for_status() + + files = {'file': open(filepath, 'rb')} + response = session.put( + upload_url, + files=files + ) + files['file'].close() + response.raise_for_status() + + +def upload_to_corporate_ftp( + filepath, ftp_host, + ftp_port, ftp_user, ftp_pass): + """Upload a file to the corporate server using FTP""" + + with ftp.FTP() as ftp_cx: + # connect and login + ftp_cx.connect(ftp_host, ftp_port) + ftp_cx.login(ftp_user, ftp_pass) + + # upload file + filename = path.basename(filepath) + + with open(filepath, 'rb') as fh: + ftp_cx.storbinary('STOR {}'.format(filename), fh) + +def get_corporate_ftp_files(ftp_host, ftp_port, ftp_user, ftp_pass): + """Get a list of the files on the FTP host""" + with ftp.FTP() as ftp_cx: + ftp_cx.connect(ftp_host, ftp_port) + ftp_cx.login(ftp_user, ftp_pass) + files = ftp_cx.nlst() + + return files diff --git a/Chapter13/ABQ_Data_Entry/abq_data_entry/test/__init__.py b/Chapter13/ABQ_Data_Entry/abq_data_entry/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Chapter13/ABQ_Data_Entry/abq_data_entry/test/test_application.py b/Chapter13/ABQ_Data_Entry/abq_data_entry/test/test_application.py new file mode 100644 index 0000000..73cc7b4 --- /dev/null +++ b/Chapter13/ABQ_Data_Entry/abq_data_entry/test/test_application.py @@ -0,0 +1,72 @@ +from unittest import TestCase +from unittest.mock import patch +from .. import application + + +class TestApplication(TestCase): + records = [ + {'Date': '2018-06-01', 'Time': '8:00', 'Technician': 'J Simms', + 'Lab': 'A', 'Plot': '1', 'Seed Sample': 'AX477', + 'Humidity': '24.09', 'Light': '1.03', 'Temperature': '22.01', + 'Equipment Fault': False, 'Plants': '9', 'Blossoms': '21', + 'Fruit': '3', 'Max Height': '8.7', 'Med Height': '2.73', + 'Min Height': '1.67', 'Notes': '\n\n', + }, + {'Date': '2018-06-01', 'Time': '8:00', 'Technician': 'J Simms', + 'Lab': 'A', 'Plot': '2', 'Seed Sample': 'AX478', + 'Humidity': '24.47', 'Light': '1.01', 'Temperature': '21.44', + 'Equipment Fault': False, 'Plants': '14', 'Blossoms': '27', + 'Fruit': '1', 'Max Height': '9.2', 'Med Height': '5.09', + 'Min Height': '2.35', 'Notes': '' + } + ] + + settings = { + 'autofill date': {'type': 'bool', 'value': True}, + 'autofill sheet data': {'type': 'bool', 'value': True}, + 'font size': {'type': 'int', 'value': 9}, + 'font family': {'type': 'str', 'value': ''}, + 'theme': {'type': 'str', 'value': 'default'} + } + + def setUp(self): + # can be parenthesized in python 3.10+ + with \ + patch('abq_data_entry.application.m.CSVModel') as csvmodel,\ + patch('abq_data_entry.application.m.SettingsModel') as settingsmodel,\ + patch('abq_data_entry.application.Application._show_login') as show_login,\ + patch('abq_data_entry.application.v.DataRecordForm'),\ + patch('abq_data_entry.application.v.RecordList'),\ + patch('abq_data_entry.application.ttk.Notebook'),\ + patch('abq_data_entry.application.get_main_menu_for_os')\ + : + + settingsmodel().fields = self.settings + csvmodel().get_all_records.return_value = self.records + show_login.return_value = True + self.app = application.Application() + + def tearDown(self): + self.app.update() + self.app.destroy() + + def test_show_recordlist(self): + self.app._show_recordlist() + self.app.update() + self.app.notebook.select.assert_called_with(self.app.recordlist) + + def test_populate_recordlist(self): + # test correct functions + self.app._populate_recordlist() + self.app.model.get_all_records.assert_called() + self.app.recordlist.populate.assert_called_with(self.records) + + # test exceptions + + self.app.model.get_all_records.side_effect = Exception('Test message') + with patch('abq_data_entry.application.messagebox'): + self.app._populate_recordlist() + application.messagebox.showerror.assert_called_with( + title='Error', message='Problem reading file', + detail='Test message' + ) diff --git a/Chapter13/ABQ_Data_Entry/abq_data_entry/test/test_models.py b/Chapter13/ABQ_Data_Entry/abq_data_entry/test/test_models.py new file mode 100644 index 0000000..0cb1baf --- /dev/null +++ b/Chapter13/ABQ_Data_Entry/abq_data_entry/test/test_models.py @@ -0,0 +1,137 @@ +from .. import models +from unittest import TestCase +from unittest import mock + +from pathlib import Path + +class TestCSVModel(TestCase): + + def setUp(self): + + self.file1_open = mock.mock_open( + read_data=( + "Date,Time,Technician,Lab,Plot,Seed Sample,Humidity,Light," + "Temperature,Equipment Fault,Plants,Blossoms,Fruit,Min Height," + "Max Height,Med Height,Notes\r\n" + "2021-06-01,8:00,J Simms,A,2,AX478,24.47,1.01,21.44,False,14," + "27,1,2.35,9.2,5.09,\r\n" + "2021-06-01,8:00,J Simms,A,3,AX479,24.15,1,20.82,False,18,49," + "6,2.47,14.2,11.83,\r\n")) + self.file2_open = mock.mock_open(read_data='') + + self.model1 = models.CSVModel('file1') + self.model2 = models.CSVModel('file2') + + @mock.patch('abq_data_entry.models.Path.exists') + def test_get_all_records(self, mock_path_exists): + mock_path_exists.return_value = True + + with mock.patch( + 'abq_data_entry.models.open', + self.file1_open + ): + records = self.model1.get_all_records() + + self.assertEqual(len(records), 2) + self.assertIsInstance(records, list) + self.assertIsInstance(records[0], dict) + + fields = ( + 'Date', 'Time', 'Technician', 'Lab', 'Plot', + 'Seed Sample', 'Humidity', 'Light', + 'Temperature', 'Equipment Fault', 'Plants', + 'Blossoms', 'Fruit', 'Min Height', 'Max Height', + 'Med Height', 'Notes') + + for field in fields: + self.assertIn(field, records[0].keys()) + + # testing boolean conversion + self.assertFalse(records[0]['Equipment Fault']) + + self.file1_open.assert_called_with( + Path('file1'), 'r', encoding='utf-8' + ) + + @mock.patch('abq_data_entry.models.Path.exists') + def test_get_record(self, mock_path_exists): + mock_path_exists.return_value = True + + with mock.patch( + 'abq_data_entry.models.open', + self.file1_open + ): + record0 = self.model1.get_record(0) + record1 = self.model1.get_record(1) + + self.assertNotEqual(record0, record1) + self.assertEqual(record0['Date'], '2021-06-01') + self.assertEqual(record1['Plot'], '3') + self.assertEqual(record0['Med Height'], '5.09') + + @mock.patch('abq_data_entry.models.Path.exists') + def test_save_record(self, mock_path_exists): + + record = { + "Date": '2021-07-01', "Time": '12:00', + "Technician": 'Test Technician', "Lab": 'E', + "Plot": '17', "Seed Sample": 'test sample', + "Humidity": '10', "Light": '99', + "Temperature": '20', "Equipment Fault": False, + "Plants": '10', "Blossoms": '200', + "Fruit": '250', "Min Height": '40', + "Max Height": '50', "Med Height": '55', + "Notes": 'Test Note\r\nTest Note\r\n' + } + record_as_csv = ( + '2021-07-01,12:00,Test Technician,E,17,test sample,10,99,' + '20,False,10,200,250,40,50,55,"Test Note\r\nTest Note\r\n"' + '\r\n') + + # test appending a file + mock_path_exists.return_value = True + + # test insert + with mock.patch('abq_data_entry.models.open', self.file2_open): + self.model2.save_record(record, None) + self.file2_open.assert_called_with( + Path('file2'), 'a', encoding='utf-8' + ) + file2_handle = self.file2_open() + file2_handle.write.assert_called_with(record_as_csv) + + # test update + with mock.patch('abq_data_entry.models.open', self.file1_open): + self.model1.save_record(record, 1) + self.file1_open.assert_called_with( + Path('file1'), 'w', encoding='utf-8' + ) + file1_handle = self.file1_open() + file1_handle.write.assert_has_calls([ + mock.call( + 'Date,Time,Technician,Lab,Plot,Seed Sample,Humidity,Light,' + 'Temperature,Equipment Fault,Plants,Blossoms,Fruit,' + 'Min Height,Max Height,Med Height,Notes\r\n'), + mock.call( + '2021-06-01,8:00,J Simms,A,2,AX478,24.47,1.01,21.44,False,' + '14,27,1,2.35,9.2,5.09,\r\n'), + mock.call( + '2021-07-01,12:00,Test Technician,E,17,test sample,10,99,' + '20,False,10,200,250,40,50,55,"Test Note\r\nTest Note\r\n"' + '\r\n') + ]) + + # test new file + mock_path_exists.return_value = False + with mock.patch('abq_data_entry.models.open', self.file2_open): + self.model2.save_record(record, None) + file2_handle = self.file2_open() + file2_handle.write.assert_has_calls([ + mock.call( + 'Date,Time,Technician,Lab,Plot,Seed Sample,Humidity,Light,' + 'Temperature,Equipment Fault,Plants,Blossoms,Fruit,' + 'Min Height,Max Height,Med Height,Notes\r\n'), + mock.call(record_as_csv) + ]) + with self.assertRaises(IndexError): + self.model2.save_record(record, 2) diff --git a/Chapter13/ABQ_Data_Entry/abq_data_entry/test/test_widgets.py b/Chapter13/ABQ_Data_Entry/abq_data_entry/test/test_widgets.py new file mode 100644 index 0000000..e796eb8 --- /dev/null +++ b/Chapter13/ABQ_Data_Entry/abq_data_entry/test/test_widgets.py @@ -0,0 +1,219 @@ +from .. import widgets +from unittest import TestCase +from unittest.mock import Mock +import tkinter as tk +from tkinter import ttk + + +class TkTestCase(TestCase): + """A test case designed for Tkinter widgets and views""" + + keysyms = { + '-': 'minus', + ' ': 'space', + ':': 'colon', + # For more see http://www.tcl.tk/man/tcl8.4/TkCmd/keysyms.htm + } + @classmethod + def setUpClass(cls): + cls.root = tk.Tk() + cls.root.wait_visibility() + + @classmethod + def tearDownClass(cls): + cls.root.update() + cls.root.destroy() + + def type_in_widget(self, widget, string): + widget.focus_force() + for char in string: + char = self.keysyms.get(char, char) + self.root.update() + widget.event_generate(''.format(char)) + self.root.update() + + def click_on_widget(self, widget, x, y, button=1): + widget.focus_force() + self.root.update() + widget.event_generate("".format(button), x=x, y=y) + self.root.update() + + +class TestValidatedMixin(TkTestCase): + + def setUp(self): + class TestClass(widgets.ValidatedMixin, ttk.Entry): + pass + self.vw1 = TestClass(self.root) + + def assertEndsWith(self, text, ending): + if not text.endswith(ending): + raise AssertionError( + "'{}' does not end with '{}'".format(text, ending) + ) + + def test_init(self): + + # check error var setup + self.assertIsInstance(self.vw1.error, tk.StringVar) + + # check validation config + self.assertEqual(self.vw1.cget('validate'), 'all') + self.assertEndsWith( + self.vw1.cget('validatecommand'), + '%P %s %S %V %i %d' + ) + self.assertEndsWith( + self.vw1.cget('invalidcommand'), + '%P %s %S %V %i %d' + ) + + def test__validate(self): + + # by default, _validate should return true + args = { + 'proposed': 'abc', + 'current': 'ab', + 'char': 'c', + 'event': 'key', + 'index': '2', + 'action': '1' + } + # test key validate routing + self.assertTrue( + self.vw1._validate(**args) + ) + fake_key_val = Mock(return_value=False) + self.vw1._key_validate = fake_key_val + self.assertFalse( + self.vw1._validate(**args) + ) + fake_key_val.assert_called_with(**args) + + # test focusout validate routing + args['event'] = 'focusout' + self.assertTrue(self.vw1._validate(**args)) + fake_focusout_val = Mock(return_value=False) + self.vw1._focusout_validate = fake_focusout_val + self.assertFalse(self.vw1._validate(**args)) + fake_focusout_val.assert_called_with(event='focusout') + + + def test_trigger_focusout_validation(self): + + fake_focusout_val = Mock(return_value=False) + self.vw1._focusout_validate = fake_focusout_val + fake_focusout_invalid = Mock() + self.vw1._focusout_invalid = fake_focusout_invalid + + val = self.vw1.trigger_focusout_validation() + self.assertFalse(val) + fake_focusout_val.assert_called_with(event='focusout') + fake_focusout_invalid.assert_called_with(event='focusout') + + +class TestValidatedSpinbox(TkTestCase): + + def setUp(self): + self.value = tk.DoubleVar() + self.vsb = widgets.ValidatedSpinbox( + self.root, + textvariable=self.value, + from_=-10, to=10, increment=1 + ) + self.vsb.pack() + self.vsb.wait_visibility() + + def tearDown(self): + self.vsb.destroy() + + def key_validate(self, new, current=''): + return self.vsb._key_validate( + new, # inserted char + 'end', # position to insert + current, # current value + current + new, # proposed value + '1' # action code (1 == insert) + ) + + def click_arrow(self, arrow='inc', times=1): + x = self.vsb.winfo_width() - 5 + y = 5 if arrow == 'inc' else 15 + for _ in range(times): + self.click_on_widget(self.vsb, x=x, y=y) + + def test__key_validate(self): + ################### + # Unit-test Style # + ################### + + # test valid input + for x in range(10): + x = str(x) + p_valid = self.vsb._key_validate(x, 'end', '', x, '1') + n_valid = self.vsb._key_validate(x, 'end', '-', '-' + x, '1') + self.assertTrue(p_valid) + self.assertTrue(n_valid) + + # test letters + valid = self.key_validate('a') + self.assertFalse(valid) + + # test non-increment number + valid = self.key_validate('1', '0.') + self.assertFalse(valid) + + # test too high number + valid = self.key_validate('0', '10') + self.assertFalse(valid) + + def test__key_validate_integration(self): + ########################## + # Integration test style # + ########################## + + self.vsb.delete(0, 'end') + self.type_in_widget(self.vsb, '10') + self.assertEqual(self.vsb.get(), '10') + + self.vsb.delete(0, 'end') + self.type_in_widget(self.vsb, 'abcdef') + self.assertEqual(self.vsb.get(), '') + + self.vsb.delete(0, 'end') + self.type_in_widget(self.vsb, '200') + self.assertEqual(self.vsb.get(), '2') + + def test__focusout_validate(self): + + # test valid + for x in range(10): + self.value.set(x) + posvalid = self.vsb._focusout_validate() + self.value.set(-x) + negvalid = self.vsb._focusout_validate() + + self.assertTrue(posvalid) + self.assertTrue(negvalid) + + # test too low + self.value.set('-200') + valid = self.vsb._focusout_validate() + self.assertFalse(valid) + + # test invalid number + self.vsb.delete(0, 'end') + self.vsb.insert('end', '-a2-.3') + valid = self.vsb._focusout_validate() + self.assertFalse(valid) + + def test_arrows(self): + self.value.set(0) + self.click_arrow(times=1) + self.assertEqual(self.vsb.get(), '1') + + self.click_arrow(times=5) + self.assertEqual(self.vsb.get(), '6') + + self.click_arrow(arrow='dec', times=1) + self.assertEqual(self.vsb.get(), '5') diff --git a/Chapter13/ABQ_Data_Entry/abq_data_entry/views.py b/Chapter13/ABQ_Data_Entry/abq_data_entry/views.py new file mode 100644 index 0000000..0faa717 --- /dev/null +++ b/Chapter13/ABQ_Data_Entry/abq_data_entry/views.py @@ -0,0 +1,556 @@ +import tkinter as tk +from tkinter import ttk +from tkinter.simpledialog import Dialog +from datetime import datetime +from . import widgets as w +from .constants import FieldTypes as FT +from . import images + +class DataRecordForm(tk.Frame): + """The input form for our widgets""" + + var_types = { + FT.string: tk.StringVar, + FT.string_list: tk.StringVar, + FT.short_string_list: tk.StringVar, + FT.iso_date_string: tk.StringVar, + FT.long_string: tk.StringVar, + FT.decimal: tk.DoubleVar, + FT.integer: tk.IntVar, + FT.boolean: tk.BooleanVar + } + + def _add_frame(self, label, style='', cols=3): + """Add a labelframe to the form""" + + frame = ttk.LabelFrame(self, text=label) + if style: + frame.configure(style=style) + frame.grid(sticky=tk.W + tk.E) + for i in range(cols): + frame.columnconfigure(i, weight=1) + return frame + + def __init__(self, parent, model, settings, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + + self.model= model + self.settings = settings + fields = self.model.fields + + # new for ch9 + style = ttk.Style() + + # Frame styles + style.configure( + 'RecordInfo.TLabelframe', + background='khaki', padx=10, pady=10 + ) + style.configure( + 'EnvironmentInfo.TLabelframe', background='lightblue', + padx=10, pady=10 + ) + style.configure( + 'PlantInfo.TLabelframe', + background='lightgreen', padx=10, pady=10 + ) + # Style the label Element as well + style.configure( + 'RecordInfo.TLabelframe.Label', background='khaki', + padx=10, pady=10 + ) + style.configure( + 'EnvironmentInfo.TLabelframe.Label', + background='lightblue', padx=10, pady=10 + ) + style.configure( + 'PlantInfo.TLabelframe.Label', + background='lightgreen', padx=10, pady=10 + ) + + # Style for the form labels and buttons + style.configure('RecordInfo.TLabel', background='khaki') + style.configure('RecordInfo.TRadiobutton', background='khaki') + style.configure('EnvironmentInfo.TLabel', background='lightblue') + style.configure( + 'EnvironmentInfo.TCheckbutton', + background='lightblue' + ) + style.configure('PlantInfo.TLabel', background='lightgreen') + + + # Create a dict to keep track of input widgets + self._vars = { + key: self.var_types[spec['type']]() + for key, spec in fields.items() + } + + # Build the form + self.columnconfigure(0, weight=1) + + # new chapter 8 + # variable to track current record id + self.current_record = None + + # Label for displaying what record we're editing + self.record_label = ttk.Label(self) + self.record_label.grid(row=0, column=0) + + # Record info section + r_info = self._add_frame( + "Record Information", 'RecordInfo.TLabelframe' + ) + + # line 1 + w.LabelInput( + r_info, "Date", + field_spec=fields['Date'], + var=self._vars['Date'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=0, column=0) + w.LabelInput( + r_info, "Time", + field_spec=fields['Time'], + var=self._vars['Time'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=0, column=1) + w.LabelInput( + r_info, "Lab", + field_spec=fields['Lab'], + var=self._vars['Lab'], + label_args={'style': 'RecordInfo.TLabel'}, + input_args={'style': 'RecordInfo.TRadiobutton'} + ).grid(row=0, column=2) + # line 2 + w.LabelInput( + r_info, "Plot", + field_spec=fields['Plot'], + var=self._vars['Plot'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=1, column=0) + w.LabelInput( + r_info, "Technician", + field_spec=fields['Technician'], + var=self._vars['Technician'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=1, column=1) + w.LabelInput( + r_info, "Seed Sample", + field_spec=fields['Seed Sample'], + var=self._vars['Seed Sample'], + label_args={'style': 'RecordInfo.TLabel'} + ).grid(row=1, column=2) + + + # Environment Data + e_info = self._add_frame( + "Environment Data", 'EnvironmentInfo.TLabelframe' + ) + + e_info = ttk.LabelFrame( + self, + text="Environment Data", + style='EnvironmentInfo.TLabelframe' + ) + e_info.grid(row=2, column=0, sticky="we") + w.LabelInput( + e_info, "Humidity (g/m³)", + field_spec=fields['Humidity'], + var=self._vars['Humidity'], + disable_var=self._vars['Equipment Fault'], + label_args={'style': 'EnvironmentInfo.TLabel'} + ).grid(row=0, column=0) + w.LabelInput( + e_info, "Light (klx)", + field_spec=fields['Light'], + var=self._vars['Light'], + disable_var=self._vars['Equipment Fault'], + label_args={'style': 'EnvironmentInfo.TLabel'} + ).grid(row=0, column=1) + w.LabelInput( + e_info, "Temperature (°C)", + field_spec=fields['Temperature'], + disable_var=self._vars['Equipment Fault'], + var=self._vars['Temperature'], + label_args={'style': 'EnvironmentInfo.TLabel'} + ).grid(row=0, column=2) + w.LabelInput( + e_info, "Equipment Fault", + field_spec=fields['Equipment Fault'], + var=self._vars['Equipment Fault'], + label_args={'style': 'EnvironmentInfo.TLabel'}, + input_args={'style': 'EnvironmentInfo.TCheckbutton'} + ).grid(row=1, column=0, columnspan=3) + + # Plant Data section + p_info = self._add_frame("Plant Data", 'PlantInfo.TLabelframe') + + w.LabelInput( + p_info, "Plants", + field_spec=fields['Plants'], + var=self._vars['Plants'], + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=0, column=0) + w.LabelInput( + p_info, "Blossoms", + field_spec=fields['Blossoms'], + var=self._vars['Blossoms'], + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=0, column=1) + w.LabelInput( + p_info, "Fruit", + field_spec=fields['Fruit'], + var=self._vars['Fruit'], + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=0, column=2) + # Height data + # create variables to be updated for min/max height + # they can be referenced for min/max variables + min_height_var = tk.DoubleVar(value='-infinity') + max_height_var = tk.DoubleVar(value='infinity') + + w.LabelInput( + p_info, "Min Height (cm)", + field_spec=fields['Min Height'], + var=self._vars['Min Height'], + input_args={"max_var": max_height_var, + "focus_update_var": min_height_var}, + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=1, column=0) + w.LabelInput( + p_info, "Max Height (cm)", + field_spec=fields['Max Height'], + var=self._vars['Max Height'], + input_args={"min_var": min_height_var, + "focus_update_var": max_height_var}, + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=1, column=1) + w.LabelInput( + p_info, "Median Height (cm)", + field_spec=fields['Med Height'], + var=self._vars['Med Height'], + input_args={"min_var": min_height_var, + "max_var": max_height_var}, + label_args={'style': 'PlantInfo.TLabel'} + ).grid(row=1, column=2) + + + # Notes section -- Update grid row value for ch8 + w.LabelInput( + self, "Notes", field_spec=fields['Notes'], + var=self._vars['Notes'], input_args={"width": 85, "height": 10} + ).grid(sticky="nsew", row=4, column=0, padx=10, pady=10) + + # buttons + buttons = tk.Frame(self) + buttons.grid(sticky=tk.W + tk.E, row=5) + self.save_button_logo = tk.PhotoImage(file=images.SAVE_ICON) + self.savebutton = ttk.Button( + buttons, text="Save", command=self._on_save, + image=self.save_button_logo, compound=tk.LEFT + ) + self.savebutton.pack(side=tk.RIGHT) + + self.reset_button_logo = tk.PhotoImage(file=images.RESET_ICON) + self.resetbutton = ttk.Button( + buttons, text="Reset", command=self.reset, + image=self.reset_button_logo, compound=tk.LEFT + ) + self.resetbutton.pack(side=tk.RIGHT) + + # new for ch12 + # Triggers + for field in ('Lab', 'Plot'): + self._vars[field].trace_add( + 'write', self._populate_current_seed_sample) + + for field in ('Date', 'Time', 'Lab'): + self._vars[field].trace_add( + 'write', self._populate_tech_for_lab_check) + + # default the form + self.reset() + + def _on_save(self): + self.event_generate('<>') + + @staticmethod + def tclerror_is_blank_value(exception): + blank_value_errors = ( + 'expected integer but got ""', + 'expected floating-point number but got ""', + 'expected boolean value but got ""' + ) + is_bve = str(exception).strip() in blank_value_errors + return is_bve + + def get(self): + """Retrieve data from form as a dict""" + + # We need to retrieve the data from Tkinter variables + # and place it in regular Python objects + data = dict() + for key, var in self._vars.items(): + try: + data[key] = var.get() + except tk.TclError as e: + if self.tclerror_is_blank_value(e): + data[key] = None + else: + raise e + return data + + def reset(self): + """Resets the form entries""" + + lab = self._vars['Lab'].get() + time = self._vars['Time'].get() + technician = self._vars['Technician'].get() + try: + plot = self._vars['Plot'].get() + except tk.TclError: + plot = '' + plot_values = self._vars['Plot'].label_widget.input.cget('values') + + # clear all values + for var in self._vars.values(): + if isinstance(var, tk.BooleanVar): + var.set(False) + else: + var.set('') + + # Autofill Date + if self.settings['autofill date'].get(): + current_date = datetime.today().strftime('%Y-%m-%d') + self._vars['Date'].set(current_date) + self._vars['Time'].label_widget.input.focus() + + # check if we need to put our values back, then do it. + if ( + self.settings['autofill sheet data'].get() and + plot not in ('', 0, plot_values[-1]) + ): + self._vars['Lab'].set(lab) + self._vars['Time'].set(time) + self._vars['Technician'].set(technician) + next_plot_index = plot_values.index(plot) + 1 + self._vars['Plot'].set(plot_values[next_plot_index]) + self._vars['Seed Sample'].label_widget.input.focus() + + def get_errors(self): + """Get a list of field errors in the form""" + + errors = dict() + for key, var in self._vars.items(): + inp = var.label_widget.input + error = var.label_widget.error + + if hasattr(inp, 'trigger_focusout_validation'): + inp.trigger_focusout_validation() + if error.get(): + errors[key] = error.get() + + return errors + + # rewrite for ch12 + def load_record(self, rowkey, data=None): + """Load a record's data into the form""" + self.current_record = rowkey + if rowkey is None: + self.reset() + self.record_label.config(text='New Record') + else: + date, time, lab, plot = rowkey + title = f'Record for Lab {lab}, Plot {plot} at {date} {time}' + self.record_label.config(text=title) + for key, var in self._vars.items(): + var.set(data.get(key, '')) + try: + var.label_widget.input.trigger_focusout_validation() + except AttributeError: + pass + + # new for ch12 + + def _populate_current_seed_sample(self, *_): + """Auto-populate the current seed sample for Lab and Plot""" + if not self.settings['autofill sheet data'].get(): + return + plot = self._vars['Plot'].get() + lab = self._vars['Lab'].get() + + if plot and lab: + seed = self.model.get_current_seed_sample(lab, plot) + self._vars['Seed Sample'].set(seed) + + def _populate_tech_for_lab_check(self, *_): + """Populate technician based on the current lab check""" + if not self.settings['autofill sheet data'].get(): + return + date = self._vars['Date'].get() + try: + datetime.fromisoformat(date) + except ValueError: + return + time = self._vars['Time'].get() + lab = self._vars['Lab'].get() + + if all([date, time, lab]): + check = self.model.get_lab_check(date, time, lab) + tech = check['lab_tech'] if check else '' + self._vars['Technician'].set(tech) + + +class LoginDialog(Dialog): + """A dialog that asks for username and password""" + + def __init__(self, parent, title, error=''): + + self._pw = tk.StringVar() + self._user = tk.StringVar() + self._error = tk.StringVar(value=error) + super().__init__(parent, title=title) + + def body(self, frame): + """Construct the interface and return the widget for initial focus + + Overridden from Dialog + """ + ttk.Label(frame, text='Login to ABQ').grid(row=0) + + if self._error.get(): + ttk.Label(frame, textvariable=self._error).grid(row=1) + user_inp = w.LabelInput( + frame, 'User name:', input_class=w.RequiredEntry, + var=self._user + ) + user_inp.grid() + w.LabelInput( + frame, 'Password:', input_class=w.RequiredEntry, + input_args={'show': '*'}, var=self._pw + ).grid() + return user_inp.input + + def buttonbox(self): + box = ttk.Frame(self) + ttk.Button( + box, text="Login", command=self.ok, default=tk.ACTIVE + ).grid(padx=5, pady=5) + ttk.Button( + box, text="Cancel", command=self.cancel + ).grid(row=0, column=1, padx=5, pady=5) + self.bind("", self.ok) + self.bind("", self.cancel) + box.pack() + + + def apply(self): + self.result = (self._user.get(), self._pw.get()) + + +class RecordList(tk.Frame): + """Display for CSV file contents""" + + column_defs = { + '#0': {'label': 'Row', 'anchor': tk.W}, + 'Date': {'label': 'Date', 'width': 150, 'stretch': True}, + 'Time': {'label': 'Time'}, + 'Lab': {'label': 'Lab', 'width': 40}, + 'Plot': {'label': 'Plot', 'width': 80} + } + default_width = 100 + default_minwidth = 10 + default_anchor = tk.CENTER + + def __init__(self, parent, inserted, updated, *args, **kwargs): + super().__init__(parent, *args, **kwargs) + self.inserted = inserted + self.updated = updated + self.columnconfigure(0, weight=1) + self.rowconfigure(0, weight=1) + + # create treeview + self.treeview = ttk.Treeview( + self, + columns=list(self.column_defs.keys())[1:], + selectmode='browse' + ) + self.treeview.grid(row=0, column=0, sticky='NSEW') + + # Configure treeview columns + for name, definition in self.column_defs.items(): + label = definition.get('label', '') + anchor = definition.get('anchor', self.default_anchor) + minwidth = definition.get('minwidth', self.default_minwidth) + width = definition.get('width', self.default_width) + stretch = definition.get('stretch', False) + self.treeview.heading(name, text=label, anchor=anchor) + self.treeview.column( + name, anchor=anchor, minwidth=minwidth, + width=width, stretch=stretch + ) + + self.treeview.bind('', self._on_open_record) + self.treeview.bind('', self._on_open_record) + + # configure scrollbar for the treeview + self.scrollbar = ttk.Scrollbar( + self, + orient=tk.VERTICAL, + command=self.treeview.yview + ) + self.treeview.configure(yscrollcommand=self.scrollbar.set) + self.scrollbar.grid(row=0, column=1, sticky='NSW') + + # configure tagging + self.treeview.tag_configure('inserted', background='lightgreen') + self.treeview.tag_configure('updated', background='lightblue') + + # For ch12, hide first column since row # is no longer meaningful + self.treeview.config(show='headings') + + # new for ch12 + + @staticmethod + def _rowkey_to_iid(rowkey): + """Convert a rowkey tuple to an IID string""" + return '{}|{}|{}|{}'.format(*rowkey) + + @staticmethod + def _iid_to_rowkey(iid): + """Convert an IID string to a rowkey tuple""" + return tuple(iid.split("|")) + + # update for ch12 + def populate(self, rows): + """Clear the treeview and write the supplied data rows to it.""" + + for row in self.treeview.get_children(): + self.treeview.delete(row) + + cids = list(self.column_defs.keys())[1:] + for rowdata in rows: + values = [rowdata[key] for key in cids] + rowkey = tuple([str(v) for v in values]) + if rowkey in self.inserted: + tag = 'inserted' + elif rowkey in self.updated: + tag = 'updated' + else: + tag = '' + iid = self._rowkey_to_iid(rowkey) + self.treeview.insert( + '', 'end', iid=iid, text=iid, values=values, tag=tag) + + if len(rows) > 0: + firstrow = self.treeview.identify_row(0) + self.treeview.focus_set() + self.treeview.selection_set(firstrow) + self.treeview.focus(firstrow) + + # update for ch12 + def _on_open_record(self, *_): + """Handle record open request""" + selected_iid = self.treeview.selection()[0] + self.selected_id = self._iid_to_rowkey(selected_iid) + self.event_generate('<>') diff --git a/Chapter13/ABQ_Data_Entry/abq_data_entry/widgets.py b/Chapter13/ABQ_Data_Entry/abq_data_entry/widgets.py new file mode 100644 index 0000000..ec72ed4 --- /dev/null +++ b/Chapter13/ABQ_Data_Entry/abq_data_entry/widgets.py @@ -0,0 +1,441 @@ +import tkinter as tk +from tkinter import ttk +from datetime import datetime +from decimal import Decimal, InvalidOperation +from .constants import FieldTypes as FT + + +################## +# Widget Classes # +################## + +class ValidatedMixin: + """Adds a validation functionality to an input widget""" + + def __init__(self, *args, error_var=None, **kwargs): + self.error = error_var or tk.StringVar() + super().__init__(*args, **kwargs) + + vcmd = self.register(self._validate) + invcmd = self.register(self._invalid) + + style = ttk.Style() + widget_class = self.winfo_class() + validated_style = 'ValidatedInput.' + widget_class + style.map( + validated_style, + foreground=[('invalid', 'white'), ('!invalid', 'black')], + fieldbackground=[('invalid', 'darkred'), ('!invalid', 'white')] + ) + self.configure(style=validated_style) + + self.configure( + validate='all', + validatecommand=(vcmd, '%P', '%s', '%S', '%V', '%i', '%d'), + invalidcommand=(invcmd, '%P', '%s', '%S', '%V', '%i', '%d') + ) + + def _toggle_error(self, on=False): + self.configure(foreground=('red' if on else 'black')) + + def _validate(self, proposed, current, char, event, index, action): + """The validation method. + + Don't override this, override _key_validate, and _focus_validate + """ + self.error.set('') + + valid = True + # if the widget is disabled, don't validate + state = str(self.configure('state')[-1]) + if state == tk.DISABLED: + return valid + + if event == 'focusout': + valid = self._focusout_validate(event=event) + elif event == 'key': + valid = self._key_validate( + proposed=proposed, + current=current, + char=char, + event=event, + index=index, + action=action + ) + return valid + + def _focusout_validate(self, **kwargs): + return True + + def _key_validate(self, **kwargs): + return True + + def _invalid(self, proposed, current, char, event, index, action): + if event == 'focusout': + self._focusout_invalid(event=event) + elif event == 'key': + self._key_invalid( + proposed=proposed, + current=current, + char=char, + event=event, + index=index, + action=action + ) + + def _focusout_invalid(self, **kwargs): + """Handle invalid data on a focus event""" + pass + + def _key_invalid(self, **kwargs): + """Handle invalid data on a key event. By default we want to do nothing""" + pass + + def trigger_focusout_validation(self): + valid = self._validate('', '', '', 'focusout', '', '') + if not valid: + self._focusout_invalid(event='focusout') + return valid + + +class DateEntry(ValidatedMixin, ttk.Entry): + + def _key_validate(self, action, index, char, **kwargs): + valid = True + + if action == '0': # This is a delete action + valid = True + elif index in ('0', '1', '2', '3', '5', '6', '8', '9'): + valid = char.isdigit() + elif index in ('4', '7'): + valid = char == '-' + else: + valid = False + return valid + + def _focusout_validate(self, event): + valid = True + if not self.get(): + self.error.set('A value is required') + valid = False + try: + datetime.strptime(self.get(), '%Y-%m-%d') + except ValueError: + self.error.set('Invalid date') + valid = False + return valid + + +class RequiredEntry(ValidatedMixin, ttk.Entry): + + def _focusout_validate(self, event): + valid = True + if not self.get(): + valid = False + self.error.set('A value is required') + return valid + + +class ValidatedCombobox(ValidatedMixin, ttk.Combobox): + + def _key_validate(self, proposed, action, **kwargs): + valid = True + # if the user tries to delete, + # just clear the field + if action == '0': + self.set('') + return True + + # get our values list + values = self.cget('values') + # Do a case-insensitve match against the entered text + matching = [ + x for x in values + if x.lower().startswith(proposed.lower()) + ] + if len(matching) == 0: + valid = False + elif len(matching) == 1: + self.set(matching[0]) + self.icursor(tk.END) + valid = False + return valid + + def _focusout_validate(self, **kwargs): + valid = True + if not self.get(): + valid = False + self.error.set('A value is required') + return valid + + +class ValidatedSpinbox(ValidatedMixin, ttk.Spinbox): + """A Spinbox that only accepts Numbers""" + + def __init__(self, *args, min_var=None, max_var=None, + focus_update_var=None, from_='-Infinity', to='Infinity', **kwargs + ): + super().__init__(*args, from_=from_, to=to, **kwargs) + increment = Decimal(str(kwargs.get('increment', '1.0'))) + self.precision = increment.normalize().as_tuple().exponent + # there should always be a variable, + # or some of our code will fail + self.variable = kwargs.get('textvariable') + if not self.variable: + self.variable = tk.DoubleVar() + self.configure(textvariable=self.variable) + + if min_var: + self.min_var = min_var + self.min_var.trace_add('write', self._set_minimum) + if max_var: + self.max_var = max_var + self.max_var.trace_add('write', self._set_maximum) + self.focus_update_var = focus_update_var + self.bind('', self._set_focus_update_var) + + def _set_focus_update_var(self, event): + value = self.get() + if self.focus_update_var and not self.error.get(): + self.focus_update_var.set(value) + + def _set_minimum(self, *_): + current = self.get() + try: + new_min = self.min_var.get() + self.config(from_=new_min) + except (tk.TclError, ValueError): + pass + if not current: + self.delete(0, tk.END) + else: + self.variable.set(current) + self.trigger_focusout_validation() + + def _set_maximum(self, *_): + current = self.get() + try: + new_max = self.max_var.get() + self.config(to=new_max) + except (tk.TclError, ValueError): + pass + if not current: + self.delete(0, tk.END) + else: + self.variable.set(current) + self.trigger_focusout_validation() + + def _key_validate( + self, char, index, current, proposed, action, **kwargs + ): + if action == '0': + return True + valid = True + min_val = self.cget('from') + max_val = self.cget('to') + no_negative = min_val >= 0 + no_decimal = self.precision >= 0 + + # First, filter out obviously invalid keystrokes + if any([ + (char not in '-1234567890.'), + (char == '-' and (no_negative or index != '0')), + (char == '.' and (no_decimal or '.' in current)) + ]): + return False + + # At this point, proposed is either '-', '.', '-.', + # or a valid Decimal string + if proposed in '-.': + return True + + # Proposed is a valid Decimal string + # convert to Decimal and check more: + proposed = Decimal(proposed) + proposed_precision = proposed.as_tuple().exponent + + if any([ + (proposed > max_val), + (proposed_precision < self.precision) + ]): + return False + + return valid + + def _focusout_validate(self, **kwargs): + valid = True + value = self.get() + min_val = self.cget('from') + max_val = self.cget('to') + + try: + d_value = Decimal(value) + except InvalidOperation: + self.error.set(f'Invalid number string: {value}') + return False + + if d_value < min_val: + self.error.set(f'Value is too low (min {min_val})') + valid = False + if d_value > max_val: + self.error.set(f'Value is too high (max {max_val})') + valid = False + + return valid + +class ValidatedRadioGroup(ttk.Frame): + """A validated radio button group""" + + def __init__( + self, *args, variable=None, error_var=None, + values=None, button_args=None, **kwargs + ): + super().__init__(*args, **kwargs) + self.variable = variable or tk.StringVar() + self.error = error_var or tk.StringVar() + self.values = values or list() + button_args = button_args or dict() + + for v in self.values: + button = ttk.Radiobutton( + self, value=v, text=v, variable=self.variable, **button_args + ) + button.pack(side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x') + self.bind('', self.trigger_focusout_validation) + + def trigger_focusout_validation(self, *_): + self.error.set('') + if not self.variable.get(): + self.error.set('A value is required') + + +class BoundText(tk.Text): + """A Text widget with a bound variable.""" + + def __init__(self, *args, textvariable=None, **kwargs): + super().__init__(*args, **kwargs) + self._variable = textvariable + if self._variable: + # insert any default value + self.insert('1.0', self._variable.get()) + self._variable.trace_add('write', self._set_content) + self.bind('<>', self._set_var) + + def _set_var(self, *_): + """Set the variable to the text contents""" + if self.edit_modified(): + content = self.get('1.0', 'end-1chars') + self._variable.set(content) + self.edit_modified(False) + + def _set_content(self, *_): + """Set the text contents to the variable""" + self.delete('1.0', tk.END) + self.insert('1.0', self._variable.get()) + + +########################### +# Compound Widget Classes # +########################### + + +class LabelInput(ttk.Frame): + """A widget containing a label and input together.""" + + field_types = { + FT.string: RequiredEntry, + FT.string_list: ValidatedCombobox, + FT.short_string_list: ValidatedRadioGroup, + FT.iso_date_string: DateEntry, + FT.long_string: BoundText, + FT.decimal: ValidatedSpinbox, + FT.integer: ValidatedSpinbox, + FT.boolean: ttk.Checkbutton + } + + def __init__( + self, parent, label, var, input_class=None, + input_args=None, label_args=None, field_spec=None, + disable_var=None, **kwargs + ): + super().__init__(parent, **kwargs) + input_args = input_args or {} + label_args = label_args or {} + self.variable = var + self.variable.label_widget = self + + # Process the field spec to determine input_class and validation + if field_spec: + field_type = field_spec.get('type', FT.string) + input_class = input_class or self.field_types.get(field_type) + # min, max, increment + if 'min' in field_spec and 'from_' not in input_args: + input_args['from_'] = field_spec.get('min') + if 'max' in field_spec and 'to' not in input_args: + input_args['to'] = field_spec.get('max') + if 'inc' in field_spec and 'increment' not in input_args: + input_args['increment'] = field_spec.get('inc') + # values + if 'values' in field_spec and 'values' not in input_args: + input_args['values'] = field_spec.get('values') + + # setup the label + if input_class in (ttk.Checkbutton, ttk.Button): + # Buttons don't need labels, they're built-in + input_args["text"] = label + else: + self.label = ttk.Label(self, text=label, **label_args) + self.label.grid(row=0, column=0, sticky=(tk.W + tk.E)) + + # setup the variable + if input_class in ( + ttk.Checkbutton, ttk.Button, ttk.Radiobutton, ValidatedRadioGroup + ): + input_args["variable"] = self.variable + else: + input_args["textvariable"] = self.variable + + # Setup the input + if input_class == ttk.Radiobutton: + # for Radiobutton, create one input per value + self.input = tk.Frame(self) + for v in input_args.pop('values', []): + button = input_class( + self.input, value=v, text=v, **input_args) + button.pack(side=tk.LEFT, ipadx=10, ipady=2, expand=True, fill='x') + self.input.error = getattr(button, 'error', None) + self.input.trigger_focusout_validation = \ + button._focusout_validate + else: + self.input = input_class(self, **input_args) + + self.input.grid(row=1, column=0, sticky=(tk.W + tk.E)) + self.columnconfigure(0, weight=1) + + # Set up error handling & display + error_style = 'Error.' + label_args.get('style', 'TLabel') + ttk.Style().configure(error_style, foreground='darkred') + self.error = getattr(self.input, 'error', tk.StringVar()) + ttk.Label(self, textvariable=self.error, style=error_style).grid( + row=2, column=0, sticky=(tk.W + tk.E) + ) + + # Set up disable variable + if disable_var: + self.disable_var = disable_var + self.disable_var.trace_add('write', self._check_disable) + + def _check_disable(self, *_): + if not hasattr(self, 'disable_var'): + return + + if self.disable_var.get(): + self.input.configure(state=tk.DISABLED) + self.variable.set('') + self.error.set('') + else: + self.input.configure(state=tk.NORMAL) + + def grid(self, sticky=(tk.E + tk.W), **kwargs): + """Override grid to add default sticky values""" + super().grid(sticky=sticky, **kwargs) diff --git a/Chapter13/ABQ_Data_Entry/docs/Application_layout.png b/Chapter13/ABQ_Data_Entry/docs/Application_layout.png new file mode 100644 index 0000000000000000000000000000000000000000..93990f232d19518ca465e7715bbf4f0f10cabce9 GIT binary patch literal 9117 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 mufwd~J*Rr~aor9m$$ITZW{ Date: Fri, 20 Aug 2021 15:09:44 -0500 Subject: [PATCH 32/32] Update chapter 11 per revisions --- .../ABQ_Data_Entry/abq_data_entry/models.py | 2 +- .../abq_data_entry/test/test_application.py | 13 +++++-- .../abq_data_entry/test/test_widgets.py | 26 ++++++------- Chapter11/unittest_demo/mycalc.py | 38 +++++++++---------- Chapter11/unittest_demo/test_mycalc_badly.py | 4 +- 5 files changed, 43 insertions(+), 40 deletions(-) diff --git a/Chapter11/ABQ_Data_Entry/abq_data_entry/models.py b/Chapter11/ABQ_Data_Entry/abq_data_entry/models.py index f723882..0a879f4 100644 --- a/Chapter11/ABQ_Data_Entry/abq_data_entry/models.py +++ b/Chapter11/ABQ_Data_Entry/abq_data_entry/models.py @@ -88,7 +88,7 @@ def get_all_records(self): with open(self.file, 'r', encoding='utf-8') as fh: # Casting to list is necessary for unit tests to work - csvreader = csv.DictReader(list(fh.readlines())) + csvreader = csv.DictReader(fh) missing_fields = set(self.fields.keys()) - set(csvreader.fieldnames) if len(missing_fields) > 0: fields_string = ', '.join(missing_fields) diff --git a/Chapter11/ABQ_Data_Entry/abq_data_entry/test/test_application.py b/Chapter11/ABQ_Data_Entry/abq_data_entry/test/test_application.py index 73cc7b4..5ac5ce3 100644 --- a/Chapter11/ABQ_Data_Entry/abq_data_entry/test/test_application.py +++ b/Chapter11/ABQ_Data_Entry/abq_data_entry/test/test_application.py @@ -32,9 +32,15 @@ class TestApplication(TestCase): def setUp(self): # can be parenthesized in python 3.10+ with \ - patch('abq_data_entry.application.m.CSVModel') as csvmodel,\ - patch('abq_data_entry.application.m.SettingsModel') as settingsmodel,\ - patch('abq_data_entry.application.Application._show_login') as show_login,\ + patch( + 'abq_data_entry.application.m.CSVModel' + ) as csvmodel,\ + patch( + 'abq_data_entry.application.m.SettingsModel' + ) as settingsmodel,\ + patch( + 'abq_data_entry.application.Application._show_login' + ) as show_login,\ patch('abq_data_entry.application.v.DataRecordForm'),\ patch('abq_data_entry.application.v.RecordList'),\ patch('abq_data_entry.application.ttk.Notebook'),\ @@ -52,7 +58,6 @@ def tearDown(self): def test_show_recordlist(self): self.app._show_recordlist() - self.app.update() self.app.notebook.select.assert_called_with(self.app.recordlist) def test_populate_recordlist(self): diff --git a/Chapter11/ABQ_Data_Entry/abq_data_entry/test/test_widgets.py b/Chapter11/ABQ_Data_Entry/abq_data_entry/test/test_widgets.py index 87173e3..35b66f2 100644 --- a/Chapter11/ABQ_Data_Entry/abq_data_entry/test/test_widgets.py +++ b/Chapter11/ABQ_Data_Entry/abq_data_entry/test/test_widgets.py @@ -28,15 +28,13 @@ def type_in_widget(self, widget, string): widget.focus_force() for char in string: char = self.keysyms.get(char, char) - self.root.update() - widget.event_generate(''.format(char)) - self.root.update() + widget.event_generate(f'') + self.root.update_idletasks() def click_on_widget(self, widget, x, y, button=1): widget.focus_force() - self.root.update() - widget.event_generate("".format(button), x=x, y=y) - self.root.update() + widget.event_generate(f'', x=x, y=y) + self.root.update_idletasks() @staticmethod def find_element(widget, element): @@ -47,7 +45,7 @@ def find_element(widget, element): for x in x_coords: for y in y_coords: if widget.identify(x, y) == element: - return (x, y) + return (x + 1, y + 1) raise Exception(f'{element} was not found in widget') @@ -166,11 +164,10 @@ def click_arrow(self, arrow, times=1): for _ in range(times): self.click_on_widget(self.vsb, x=x, y=y) - def test__key_validate(self): + def test_key_validate(self): ################### # Unit-test Style # ################### - # test valid input for x in range(10): x = str(x) @@ -179,19 +176,19 @@ def test__key_validate(self): self.assertTrue(p_valid) self.assertTrue(n_valid) - # test letters + def test_key_validate_letters(self): valid = self.key_validate('a') self.assertFalse(valid) - # test non-increment number + def test_key_validate_increment(self): valid = self.key_validate('1', '0.') self.assertFalse(valid) - # test too high number + def test_key_validate_high(self): valid = self.key_validate('0', '10') self.assertFalse(valid) - def test__key_validate_integration(self): + def test_key_validate_integration(self): ########################## # Integration test style # ########################## @@ -208,7 +205,7 @@ def test__key_validate_integration(self): self.type_in_widget(self.vsb, '200') self.assertEqual(self.vsb.get(), '2') - def test__focusout_validate(self): + def test_focusout_validate(self): # test valid for x in range(10): @@ -233,6 +230,7 @@ def test__focusout_validate(self): def test_arrows(self): self.value.set(0) + self.vsb.update() self.click_arrow('up', times=1) self.assertEqual(self.vsb.get(), '1') diff --git a/Chapter11/unittest_demo/mycalc.py b/Chapter11/unittest_demo/mycalc.py index 21731c1..b76fba2 100644 --- a/Chapter11/unittest_demo/mycalc.py +++ b/Chapter11/unittest_demo/mycalc.py @@ -2,26 +2,26 @@ class MyCalc: - def __init__(self, a, b): - self.a = a - self.b = b + def __init__(self, a, b): + self.a = a + self.b = b - def add(self): - return self.a + self.b + def add(self): + return self.a + self.b - def mod_divide(self): - if self.b == 0: - raise ValueError("Cannot divide by zero") - return (int(self.a / self.b), self.a % self.b) + def mod_divide(self): + if self.b == 0: + raise ValueError("Cannot divide by zero") + return (int(self.a / self.b), self.a % self.b) - def mod_divide2(self): - if type(self.a) is not int or type(self.b) is not int: - raise ValueError("Method only valid for ints") - if self.b == 0: - raise ValueError("Cannot divide by zero") - return (self.a // self.b, self.a % self.b) + def mod_divide2(self): + if type(self.a) is not int or type(self.b) is not int: + raise ValueError("Method only valid for ints") + if self.b == 0: + raise ValueError("Cannot divide by zero") + return (self.a // self.b, self.a % self.b) - def rand_between(self): - return ( - (random.random() * abs(self.a - self.b)) + - min(self.a, self.b)) + def rand_between(self): + return ( + (random.random() * abs(self.a - self.b)) + + min(self.a, self.b)) diff --git a/Chapter11/unittest_demo/test_mycalc_badly.py b/Chapter11/unittest_demo/test_mycalc_badly.py index af254a8..c8c78de 100644 --- a/Chapter11/unittest_demo/test_mycalc_badly.py +++ b/Chapter11/unittest_demo/test_mycalc_badly.py @@ -1,11 +1,11 @@ -from mycalc import MyCalc +import mycalc import unittest class TestMyCalc(unittest.TestCase): def test_add(self): - mc = MyCalc(1, 10) + mc = mycalc.MyCalc(1, 10) assert mc.add() == 11 # much better error output