Tkinter app – data from remote API

source code is available on my GitHub

In this example, the data comes from the Orlen wholesale price archive.

Due to the fact that the way of downloading data from the website has changed, I also had to change the way my program works (the previous version used searching the website in order to find the appropriate values ​​- web scraping). Now it has become necessary to use an API to obtain price data.

To determine what a correct query to the Orlen API should look like, I used the Web tab of the developer mode of the browser.

The program will download data of fuel prices, and display data and a chart according specific years and months.

The project will consist of the main program file: orlen-prices.py and the utils.py file containing a function to download responses from an external API and a function to convert data in json format to the appropriate Pandas data frame.

Downloaded data will be cached in a simple way to limit the number of requests to the API.

First, I will describe the fetch_data() function from the utils.py file responsible for fetching the response from an external server. If the query is successful, the function returns a response in json format. If an exception occurs, the function returns None.

def fetch_data(product_id, current_date):
        url = f'https://tool.orlen.pl/api/wholesalefuelprices/'\
                    f'ByProduct?productId={product_id}'\
                        f'&from=2000-01-01'\
                            f'&to={current_date}'
        try:
            response = requests.get(url)
        except requests.RequestException:
            return None
        else:
            return response.json()

The next function from the utils.py file converts the data in json format to a Pandas dataframe. The resulting frame will contain only two columns, the rest of the data is omitted. The effectiveDate column will contain datetime values ​​and will be set as an index (to facilitate searching by date). The value column will be of type int. Instead, the entire frame will be sorted by index.

def json_to_df(data):
    df = pd.json_normalize(data)
    df = df[['effectiveDate', 'value']]
    df['effectiveDate'] = pd.to_datetime(df['effectiveDate'])
    df['value'] = df['value'].astype(int)
    df.set_index(df['effectiveDate'], inplace=True)
    df.sort_index(inplace=True)
    return df

The main file contains the MainWindow class responsible for displaying the program window and all widgets, which is initialized by default:

if __name__ == "__main__":
    root = tk.Tk()
    root.protocol("WM_DELETE_WINDOW", MainWindow._exit)
    app = MainWindow(root)
    app.mainloop()

The above code creates an instance of the MainWindow class and sets a function that allows the program to close – it’s a static function of the class:

background – defines the background color of the TreeView widget

The MainWindow class will inherit from the Frame class of the tkinter library:

import tkinter as tk
class MainWindow(tk.Frame):
    pass

First, I define class constants and variables, e.g.

  • background – defines the background color of the TreeView widget
  • price_data – a dictionary containing data downloaded from the API
  • current_date – variable containing the current date
  • months – list of month abbreviations
  • products – a dictionary containing product names and their codes
    bacground = '#EEEEEE'
    price_data = {}
    current_date = date.today()
    months = calendar.month_abbr
    products = {
        'Eurosuper95': 41,
        'SuperPlus98': 42,
        'Bio100': 47,
        'Arktyczny2': 44,
        'Ekodiesel': 43,
        'GrzewczyEkoterm': 46,
        'MiejskiSuper' : 45
    }

In the init() method, I initialize the values ​​from the parent class, and then run the create_UI() method responsible for displaying the widgets.

    def __init__(self, root):
        super().__init__(root)
        self.create_UI(root)

the createUI() method sets the size of the program window to maximized, the window title is set and then the appropriate widgets are initialized, i.e.

  • the show_menu() method sets the menu bar
  • the show_product_combobox() method sets the product combobox
  • the show year_combobox() method sets the year box
  • the show_radiobuttons() method sets the radio buttons to select the month
  • the show_chart_button() method sets the chart display button
  • the show_msg_label() method sets the status bar label
  • the show_data_table() method sets up a widget that displays price data
def create_UI(self, root):
    root.state('zoomed')
    root.title('Orlen wholesale prices')
    self.show_menu(root)
    self.show_product_combobox(root)
    self.show_year_combobox(root)
    self.show_radiobuttons(root)
    self.show_chart_button(root)
    self.show_msg_label(root)
    self.show_data_table(root)

From the menu it will be possible to optionally close the program by selecting File/Exit, i.e.

    def show_menu(self, root):
        # Menu bar
        menu_bar = tk.Menu(root)
        root.config(menu=menu_bar)
        # File menu 
        file_menu = tk.Menu(menu_bar, tearoff=0)
        file_menu.add_command(label='Exit', command=self._exit)
        menu_bar.add_cascade(label='File', menu=file_menu)

The show_product_combobox() method initializes the Combobox widget, which will be responsible for selecting the product for which we want to track price changes. The selection will be passed using the product_sv variable – StringVar object. Before the user selects the appropriate product, a hint is displayed on the widget.

    def show_product_combobox(self, root):
        self.product_sv = tk.StringVar(value='')
        product_cb = ttk.Combobox(root, textvariable=self.product_sv)
        product_cb['values'] = [x for x in self.products]
        product_cb.set('Product')
        product_cb.state = 'readonly'
        product_cb.bind('<<ComboboxSelected>>', self.enable_show_chart_btn)
        product_cb.grid(row=0, column=0, padx=10, pady=10)

When the user selects the appropriate product, the associated method is run: enable_show_chart_button(), because by default the chart drawing button is inactive. Initially, the condition is checked whether the button is inactive, then whether the product and year have been selected. If all these conditions are met, the state of the button changes to active.

    def enable_show_chart_btn(self, sender):
        if str(self.show_chart_btn['state']) == 'disabled' and self.product_sv.get() != 'Product' and self.year_sv.get() != 'Year':
            self.show_chart_btn['state'] = '!disabled'

The method of selecting the year for which we want to view data looks similar. Selectable year values ​​range from 2004 to the current year, with the current year being the highest in the list.

    def show_year_combobox(self, root):
        self.year_sv = tk.StringVar(value='')
        year_cb = ttk.Combobox(root, textvariable=self.year_sv)
        year_cb['values'] = [x for x in range(self.current_date.year, 2004-1, -1)]
        year_cb.set('Year')
        year_cb.state = 'readonly'
        year_cb.bind('<<ComboboxSelected>>', self.enable_show_chart_btn)
        year_cb.grid(row=0, column=1, padx=10, pady=10)

The radio fields for selecting a specific month are embedded in a separate frame, and the selected value is passed by the radio_sv variable. Since calendar.month_abbr returns a list where index 1 corresponds to January, and so on, the value at index 0 contains an empty string. I used this to put the default option – All – to display values ​​for all months of a given year. The enumerate() function used in the list is to place the next radio widget in the new column.

    def show_radiobuttons(self, root):
        # Frame for Radiobuttons
        rb_frame = tk.Frame(root)
        rb_frame['borderwidth'] = 1
        rb_frame['relief']='groove'
        rb_frame.grid(row=0, column=2, sticky='w')
        # Radiobuttons
        self.radio_sv = tk.StringVar(value='All')
        for i, month in enumerate(self.months):
            if month=="":
                month_rb = tk.Radiobutton(
                    rb_frame, 
                    text='All',
                    value='All', 
                    variable=self.radio_sv)
            else:
                month_rb = tk.Radiobutton(
                    rb_frame, 
                    text=month,
                    value=month, 
                    variable=self.radio_sv)
            month_rb.grid(row=0, column=i)

The button that starts drawing the chart is inactive by default. Only selecting the product and year changes the state of the button to active. Pressing the button triggers the show_chart_btn_clicked() method.

    def show_chart_button(self, root):
        self.show_chart_btn = ttk.Button(
            root,
             text='Show Chart',
             command=self.show_chart_btn_clicked)
        self.show_chart_btn['state'] = 'disabled'
        self.show_chart_btn.grid(row=0, column=3, padx=10, sticky='w')

Data is placed in the Treeview object. These data are downloaded for a specific product and date after pressing the button.

    def show_data_table(self, root):
        # Show Data Frame
        data_frame = tk.Frame(root)
        data_frame['borderwidth'] = 1
        data_frame['relief']='groove'
        data_frame.grid(row=1, column=0, columnspan=2, sticky=tk.N)
        # Show Data Table
        nametofont("TkHeadingFont").configure(weight='bold')
        self.table = ttk.Treeview(data_frame, 
                    show='headings')
        scrollbar = ttk.Scrollbar(data_frame, 
                                orient='vertical',
                                command=self.table.yview)
        scrollbar.grid(row=0, column=1, sticky=tk.NS)
        
        self.table.configure(yscrollcommand=scrollbar.set)

The program has a status bar showing info about correct loading of data, lack of data for a selected product in a selected period or errors related to e.g. lack of internet connection.

    def show_msg_label(self, root):
        self.msg = tk.StringVar()
        msg_label = tk.Label(root, 
                                relief='groove',
                                anchor='w',
                                textvariable=self.msg)
        msg_label.grid(row=3, column=0, columnspan=6, sticky='ews')
        root.grid_rowconfigure(3, weight=1)
        root.grid_columnconfigure(5, weight=1)

Selecting the graph button does the following:

  • clears the status bar message
  • if the product data has already been downloaded, it does not query the external API
  • if the data are not downloaded yet, an attempt is made to download them from the server. When the attempt fails, a download error message is displayed
  • the data is filtered based on the date values ​​selected by the user
  • the filtered data is passed to the methods: _set_data_table(), which updates the numerical data, and _show_chart(), which displays the chart
def show_chart_btn_clicked(self):
        self.msg.set('')
        product_id = self.products[self.product_sv.get()]
        if product_id in self.price_data:
            data = self.price_data[product_id]
        else:
            data = fetch_data(product_id, self.current_date)
            if data is not None:
                self.price_data[product_id] = data
            else:
                self.msg.set('Error fetching data')
                return 
        product_df = json_to_df(data)
        year = self.year_sv.get()
        month = self.radio_sv.get()
        self.table.delete(*self.table.get_children())
        try:
            if month == 'All':
                chart_df = product_df.loc[year]
            else: 
                month_int = datetime.strptime(month, '%b').month
                chart_df = product_df.loc[f'{year}-{month_int}']
        except:
            self.msg.set('No data for selected date')
            for widget in self.chart_canvas.winfo_children():
                widget.destroy()
        else:
            self.msg.set('OK')
            self._set_data_table(chart_df[::-1])
            self._show_chart(root, chart_df, year, month)

Updating data in the Treeview object displaying numerical data on prices:

def _set_data_table(self, df):
        for i, row in enumerate(df.iloc):
            date, value = row
            if i % 2:
                self.table.insert(
                    parent='', index=i, text='', 
                    values=(date.date(), value),
                    tag='odd')
            else:
                self.table.insert(
                    parent='', index=i, text='', 
                    values=(date.date(), value))

Displaying the graph:

def _show_chart(self, root, df, year, month):
        # Show Chart Frame
        self.chart_canvas = tk.Frame(root)
        self.chart_canvas['borderwidth'] = 1
        # self.chart_canvas['relief']='groove'
        self.chart_canvas.grid(row=1, column=2)
        # Chart
        date = [str(x.date()) for x in df['effectiveDate']]
        price = df['value'].tolist()
        fig, ax = plt.subplots()
        if month == 'All':
            month = ''
        ax.set_title(
            f'Orlen wholesale prices \
                {self.product_sv.get()} [PLN/m3] - {month} {year}')
        ax.set_xlabel('Date')
        ax.set_ylabel('Price [PLN]')
        fig.autofmt_xdate()
        ax.grid(True)
        ax.xaxis.set_major_locator(plt.MaxNLocator(12))
        ax.yaxis.set_major_locator(plt.MaxNLocator(12))
        textstr='(c) S.Kwiatkowski'
        props = dict(boxstyle='round', alpha=0.5)
        ax.plot(date, price, c='#CA3F62')
        ax.text(0.8, 0.95, textstr, transform=ax.transAxes, fontsize=8,
            verticalalignment='top', bbox=props)
        canvas = FigureCanvasTkAgg(fig, master=self.chart_canvas)
        canvas.draw()
        canvas.get_tk_widget().grid(row=1, column=2, columnspan=2, rowspan=2, sticky='nswe')
        

Date picker for tkinter

It is a utility class for the project described in this post. You can use it freely in your project (MIT license). Project files you can find here.

My date picker class code:

import tkinter as tk
import calendar
from datetime import datetime
from functools import partial


class DatePicker():
    def __init__(self, root, date_entry, date_strf):
        self.root = root
        self.date_entry = date_entry
        self.date_strf = date_strf
        self.top_level = tk.Toplevel(self.root)
        self.top_level.grab_set()
        self.top_level.title('Date picker')
        x = self.root.winfo_rootx()
        y = self.root.winfo_rooty()
        width = self.root.winfo_width()
        height = self.root.winfo_height()
        self.top_level.geometry(
            '+{}+{}'.format(x + int(width / 2), y + int(height / 2)))

As you can see I’ve created child window in function __init__() as an instance of class tk.Toplevel. Then I blocked widgets in the parent window until date picker window closes (grab_set() function). The next lines describe the position of date picker window relative to the parent window.

        self.c = calendar
        self.cal = self.c.Calendar(self.c.firstweekday())
        self.dp_frame = None
        self.create_cal_ui()

Than I’ve made an instance of Calendar class. Next, dp_frame variable which stores an instance of tk.Frame class was set up. Then the method create_cal_ui() arranges widgets in window.

create_cal_ui() method:

    def create_cal_ui(self, year=datetime.today().year, month=datetime.today().month):

        mc = self.cal.monthdayscalendar(year, month)
        self.month = month
        self.year = year
        month_txt = self.c.month_name[month]
        self.date_str = f'{month_txt} {year}'

        if self.dp_frame is not None:
            self.dp_frame.destroy()

        self.dp_frame = tk. Frame(self.top_level)
        self.dp_frame.grid(column=0, row=0)

        self.prev_img = tk.PhotoImage(file='Resources/prev.gif')
        self.next_img = tk.PhotoImage(file='Resources/next.gif')
        prev_btn = tk.Button(self.dp_frame, image=self.prev_img, relief='flat')
        prev_btn.bind(
            '<Button-1>', lambda event: self.set_date(event, 'prev_btn'))
        prev_btn.grid(row=0, column=0)
        next_btn = tk.Button(self.dp_frame, image=self.next_img, relief='flat')
        next_btn.bind(
            '<Button-1>', lambda event: self.set_date(event, 'next_btn'))
        next_btn.grid(row=0, column=6)
        self.date_lbl = tk.Label(self.dp_frame, text=self.date_str,
                                 font=12)
        self.date_lbl.grid(row=0, column=1, columnspan=5, sticky='WE')

        week_names = self.c.day_abbr
        for i, name in enumerate(week_names):
            label = tk.Label(self.dp_frame, text=name).grid(column=i, row=1)

        col = 0
        row = 2
        for week in mc:
            for day in week:
                state = 'normal'
                if day == 0:
                    state = 'disabled'
                    day = ''
                day = str(day)
                button = tk.Button(self.dp_frame, text=day,
                                   relief='flat', state=state, command=partial(self.get_date, day))
                button.grid(column=col, row=row)
                col += 1
            row += 1
            col = 0

Method create_cal_ui() takes year and month as arguments – default: current year and current month. Then monthdayscalendar() function from Calendar class gets the list of days in month. Each week is a separate list, so you have a list of lists. Days not belonging to the current month are marked as 0 (zero). The month_txt represents the month string and self.month holds integer value of a month.

 if self.dp_frame is not None:
            self.dp_frame.destroy()

It checks if month is changed. If so, the current frame is destroyed.

Then I create prev_btn and next_btn buttons to change month and date_lbl label to show selected month. In order to change a month I had to bind a method to a button. Event argument does’t have a sender name information. So I’ve created lambda function to pass additional string argument to set_date() method.

prev_btn.bind(
            '<Button-1>', lambda event: self.set_date(event, 'prev_btn'))
next_btn.bind(
            '<Button-1>', lambda event: self.set_date(event, 'next_btn'))

Next I put week names abbreviations:

    week_names = self.c.day_abbr
    for i, name in enumerate(week_names):
        label = tk.Label(self.dp_frame, text=name).grid(column=i, row=1)

Then the day buttons are shown. When pressed on a button get_date() method runs. I used partial function from functools module. Lambda function doesn’t work in this case due to late binding.

col = 0
        row = 2
        for week in mc:
            for day in week:
                state = 'normal'
                if day == 0:
                    state = 'disabled'
                    day = ''
                day = str(day)
                button = tk.Button(self.dp_frame, text=day,
                                   relief='flat', state=state, command=partial(self.get_date, day))
                button.grid(column=col, row=row)
                col += 1
            row += 1
            col = 0

Code of set_date() method:

    def set_date(self, event, sender):
        if sender == 'prev_btn':
            self.month -= 1
            if self.month < 1:
                self.month = 12
                self.year -= 1
        if sender == 'next_btn':
            self.month += 1
            if self.month > 12:
                self.month = 1
                self.year += 1
        self.create_cal_ui(self.year, self.month)

Code of get_date() method:

def get_date(self, day):
    day = int(day)
    self.date_entry.delete(0, tk.END)
    d = datetime(self.year, self.month, day)
    self.date_entry.insert(0, d.strftime(self.date_strf))
    self.top_level.destroy()

I’ve made datetime object instance with formatting. Then I’ve cleared date_entry and added new content. Finally when date is chosen the date picker is destroyed.

Tkinter GUI for CRUD app #3

Below I have described the code of the NewCarWindow class describing a new child window, created when the add_car_button button is clicked:

import tkinter as tk
from sql_helper import Helper


class NewCarWindow():
    def __init__(self, root, parent):
        self.top_level = tk.Toplevel(root)
        self.helper = Helper()
        self.parent = parent
        x = self.top_level.winfo_screenwidth()
        y = self.top_level.winfo_screenheight()
        geometry = '+{}+{}'.format(int((x / 2) - 100),
                                   int((y / 2) - 100))
        size = self.top_level.geometry(geometry)

First, I import the required classes, then the init () method sets the child window’s position relative to the main window.

        self.top_level.title('Add car')
        self.top_level.grab_set()
        label_make = tk.Label(self.top_level, text='Make: ',
                              font=12, padx=20, pady=10).grid(row=0, column=0)
        label_model = tk.Label(self.top_level, text='Model: ',
                               font=12, padx=20, pady=10).grid(row=1, column=0)
        label_year = tk.Label(self.top_level, text='Year: ',
                              font=12, padx=20, pady=10).grid(row=2, column=0)
        label_vrn = tk.Label(self.top_level, text='VRN: ',
                             font=12, padx=20, pady=10).grid(row=3, column=0)
        label_vin = tk.Label(self.top_level, text='VIN: ',
                             font=12, padx=20, pady=10).grid(row=4, column=0)

Then the title of the child window is set. The grab_set () method makes the main window inactive – e.g. you cannot press any button, etc.

The next step is to set the text labels describing the input fields, which I define below, together with variables of the StringVar type that store the values ​​entered into these fields:

    self.make_sv = tk.StringVar()
    self.model_sv = tk.StringVar()
    self.year_sv = tk.StringVar()
    self.vrn_sv = tk.StringVar()
    self.vin_sv = tk.StringVar()
    self.info_sv = tk.StringVar()

    entry_make = tk.Entry(
            self.top_level, text=self.make_sv)
    entry_make.focus_set()
    entry_make.grid(row=0, column=1)
    entry_model = tk.Entry(
            self.top_level, text=self.model_sv).grid(row=1, column=1)
    entry_year = tk.Entry(
            self.top_level, text=self.year_sv).grid(row=2, column=1)
    entry_vrn = tk.Entry(
            self.top_level, text=self.vrn_sv).grid(row=3, column=1)
    entry_vin = tk.Entry(
            self.top_level, text=self.vin_sv).grid(row=4, column=1)

    info_label = tk.Label(self.top_level, textvariable=self.info_sv,
                              font=12, padx=10, pady=10, fg='red').grid(row=5, column=0, columnspan=2)

The info_label label is used to display a message in the case of not filling all the required fields to create a new car.

        save_button = tk.Button(self.top_level, text='Save',
                                command=self.save_new_car)
        save_button.bind('<Return>', self.save_new_car)
        save_button.grid(row=6, column=1, sticky='W', padx=10)
        cancel_button = tk.Button(self.top_level, text='Cancel',
                                  command=self.top_level.destroy)
        cancel_button.grid(row=6, column=1, sticky='W', padx=70)
        cancel_button.bind('<Return>', self.top_level.destroy)

At the bottom of the window, the Save and Cancel buttons are displayed to save the data about the new car in the database or close the ‘Add car’ window.

def save_new_car(self, event=None):
    if self.make_sv.get() and self.model_sv.get() and self.year_sv.get() and self.vrn_sv.get() and self.vin_sv.get():
        self.helper.add_car(self.make_sv.get(),
                            self.model_sv.get(),
                            self.year_sv.get(),
                            self.vrn_sv.get(),
                            self.vin_sv.get())
        self.top_level.destroy()
        self.parent.show_cars()
    else:
        self.info_sv.set('Please fill in all entry fields')

The code for the save_new_car() method is activated if the Save button is pressed. If all the values ​​needed to create a new car have been filled, the add_car () method of the Helper class is run, then the child window is closed and the entries about cars in the main window are updated using the show_cars() method of the CarManager class.

The ‘Add car’ child window. The message is displayed when all input fields are not filled in. Focus is set on the first element.

***

The code of the RepairsWindow class, activated after pressing the main button repair_button:

import tkinter as tk
from tkinter import ttk
from sql_helper import Helper
from date_picker import DatePicker


class RepairsWindow():
    def __init__(self, root, car):
        self.top_level = tk.Toplevel(root)
        self.root = root
        self.top_level.title('Repairs')
        self.top_level.grab_set()
        self.car = car
        self.helper = Helper()

        x = self.top_level.winfo_screenwidth()
        y = self.top_level.winfo_screenheight()
        geometry = '+{}+{}'.format(int((x / 2) - 150),
                                   int((y / 2) - 150))
        size = self.top_level.geometry(geometry)

First, I import all the necessary classes. In the init () method, a new window is created – an instance of the TopLevel class, and the window title is set to ‘Repairs’. Then, using the grab_set () method, the possibility of starting widgets from the main window is blocked. The self.car and self.helper variables are set and the child window is positioned relative to the main window.

        toolbox_frame = tk.Frame(self.top_level)
        toolbox_frame.grid(column=0, row=0, sticky='W')
        self.add_repair_img = tk.PhotoImage(file='Resources/add_repair.gif')
        add_repair_button = tk.Button(
            toolbox_frame, image=self.add_repair_img, command=self.add_repair)
        add_repair_button.grid(column=0, row=0, sticky='W')
        add_repair_button.bind('<Return>', self.add_repair)
        add_repair_button.bind('<KP_Enter>', self.add_repair)
        if car.sold:
            add_repair_button.config(state='disabled')

A toolbox_frame is created in the child window, in which the add_repair_button button is placed. The add_repair () method is bound to the created button – activated with the mouse or keyboard after pressing Enter on the numeric or main keyboard. Then it is checked if the car is not marked as ‘sold’, then the button for adding new repairs is inactive.

        col_headers = ('No', 'Date', 'Description')
        self.repairs_tv = ttk.Treeview(self.top_level, columns=col_headers,
                                       show='headings', selectmode='none')
        self.repairs_tv.tag_configure('c1', background='ivory2')
        self.repairs_tv.tag_configure('c2', background='ivory3')
        for i, col in enumerate(col_headers):
            self.repairs_tv.heading(col, text=col)
            self.repairs_tv.column(col, anchor='center')
            if i == 0:
                self.repairs_tv.column(col, width=50, stretch='NO')
        self.repairs_tv.grid(column=0, row=2,  sticky='NSWE')

        scrollbar = tk.Scrollbar(self.top_level, command=self.repairs_tv.yview)
        scrollbar.grid(column=1, row=2, sticky='NS')
        
        self.show_repairs()

The next code snippet describes creating the Treeview widget that will display repair data. To improve readability, I created two tags that set the background colors of the entered widget elements. The init () method ends by calling the show_repairs () method, which adds repairs to the Treeview widget.

    def show_repairs(self):
        repairs = self.helper.show_repairs(self.car)
        self.repairs_tv.delete(*self.repairs_tv.get_children())
        for i, repair in enumerate(repairs, start=1):
            repair = (i, repair[0], repair[2])
            if i % 2:
                self.repairs_tv.insert('', 'end', values=repair, tag='c1')
            else:
                self.repairs_tv.insert('', 'end', values=repair, tag='c2')

The show_repairs () method calls the appropriate Helper method to retrieve the repair data from the database. Then the repairs_tv items are deleted and the updated data is set. To improve readability, the following lines are alternately colored, i.e.

Repairs window.

Code of the add_repair method, which is activated when the add_repair_button button is pressed with the mouse or the keyboard:

    def add_repair(self, event=None):
        self.add_repair_frame = tk.Frame(self.top_level)
        self.add_repair_frame.grid(
            column=0, row=1, pady=20, sticky='WE')

        date_label = tk.Label(self.add_repair_frame,
                              text='Date:').grid(column=0, row=2)
        self.date_sv = tk.StringVar()
        self.date_entry = tk.Entry(self.add_repair_frame,
                                   text=self.date_sv)
        self.date_entry.focus_set()
        self.date_entry.grid(column=1, row=2, sticky='W')

        self.cal_img = tk.PhotoImage(file='Resources/calendar.gif')
        show_cal_btn = tk.Button(self.add_repair_frame, image=self.cal_img,
                                 command=self.show_cal, relief='flat').grid(column=1, row=2, sticky='W', padx=170)

        description_label = tk.Label(self.add_repair_frame,
                                     text='Description:').grid(column=0, row=3)
        self.description_sv = tk.StringVar()
        self.description_entry = tk.Entry(self.add_repair_frame,
                                          text=self.description_sv)
        self.description_entry.grid(column=1, row=3, ipadx=200)
        save_button = tk.Button(self.add_repair_frame, text='Save',
                                command=self.save_repair)
        save_button.grid(column=1, row=4, pady=10, sticky='E')
        save_button.bind('<Return>', self.save_repair)
        save_button.bind('<KP_Enter>', self.save_repair)
        cancel_button = tk.Button(self.add_repair_frame, text='Cancel',
                                  command=self.cancel_repair)
        cancel_button.grid(column=1, row=4, sticky='E', padx=60)
        cancel_button.bind('<Return>', self.cancel_repair)
        cancel_button.bind('<KP_Enter>', self.cancel_repair)
Repairs window after pressing the add_repair_button button.

Code of the cancel_repair () method that hides the repair_frame:

    def cancel_repair(self, event=None):
        self.add_repair_frame.grid_remove()

Save_repair () method code:

def save_repair(self, event=None):
        if self.date_sv.get() and self.description_sv.get():
            self.helper.add_repair(self.car, self.date_sv.get(),
                                   self.description_sv.get())
            self.show_repairs()
            self.add_repair_frame.grid_remove()

In the save_repair () method, basic form validation is performed, ie it is checked if all required fields to create a repair entry are filled in.

If so, the repair note is saved in the database (add_repair method of the Helper class), and then the entries about the repairs are updated and the repair_frame is hidden.

The remaining show_cal () method, which is activated after pressing the show_cal_btn button:

    def show_cal(self, event=None):
        date_picker = DatePicker(self.top_level, self.date_entry, '%d-%m-%Y')
        self.description_entry.focus_set()

The show_cal () method creates an instance of the DatePicker class that I created to get the date and insert it into the given Entry field. As an argument, this class allows you to insert the selected date formatting, so that, for example, in the application settings the user could choose how the date should be displayed.

You can find the code of the DatePicker class in this post.

Selecting a date using my DatePicker class

***

P.S. If the main window is to run as maximized, but with the resize option, then in the init () method of the CarManager class, add:

root.attributes('-zoomed', True)

P.P.S. If entries about cars are to be displayed with row formatting, the show_cars () method can be modified by adding tags that define row colors, i.e.

    def show_cars(self):
        all_cars = self.helper.show_all_cars()
        self.car_tview.delete(*self.car_tview.get_children())
        for i, car in enumerate(all_cars, start=1):
            car = list(car)
            car.insert(0, i)
            if i % 2:
                self.car_tview.insert('', 'end', values=car, tag='c2')
            else:
                self.car_tview.insert('', 'end', values=car, tag='c1')
            self.car_tview.tag_configure('c1', background='ivory3')
            self.car_tview.tag_configure('c2', background='ivory2')
Treeview widget with formatting tags

Tkinter GUI for CRUD app #2

In an earlier post, I created the main application window. Then I will complete the methods that handle events initiated by interface elements.

First, I will complete the show_cars () method, which is activated after loading widgets into the main window and each time when adding or removing a car from the database.

    def show_cars(self):
        all_cars = self.helper.show_all_cars()
        self.car_tview.delete(*self.car_tview.get_children())
        for i, car in enumerate(all_cars, start=1):
            car = list(car)
            car.insert(0, i)
            self.car_tview.insert('', 'end', values=car)


The show_all_cars () method of the Helper class runs first. It returns all cars in the database as a list of tuples. Then all items in the TreeView widget are removed to reload the car list after the changes.

To simplify handling of the del_car (), sell_car (), show_repairs () methods, I will extract a private method: __get_car_from_selection (), which returns an instance of the Car class based on the selected line, i.e.

    def __get_car_from_selection(self):
        selected_item = self.car_tview.focus()
        if selected_item == '':
            messagebox.showinfo('Information', 'No item selected')
        else:
            selection_dict = self.car_tview.item(selected_item)
            selection = selection_dict.get('values')
            i, *args = selection
            car = Car.from_list(args)
            return car

First, the above method checks if any line containing data about the car is selected. If so, the TreeView element’s dictionary is returned.

Data about the selected car is present as a value for the ‘values’ key. The first element that is the row ordinal is removed from the list.

The modified list is an argument of the from_list () method of the Car class.

Del_car () method code:

    def del_car(self):
        car = self.__get_car_from_selection()
        if car and messagebox.askyesno('Delete', 'Delete selected car?'):
            self.helper.del_car(car)
            self.show_cars()

The above method checks whether the variable car is not of the None type and whether the user has also confirmed the deletion of data about the car in the dialog box. If both of these conditions are met, the Helper class method is run, deleting the car from the database.

Then the car entries in the database are updated using the show_cars () method of the CarManager class.

    def sell_car(self):
        car = self.__get_car_from_selection()
        if car and car.sold:
            messagebox.showinfo('Information', 'Already marked as sold')
        elif car and messagebox.askyesno('Sell', 'Mark car as sold?'):
            self.helper.set_sold(car)
            self.show_cars()

In the above method, it is checked whether the car has already been marked as sold. If not, a confirmation dialog is displayed.

If the user accepts it, the set_sold () method of the Helper class is called and then the show_cars () method of the CarManager object.

Show_repairs () method code:

    def show_repairs(self):
        car = self.__get_car_from_selection()
        if car:
            repairs = RepairsWindow(self.root, car)

In the show_repairs () method, when the variable car is not of type None, a new child window is created that is described by the RepairsWindow class.

The last method described in this post is the add_car () method, which is activated after pressing the add_car_button button.

def add_car(self):
        add_car = NewCarWindow(self.root, self)

The add_car () method creates a new child window that is described by the NewCarWindow class.

I will describe the codes of both classes in the next post.

The methods related to the search_entry input field still need to be added, i.e. on_entry_in (), on_entry_out () and on_entry_return (), i.e.

    def on_entry_in(self, event):
        self.search_entry.config(fg='black')
        self.search_variable.set('')

    def on_entry_out(self, event):
        self.search_entry.config(fg='grey')
        self.search_variable.set('Search car by VRN')

Gaining focus on the search_entry search box clears the search box and changes the font color. The loss of focus causes the display of gray text ‘Search car by VRN’ in the search_entry field.

def on_entry_return(self, event):
    vrn = self.search_variable.get()
    if vrn == '':
        self.show_cars()
    else:
        car = self.helper.search_by_vrn(vrn)
        self.car_tview.delete(*self.car_tview.get_children())
        if car:
            car = list(car)
            car.insert(0, 1)
            self.car_tview.insert('', 'end', values=car)

The on_entry_return method code above checks if the search field is empty after pressing Enter. If so, data on all available cars from the database are displayed.

If a phrase is entered in the search box, the search_by_vrn () method of the Helper class is run, followed by the updated car_tview widget to display the search results.

Tkinter GUI for CRUD app #1

In the >>previous post<<, I described a simple CRUD program that modifies a database (SQLite in this case).

For the program I created earlier, I will create GUI in the tkinter library. The interface will be developed gradually, making it easier to explain the methods used.

Program code can be found here.

I will slightly modify the files created in the previous post, i.e. for the class Car() I’ll add a class method that creates an instance when from a list. In addition, in the Helper () class, the del_car () method, apart from deleting the car from the database, also deletes all data about the entered repairs related to this car. The code of the new fragments looks like this:

class Car():
    @classmethod
    def from_list(cls, list):
        make, model, year, vrn, vin, sold = list
        return cls(make, model, year, vrn, vin, sold)

class Helper():
    def del_car(self, car):
        with self.conn:
            self.c.execute("SELECT ROWID FROM cars WHERE vin=:vin",
                           {'vin': car.vin})
            car_id = self.c.fetchone()
            self.c.execute("DELETE FROM repairs WHERE car=?",
                           (car_id))
            self.c.execute("DELETE FROM cars WHERE vin=:vin", {'vin': car.vin})

Here is my CarManager() class describing the graphical interface.

import tkinter as tk
from tkinter import messagebox, ttk
from sql_helper import Helper
from car import Car

class CarManager(tk.Frame):
    def __init__(self, root):
        super().__init__(root)
        self.root = root
        self.helper = Helper()
        root.title('Car manager')
        root.iconphoto(True, tk.PhotoImage(file='Resources/car_logo.png'))
        root.bind('<Control-x>', self.close_app)
        root.columnconfigure(1, weight=1)
        root.rowconfigure(2, weight=1)
        self.create_widgets()
        self.show_cars()


if __name__ == '__main__':
    root = tk.Tk()
    cm = CarManager(root)
    cm.mainloop()

When creating an instance of the CarManager class, I create instance of the Helper class, set the name of the created window to ‘Car Manager’ and add the program icon from the Resources directory. In addition, I set a hotkey to end the program (Ctrl + x). Then I set the way the widgets are to be scaled when the window is resized.

The last two lines of the init () method run the methods responsible for creating all widgets in the main window and for displaying data from the database about the saved cars.

Here is my close_app () method:

    def close_app(self, event):
        result = tk.messagebox.askyesno('Exit', 'Close application?')
        if result:
            self.root.destroy()

The result is True when selected the close app button, the main application window is closed.

Code of my create_widgets () method responsible for displaying interface elements:

def create_widgets(self):
        # create menu
        menubar = tk.Menu(self.root)
        self.root.config(menu=menubar)
        file_menu = tk.Menu(menubar, tearoff=0)
        file_menu.add_command(label='Exit', command=exit, accelerator="Ctrl+x")
        menubar.add_cascade(label="File", menu=file_menu)

        # create toolbar
        tbar_frame = tk.Frame(root, height=10)
        tbar_frame.grid(row=0, column=0)

        self.add_car_img = tk.PhotoImage(file='Resources/add_car.gif')
        self.remove_car_img = tk.PhotoImage(file='Resources/remove_car.gif')
        self.repairs_img = tk.PhotoImage(file='Resources/repairs.gif')
        self.sold_img = tk.PhotoImage(file='Resources/sold.gif')

        add_car_button = tk.Button(tbar_frame, image=self.add_car_img,
                                   command=self.add_car).grid(row=0, column=0, sticky='W')
        del_car_button = tk.Button(tbar_frame, image=self.remove_car_img,
                                   command=self.del_car).grid(row=0, column=0, sticky='W', padx=30)
        repairs_button = tk.Button(tbar_frame, image=self.repairs_img,
                                   command=self.show_repairs).grid(row=0, column=0, sticky='W', padx=60)
        sold_button = tk.Button(tbar_frame, image=self.sold_img,
                                command=self.sell_car).grid(row=0, column=0, sticky='W', padx=90)

        # create search entry
        self.search_variable = tk.StringVar()
        self.search_entry = tk.Entry(
            root, textvariable=self.search_variable)
        self.search_entry.config(fg='grey')
        self.search_variable.set("Search car by VRN")
        self.search_entry.bind('<FocusIn>', self.on_entry_in)
        self.search_entry.bind('<FocusOut>', self.on_entry_out)
        self.search_entry.bind('<Return>', self.on_entry_return)
        self.search_entry.bind('<KP_Enter>', self.on_entry_return)
        self.search_entry.grid(row=0, column=1, sticky='E', ipadx=20)

        # create TreeView with Scrollbar
        col_headers = ('No', 'Make', 'Model', 'Year', 'VRN', 'VIN', 'Sold')
        self.car_tview = ttk.Treeview(self.root, columns=col_headers,
                                      show='headings', selectmode='browse')
        self.car_tview.columnconfigure(0, weight=1)
        self.car_tview.rowconfigure(0, weight=1)
        # set column headers
        for i, col in enumerate(col_headers):
            self.car_tview.heading(col, text=col)
            self.car_tview.column(col, anchor='center')
            if i == 0:
                self.car_tview.column(col, width=50, stretch='NO')
        self.car_tview.grid(row=2, column=0, columnspan=2, sticky='NSWE')

        scrollbar = tk.Scrollbar(self.root, command=self.car_tview.yview)
        scrollbar.grid(column=3, row=2, sticky='NS')

Successively, the program menu is created, then a toolbar with buttons for adding a car to the database, removing a car, adding data about car repairs and marking the sale of the car.

Then, a car search by the registration number field is created to which methods are attached to react to the events of obtaining / losing focus and confirming data by pressing the ENTER key on: numeric keypad (<KP_Enter>) or main keypad (<Return>).

The next few lines define a TreeView widget containing cars data and a scroll bar, if the data does not fit on the page.

After the changes, the main program window should look like this:

The initial window of the program. For now, no data …