15.15 MVC Programming
The MVC Programming pattern consists of three parts: Model, View, and Controller
- Model - the model directly manages an application’s data and logic. If the model changes, the model sends commands to update the user’s view.
- View - the view presents the results of the application to the user. It is in charge of all program output.
- Controller - the controller accepts all user input and sends commands to the model to change the model’s state.
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:
- Check the data model (database) to see if an account number is valid
- Read an account balance
- Update an account balance
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.








