15.12 Managing GUI Program Complexity

As we explained in a previous lesson, GUI programs are best implemented as Python classes because it allows you to manage the scope of the variables in your GUI interface and callback functions. However, as GUI programs become more complex, it can become overwhelming to implement them as a single class. If a single class has more than 2,000 lines of code it is probably getting too big to effectively manage. What are some ways to effectively break down complex problems into manageable pieces?

One of the most widely used ways to break down a GUI program into manageable pieces is called the Model-View-Controller software design pattern. This is often abbreviated as MVC (Model-View-Controller). It divides a problem into three pieces:

To say this in more general terms, the controller manages the applications input, the model manages the application’s “state” and enforces application consistency, and the view updates the output, which is what the user sees on the screen. This is basically identical to what all computer processing is composed of, which is:

input --> processing --> output

The MVC design pattern renames the pieces and restricted which part of the code can talk to the other parts of code. For MVC design:

controller (input) --> model (processing) --> view (output)

From the perspective of a GUI program, this means that the callback functions, which are called when a user causes events, are the controller, the model should perform all of the application logic, and the building and modification of the GUI widgets composes the view.

Let’s develop an Automatic Teller program using this design strategy. Instead of creating one Python Class for the entire app, the code will be developed as a set of cooperating objects. So where should we begin? I would suggest that the same stages of development we used in the previous lesson are a good approach, but we will create a separate Python class for most of the stages. Let’s walk through the code development.

15.12.1 Creating the View

Let’s create a Python class that builds the user interface for an Automatic Teller app. The emphasis for this code is the creation of the elements we need to display to the user.


# atm_view.py
#
#  Author: Bill Montana
#  Course: Coding for OOP
# Section: B4
#    Date: 4 Apr 2023
#     IDE: PyCharm
#

import PySimpleGUI as sg

from utils import *
import getpass
import sys

DISPLAY_TIME_MILLISECONDS = 1000


class ATM_View:
    def __init__(self):
        self.button_size = 12

    def menu(self):
        layout_col_1 = [
            [sg.VPush()],
            [sg.Button('\nBalance\nInquiry\n', size=self.button_size)],
            [sg.VPush()],
            [sg.Button('\nExit\nATM\n', size=self.button_size)],
            [sg.VPush()]
        ]
        layout_col_2 = [
            [sg.VPush()],
            [sg.Image(source='/Users/bill/PycharmProjects/ECS2-T3-1819/src/img/pythonLogoSmall.png')],
            [sg.Text('ATM', font=('Courier', 48))],
            [sg.VPush()],
        ]
        layout_col_3 = [
            [sg.VPush()],
            [sg.Button('\nMake\nDeposit\n', size=self.button_size)],
            [sg.VPush()],
            [sg.Button('\nMake\nWithdrawal\n', size=self.button_size)],
            [sg.VPush()]
        ]
        layout = [
            [sg.VPush()],
            [
                sg.Sizer(0, 575),
                sg.Column(layout_col_1 + [[sg.Sizer(200, 0)]], vertical_alignment='center', element_justification='left'),
                sg.Column(layout_col_2 + [[sg.Sizer(350, 0)]], key='-DISPLAY-', vertical_alignment='center', element_justification='center'),
                sg.Column(layout_col_3 + [[sg.Sizer(200, 0)]], vertical_alignment='center', element_justification='right')
            ],
            [sg.VPush()]
        ]

        return sg.Window('ATM for COOP', layout, size=(800, 600),
                           font=('Helvetica', 24), element_justification='center'
                           )

    @staticmethod
    def welcome():
        layout = [
            [sg.VPush()],
            [sg.Push(), sg.Text('Welcome to the'), sg.Push()],
            [sg.Push(), sg.Text('ATM for COOP'), sg.Push()],
            [sg.VPush()]
        ]

        sg.Window('', layout, keep_on_top=True, font=('Helvetica', 36, 'bold'), size=(800, 600), finalize=True)\
            .read(timeout=DISPLAY_TIME_MILLISECONDS, close=True)

    def balances(self, c_bal, s_bal):
        """
        Display account balances
        :param c_bal: str -> Checking account balance
        :param s_bal: str -> Savings account balance
        :return: sg.Window() instance
        """
        layout = [
            [sg.Text('Account Balances')],
            [sg.VPush()],
            [sg.Text(f'Checking: ${padl(c_bal, 12)}')],
            [sg.Text(f'Savings : ${padl(s_bal, 12)}')],
            [sg.VPush()],
            [sg.Button('OK', size=self.button_size)]
        ]

        return sg.Window('', layout, keep_on_top=True, font=('Helvetica', 36), size=(780, 500), element_justification='center', no_titlebar=True)

    def enter_id(self):
        """
        Window to get account number from user. Keys -ACCTNO- and -PIN- can be used to retrieve user inputs.
        :return: sg.Window instance
        """
        layout = [
            [sg.VPush()],
            [sg.Text('Account No:', size=10, justification='r'), sg.InputText('', key='-ACCTNO-', size=15)],
            [sg.VPush()],
            [sg.Text('PIN:', size=10, justification='r'), sg.InputText('', password_char='•', key='-PIN-', size=15)],
            [sg.VPush()],
            [sg.Button('OK', size=self.button_size), sg.Button('Cancel', size=self.button_size)],
            [sg.VPush()]
        ]

        return sg.Window('', layout, keep_on_top=True, font=('Helvetica', 28), size=(780, 500), element_justification='center', no_titlebar=True)

    def update_acct_with_amt(self, xtype):
        layout = [
            [sg.VPush()],
            [sg.Text('DEPOSIT' if xtype == 'D' else 'WITHDRAWAL')],
            [sg.VPush()],
            [sg.Text('Account Type:', size=15, justification='r'), sg.Radio('Checking', '-ACCT_TYPE-', key='-C-'), sg.Radio('Savings', '-ACCT_TYPE-', key='-S-')],
            [sg.VPush()],
            [sg.Text('Amount:', size=15, justification='r'), sg.InputText('', key='-AMOUNT-', size=10)],
            [sg.VPush()],
            [sg.Button('OK', size=self.button_size), sg.Button('Cancel', size=self.button_size)],
            [sg.VPush()]
        ]

        return sg.Window('', layout, keep_on_top=True, font=('Helvetica', 28), size=(780, 500), element_justification='center', no_titlebar=True)

    def display_msg(self, m, wait=True):
        """
        Displays message on screen
        :param m: str -> M, message to be displayed
        :param wait: bool -> Wait for user confirmation
        :return: sg.Window instance
        """
        layout = [
            [sg.VPush()],
            [sg.Text(m)],
            [sg.VPush()],
        ]

        if wait:
            layout.append([sg.Button('OK')])
            layout.append([sg.VPush()])

        return sg.Window('', layout, keep_on_top=True, font=('Helvetica', 24), size=(380, 300),
                         element_justification='center', no_titlebar=True, auto_close=not wait)
        

15.12.2 Creating the Model

The model for this Automatic Teller app maintains the state of the application. It is responsible for all data storage.


# atm_model.py
#
#  Author: Bill Montana
#  Course: Coding for OOP
# Section: B4
#    Date: 4 Apr 2023
#     IDE: PyCharm
#

from pathlib import Path
from utils import *


class ATM_Model:
    def __init__(self, db):
        self.db_file = db + '.dat'
        self.db_bak = db + '.bak'
        self.db_file_struc = [['USERID', 10],
                              ['FIRST NAME', 15],
                              ['LAST NAME', 15],
                              ['PIN', 6],
                              ['CHK_BAL', 12],
                              ['SAV_BAL', 12]]

        # a record in ATM_DATA is {USERID: {FIRST NAME, LAST NAME, PIN, CHK_BAL, SAV_BAL}}
        self.ATM_DATA = {}
        self.read_db()      # read from data file to initialize ATM_DATA
        self.ERR_MSGS = ['Account update was SUCCESSFUL',
                         'User ID does not exist',
                         'Incorrect account type',
                         'INTERNAL ERROR']

    def read_db(self):
        """
        Read from data file and store in dictionary (via parse_data())
        :return: None
        """
        fobj = Path(self.db_file)
        with fobj.open('r') as f:
            d = f.readlines()
        self.parse_data(d)

    def write_db(self):
        """
        Write data from dictionary to data file
        :return: None
        """
        bak_obj = Path(self.db_bak)     # backup file
        fobj = Path(self.db_file)       # data file

        # Delete old backup file
        if bak_obj.exists():
            bak_obj.unlink()

        # Rename data file as backup
        if fobj.exists():
            fobj.rename(bak_obj)

        # create data file and write data
        with fobj.open('w') as f:
            for acct in self.ATM_DATA:
                f.write(padr(acct, self.db_file_struc[0][1]))
                for fld in range(1, len(self.db_file_struc)):
                    f.write(padr(self.ATM_DATA.get(acct).get(self.db_file_struc[fld][0]), self.db_file_struc[fld][1]))
                f.write('\n')

    def parse_data(self, lines):
        """
        Stores data (lines) in dictionary (ATM_DATA)
        :param lines: list -> lines from data file
        :return: None
        """
        for line in lines:
            ptr = 10            # character position in a line of data
            record_detail = {}  # dictionary for data on a single line

            # extract data fields from line and store in record_detail
            for fld in range(1, len(self.db_file_struc)):
                record_detail[self.db_file_struc[fld][0]] = line[ptr:ptr + self.db_file_struc[fld][1]].strip()
                ptr += self.db_file_struc[fld][1]

            # a record in ATM_DATA is {USERID: {FIRST NAME, LAST NAME, PIN, CHK_BAL, SAV_BAL}}
            self.ATM_DATA[line[:self.db_file_struc[0][1]]] = record_detail

    def update_bal(self, userid, acct, amt):
        """
        Handles both deposits and withdrawals
        :param userid: str -> User ID (account number)
        :param acct: str -> 'C' or 'S' for checking or savings
        :param amt: float -> Amount to update. Positive for deposit, negative for withdrawal.
        :return: (int, str) -> (error number, error message)
        0 = success; 1 = userid does not exist; 2 = acct not specified or incorrect;
                        3 = data missing/wrong type
        """
        try:
            if self.ATM_DATA[userid]:
                if acct == 'C':
                    cur_bal = float(self.ATM_DATA[userid]['CHK_BAL'])
                    self.ATM_DATA[userid]['CHK_BAL'] = str(cur_bal + amt)
                elif acct == 'S':
                    cur_bal = float(self.ATM_DATA[userid]['SAV_BAL'])
                    self.ATM_DATA[userid]['SAV_BAL'] = str(cur_bal + amt)
                else:
                    return 2, self.ERR_MSGS[2]
                self.write_db()
                return 0, self.ERR_MSGS[0]
        except KeyError:
            return 1, self.ERR_MSGS[1]

    def bal_inquiry(self, userid):
        """
        Check both checking and savings balances
        :param userid: str -> User ID (account number)
        :return: (str, str) -> (checking balance, savings balance),
                               unless account does not exist (int -> error number, str -> error message)
        """
        if self.ATM_DATA[userid]:
            return self.ATM_DATA[userid]['CHK_BAL'], self.ATM_DATA[userid]['SAV_BAL']

    def acct_is_valid(self, a, p):
        """
        Checks to see if userid a is valid
        :param a: str -> userid to check
        :param p: str -> PIN to check
        :return: bool -> True = found a in userid key field and p in pin key field; False otherwise
        """
        try:
            if self.ATM_DATA[a] and self.ATM_DATA[a]['PIN'] == p:
                return True
            else:
                return False
        except KeyError:
            return False
        

The data file is just a text file with fixed-width fields. It is named atm_data.dat with the following structure (record width = 70; field widths in parentheses):

USERID(10)FIRST NAME(15).LAST NAME(15)..PIN(6)CHK_BAL(12).SAV_BAL(12).
|         |              |              |     |           |           |

Here is some sample data.

Johnny Appleseed
user_id = '1234567890'
pin = '102030'
checking balance = '1913.27'
savings balance = '347.12'

Richard Branson
user_id = '0373828919''
pin = '999123'
checking balance = '190130483.13'
savings balance = '3040700.76'

And here it is in the data file.

1234567890Johnny         Appleseed      1020301913.27     347.12
0373828919Richard        Branson        999123190130483.133040700.76

15.12.3 Creating the Controller

The controller receives user events and sends messages to the model to update the model’s state. For our Automatic Teller app, we have the following four basic commands we need to send to the model:

The controller needs to recognize these events and send them to appropriate methods in the model. The controller needs to take the appropriate action for these events Therefore, the controller needs access to the elements in the view object. This can easily be accomplished by defining window layout in view and returning sg.Window instances to the controller. This gives the controller access to events and values when the window is read.


# atm_controller.py
#
#  Author: Bill Montana
#  Course: Coding for OOP
# Section: B4
#    Date: 4 Apr 2023
#     IDE: PyCharm
#

import PySimpleGUI as sg
import atm_model
import atm_view


def main():
    sg.set_options(enable_mac_notitlebar_patch=True)
    atmV = atm_view.ATM_View()
    atmM = atm_model.ATM_Model('atm_data')
    atmV.welcome()  # splash screen
    authenticated = False
    window = atmV.menu()
    userid = ''
    pin = ''

    while True:
        event, values = window.read()

        if event == sg.WIN_CLOSED:
            break
        elif 'Exit' in event:
            break

        login_cancelled = False

        if not authenticated:
            auth_win = atmV.enter_id()

            while True:
                evnt, vals = auth_win.read()

                if evnt == sg.WIN_CLOSED:
                    break
                elif evnt == 'OK':
                    if vals['-ACCTNO-'] and vals['-PIN-']:
                        userid = vals['-ACCTNO-']
                        pin = vals['-PIN-']
                        break
                elif evnt == 'Cancel':
                    login_cancelled = True
                    break

            auth_win.close()

        valid_acct = atmM.acct_is_valid(userid, pin)

        if authenticated or valid_acct:
            authenticated = True

            if 'Balance' in event:
                bals = atmM.bal_inquiry(userid)
                overlay = atmV.balances(bals[0], bals[1])

                while True:
                    evnt, vals = overlay.read()

                    if evnt == sg.WIN_CLOSED:
                        break
                    elif evnt == 'OK':
                        break

                overlay.close()

            elif 'Deposit' in event or 'Withdrawal' in event:
                xtype = 'D' if 'Deposit' in event else 'W'
                overlay = atmV.update_acct_with_amt(xtype)

                while True:
                    evnt, vals = overlay.read()

                    if evnt == sg.WIN_CLOSED:
                        break
                    elif evnt == 'Cancel':
                        break
                    elif evnt == 'OK':
                        if vals['-C-'] or vals['-S-']:
                            acct = 'C' if vals['-C-'] else 'S' if vals['-S-'] else ''

                            try:
                                amt = float(vals['-AMOUNT-'])
                                break
                            except:
                                msg_win = atmV.display_msg('Invalid input.\nTry again.', wait=False)
                                msg_win.read()

                overlay.close()

                if evnt == 'OK' and amt > 0 and acct:
                    atmM.update_bal(userid, acct, amt * (1.0 if xtype == 'D' else -1.0))
                    msg_win = atmV.display_msg(
                        f'${amt} was {"deposited into" if xtype == "D" else "withdrawn from"}\nyour {"checking" if acct == "C" else "savings"} account.',
                        wait=False)
                    msg_win.read()

            elif 'Withdrawal' in event:
                pass

        else:
            if not login_cancelled:
                msg_win = atmV.display_msg('Login credentials are incorrect.\nTry again.', wait=False)
                msg_win.read()

    window.close()


if __name__ == '__main__':
    main()