15.15 MVC Programming

The MVC Programming pattern consists of three parts: Model, View, and Controller

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 restricts 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.15.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 *

DISPLAY_TIME_MILLISECONDS = 3000


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

    def menu(self):
        """
        Main Menu window
        :return: sg.Window() instance that can be read() in other code
        """
        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():
        """
        Splash screen. Use DISPLAY_TIME_MILLISECONDS at top to set display time
        :return: None
        """
        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 that can be read() in other code

        """
        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 that can be read() in other code
        """
        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):
        """
        Get deposit or withdrawal (determined by xtype) amount and account type
        :param xtype: str -> 'D' if deposit, 'W' if withdrawal
        :return: sg.Window() instance that can be read() in other code
        """
        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 -> message to be displayed
        :param wait: bool -> Wait for user confirmation
        :return: sg.Window() instance that can be read() in other code
        """
        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)


# for testing this module
if __name__ == '__main__':
    v = ATM_View()
    v.welcome()
        

15.15.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.15.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                                                # provides access to the model
import atm_view                                                 # provides access to the view


def main():
    sg.set_options(enable_mac_notitlebar_patch=True)
    atmV = atm_view.ATM_View()                                  # instantiate view object called atmV
    atmM = atm_model.ATM_Model('atm_data')                      # instantiate model object called atmM
    atmV.welcome()                                              # splash screen from view
    authenticated = False
    window = atmV.menu()                                        # get sg.Window instance from view
    userid = ''
    pin = ''

    while True:                                                 # event loop for main menu window
        event, values = window.read()

        if event == sg.WIN_CLOSED:                              # close window button
            break
        elif 'Exit' in event:                                   # Exit button
            break

        login_cancelled = False

        if not authenticated:
            auth_win = atmV.enter_id()                          # login window for not authenticated user

            while True:                                         # event loop for login window
                evnt, vals = auth_win.read()

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

            auth_win.close()                                    # be sure to close windows when done with them

        valid_acct = atmM.acct_is_valid(userid, pin)            # check model to see if account is valid

        if authenticated or valid_acct:
            authenticated = True

            if 'Balance' in event:                              # Balance Inquiry button
                bals = atmM.bal_inquiry(userid)                 # get balances from model
                overlay = atmV.balances(bals[0], bals[1])       # window to display balances

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

                    if evnt == sg.WIN_CLOSED:                   # close window button
                        break
                    elif evnt == 'OK':                          # OK button
                        break

                overlay.close()                                 # close no longer needed window

            elif 'Deposit' in event or 'Withdrawal' in event:   # Make Deposit and Make Withdrawal buttons
                xtype = 'D' if 'Deposit' in event else 'W'
                overlay = atmV.update_acct_with_amt(xtype)      # window for deposits and withdrawals
                #                                                   xtype designates which type

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

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

                            try:
                                amt = float(vals['-AMOUNT-'])   # using try in case invalid number format is entered
                                break
                            except:
                                msg_win = atmV.display_msg('Invalid input.\nTry again.', wait=False)
                                msg_win.read()

                overlay.close()                                 # close no longer needed window

                if evnt == 'OK' and amt > 0 and acct:           # user didn't cancel, and amt & acct are not empty
                    atmM.update_bal(userid, acct, amt * (1.0 if xtype == 'D' else -1.0))    # update balances in model
                    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()

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

    window.close()                                              # close main window


if __name__ == '__main__':
    main()
        

15.15.4 Miscellaneous

A utils module was imported in both the view and model. It consists of some custon utility routines. Here it is...


def padl(s, l):
    """
    Pads s with spaces on the left to a length of l
    :param s: str -> string to pad
    :param l: int -> length for final string
    :return: str -> padded string s
    """
    return ' ' * (l - len(s)) + s


def padr(s, l):
    """
    Pads s with spaces on the right to a length of l
    :param s: str -> string to pad
    :param l: int -> length for final string
    :return: str -> padded string s
    """
    return s + ' '*(l - len(s))
        

15.15.5 Conclusion

The MVC programming pattern is a great way to separate functionality into logical units. It simplifies code, making it easier to follow and to maintain. The more modular structure makes future code changes easier to manage.

Switching to a different database model? Simply change out the model code. Changing to a different GUI framework? Only the view code needs to be changed. Found an issue in how data is written to a file? You only need to look in the model code, not code that is scattered throughout a single code file.

Coding in MVC takes a little getting used to, but it's worth it. Model and View don't even need to know about each other. Controller contains program (so-called business) logic and plays middleman to Model and View.

15.15.6 Screens

These are the various screens for this project.

Figure 15.14.1. ATM Splash Screen
Figure 15.14.2. ATM Main Menu