In this tutorial, we will build a modern "Inventory Management System" using Python, Tkinter, and CustomTkinter. The application includes user login and signup functionality, image uploads for inventory items, form handling, and dashboard management. We will also explore how to display uploaded product images safely, manage UI state correctly, and avoid common Tkinter image issues such as the "pyimage error." By the end of this tutorial, you will have a clean and functional desktop inventory application with a modern user interface.
Prerequisite:
This tutorial is part of the CustomTkinter Inventory Management System Series.
📚 View the Complete CustomTkinter Inventory Management System Series
Preliminary:
Before I begin, please activate the virtual environment and install the required dependencies.
python -m venv venv
venv\Scripts\activate
pip install customtkinter CTkDateEntry
The other dependencies are pre-installed with Python, including uuid, the image library, os, and shutil.Then, we need to set up the file and folder structure, as below:
All the files and folders remain the same as in the previous tutorial, except that in this tutorial, I have added an uploads folder and a few images under the media folder to preload the default picture for the upload. I will work on both app.py and toplevel.py.
Step 1: import the relevant libraries and set up the CTkfont and CTkimages
Before I begin, I have to import the libraries, including uuid, PIL, ctkdateentry, and so on. Next, I will set up the images and the font. Finally, I create and point out the uploads folder path, as shown in the code below:
import os
import shutil
from tkinter import filedialog
import customtkinter as ctk
from toplevel import open_login_window
from customtkinter import CTkImage
from PIL import Image
from ctkdateentry import CTkDateEntry, CTkStringVar
import uuid
root = ctk.CTk()
root.geometry("800x800")
root.title('Inventory Management System')
root.iconbitmap("inventory/media/icon.ico")
ctk.set_appearance_mode('dark')
ctk.set_default_color_theme('inventory/custom_theme.json')
# Create a bold custom font for labels
font_bold = ctk.CTkFont(family="Roboto", size=20, weight="bold")
# Load the title image for the header section
login_title = CTkImage(
dark_image=Image.open("inventory/media/title.png"),
size=(700, 150))
# Load the default placeholder image for inventory items
# This image is shown before a user uploads a new picture
default_image = CTkImage(
dark_image=Image.open("inventory/media/default.png"),
size=(350, 200))
# Folder to store uploaded files
UPLOAD_FOLDER = "inventory/uploads"
# Create folder if it doesn't exist
os.makedirs(UPLOAD_FOLDER, exist_ok=True)Once the setup is ready, I will create the header and body of the app as follows:
- Header - CTkLabel
- Log out - CTkButton
- Item Name - CTkComboBox
- Vendor/Customer - CTkEntry
- Address - CTkTextbox
- Email - CTkEntry
- Reorder date - CTkDateEntry (import from CTkDateEntry)
- selected date - CTkLabel
- Stock status - CTkRadioButton
- Quantity - CTkEntry
- Unit price - CTkSlider
# title image at the top of the dashboard
login_image = ctk.CTkLabel(root, image=login_title, text="")
login_image.image = login_title
login_image.grid(row=0, column=0, columnspan=4,
padx=10, pady=10, sticky='nsew')
# log out button at the top right corner of the dashboard
log_out_button = ctk.CTkButton(root, text="Log Out", state='disabled',
command=log_out)
log_out_button.grid(row=0, column=4,
padx=10, pady=20, sticky='e')
# Create a frame to hold all dashboard input fields and controls
dashboard_frame = ctk.CTkFrame(root, width=600, height=600, fg_color="grey")
dashboard_frame.grid(row=1, column=0, columnspan=5,
padx=10, pady=10, sticky="nsew")
# Configure 5 columns in the dashboard frame
# Each column is given equal weight so they expand evenly
# when the application window is resized
for i in range(5):
dashboard_frame.grid_columnconfigure(i, weight=1)
# Configure 8 rows in the dashboard frame
# Each row is also given equal weight for responsive resizing
for i in range(8):
dashboard_frame.grid_rowconfigure(i, weight=1)
item_name_label = ctk.CTkLabel(dashboard_frame, text="Item Name:",
font=font_bold)
item_name_label.grid(row=0, column=0, padx=10, pady=10)
# Placeholder function for combobox selection events
def combobox_callback(choice):
pass
item_name_entry = ctk.CTkComboBox(dashboard_frame,
values=["Fruit", "Vegetable"],
state='disabled',
command=combobox_callback)
item_name_entry.grid(row=0, column=1, columnspan=4, padx=10, pady=10,
sticky="ew")
item_name_entry.set("Select an item")
name_label = ctk.CTkLabel(dashboard_frame, text="Vendor/Customer:",
font=font_bold)
name_label.grid(row=1, column=0, padx=10, pady=10)
name_entry = ctk.CTkEntry(dashboard_frame,
state='disabled')
name_entry.grid(row=1, column=1, columnspan=4, padx=10, pady=10, sticky="ew")
address_label = ctk.CTkLabel(dashboard_frame, text="Address:", font=font_bold)
address_label.grid(row=2, column=0, padx=10, pady=10)
address_entry = ctk.CTkTextbox(dashboard_frame,
height=100,
state='disabled')
address_entry.grid(row=2, column=1, columnspan=4, padx=10, pady=10, sticky="ew")
email_label = ctk.CTkLabel(dashboard_frame, text="Email:", font=font_bold)
email_label.grid(row=3, column=0, padx=10, pady=10)
email_entry = ctk.CTkEntry(dashboard_frame,
state='disabled')
email_entry.grid(row=3, column=1, columnspan=4, padx=10, pady=10, sticky="ew")
reorder_label = ctk.CTkLabel(dashboard_frame, text="Reorder Date:", font=font_bold)
reorder_label.grid(row=4, column=0, padx=10, pady=10)
# Use CTkStringVar to track changes in the CTkDateEntry widget
reorder_var = CTkStringVar(dashboard_frame)
def select_date_event(*args):
selected_date = reorder_var.get()
select_date_label.configure(
text=f"Selected date: {selected_date}"
)
reorder_var.trace_add("write", select_date_event)
reorder_entry = CTkDateEntry(
dashboard_frame,
width=150,
variable =reorder_var,
justify ='left',
font=('Roboto', 14, 'bold'))
reorder_entry.grid(row=4, column=1, padx=10, pady=10, sticky="w")
# Label to display the selected date from the CTkDateEntry widget
select_date_label = ctk.CTkLabel(dashboard_frame, text="No date selected.")
select_date_label.grid(row=5, column=1, columnspan=2, padx=10, pady=10, sticky="w")
stock_status_label = ctk.CTkLabel(dashboard_frame, text="Stock Status:",
font=font_bold)
stock_status_label.grid(row=6, column=0, padx=10, pady=10)
# Placeholder function for radio button events
def radiobutton_event():
pass
# Use StringVar to track the selected value of the radio buttons
radio_var = ctk.StringVar(value="1")
radiobutton_1 = ctk.CTkRadioButton(
dashboard_frame,
text="available for sales",
command=radiobutton_event,
variable=radio_var,
value="1",
state='disabled'
)
radiobutton_1.grid(row=6, column=1, sticky='w')
radiobutton_2 = ctk.CTkRadioButton(
dashboard_frame,
text="internal use only",
command=radiobutton_event,
variable=radio_var,
value="2",
state='disabled'
)
radiobutton_2.grid(row=7, column=1, sticky='w')
quantity_label = ctk.CTkLabel(dashboard_frame, text="Quantity:", font=font_bold)
quantity_label.grid(row=8, column=0, padx=10, pady=10)
quantity_entry = ctk.CTkEntry(dashboard_frame, width=100,
state='disabled')
quantity_entry.grid(row=8, column=1, padx=10, pady=10, sticky='w')
unit_price_label = ctk.CTkLabel(dashboard_frame, text="Unit Price:", font=font_bold)
unit_price_label.grid(row=9, column=0, padx=10, pady=10)
# Placeholder function for slider events
def slider_event(value):
# Get current value from the CTkSlider
price = unit_price_entry.get()
# Format and display the price with 2 decimal places
price_label.configure(text=f"{price:.2f}")
unit_price_entry = ctk.CTkSlider(dashboard_frame, from_=0, to=100,
command=slider_event, width=150,
state='disabled')
unit_price_entry.grid(row=9, column=1, padx=10, pady=10, sticky="w")
# Label to display the current price selected on the slider with 2 decimal places
price_label = ctk.CTkLabel(dashboard_frame, text="0.00")
price_label.grid(row=9, column=2, padx=10, pady=10,
sticky="w")
Except for CTkDateEntry, the other widgets are disabled before users log in and become normal after the user is logged in.
Step 3: Adding a display picture, an upload button and a label path
Now I am ready to create the following widgets:
- Display picture - CTkLabel
- Upload button - CTkButton
- Upload path - CTkLabel
# Picture label for displaying the item image
picture_label = ctk.CTkLabel(dashboard_frame, image=default_image,
width=350, height=200, fg_color="white")
picture_label.grid(row=4, column=3, columnspan=2, rowspan=4,
padx=10, pady=10, sticky="nsew")
picture_label.image = default_image
# Button to upload item picture
upload_button = ctk.CTkButton(dashboard_frame, text="Upload picture",
state='disabled',
command=upload_picture)
upload_button.grid(row=8, column=3, padx=10, pady=10, sticky='e')
# Label to show upload status and filename of the uploaded picture
update_label = ctk.CTkLabel(dashboard_frame, text="No file uploaded yet.")
update_label.grid(row=8, column=4, padx=10, pady=10, sticky='w')
The display picture is loaded with a default picture from the media folder. If the user uploads a new picture, it will change to a new picture and show an upload path. Vice versa, if the user logs out, it will return the default picture and clear the upload path.
However, I did not load any default picture. Once I log out, it will incur a "PyImage 7" error.
What is "PyImage7" actually?
pyimage7 is
- a temporary Tkinter internal image ID
- created when I use CTkImage
- stored in RAM only
- tied to the widget and Tk instance
The picture label still wants to be used, and Tkinter has already deleted it from memory (when I logged out). Since the "PyImage7" is not available. Therefore, this error was incurred.
So, when the user uploads a picture, the code is shown as follows:
def upload_picture():
# Open a file dialog to select image files
file_path = filedialog.askopenfilename(
filetypes=[("Images", "*.png *.jpg *.jpeg")]
)
# Continue only if a file was selected
if file_path:
# Get the file extension (e.g. .png, .jpg
ext = os.path.splitext(file_path)[1]
# Generate a unique filename using UUID
# This prevents duplicate filenames
filename = f"{uuid.uuid4().hex}{ext}"
# Create the full destination path
dest = os.path.join(UPLOAD_FOLDER, filename)
# Copy the selected image into the uploads folder
shutil.copy(file_path, dest)
# Display the uploaded image in the picture label
display_image(dest)
# Create shorter display name
display_name = filename[:15] + "..."
# Update the status label with the uploaded filename
update_label.configure(
text=f"Uploaded:\n{display_name}",
wraplength=180,
justify="left"
)
def display_image(image_path):
# Open the image using Pillow
img = Image.open(image_path)
# Create a CTkImage for CustomTkinter
# The image will be resized to 350x200 pixels
new_image = ctk.CTkImage(
light_image=img,
dark_image=img,
size=(350, 200)
)
# Display the image in the label
# Remove the default text when the image is shown
picture_label.configure(image=new_image, text="")
# Keep a reference to prevent garbage collection
# This avoids Tkinter pyimage errors
picture_label.image = new_imageThe first function focuses on the process of how the user uploads and stores the picture in the Upload folder and assigns a UUID number for each picture to avoid duplication of the picture with a similar file name.
Meanwhile, the second function describes how to open, resize, and change the default picture to an uploaded picture and to display the uploaded picture in the label.
In the last tutorial, I created a simple log-out function. However, in this tutorial, I also added a few widgets as shown in Step 3 above. Therefore, now the logout function should include all those widgets.
I will set the following widgets' state as follows:
- log-out button - disabled
- item_name entry - disabled
- name_entry - disabled
- address_entry - disabled
- email_entry - disabled
- Stock_status_label - disabled
- radiobutton - disabled
- quantity_entry - disabled
- unit-price_entry - disabled
- Update_label - disabled
Before logging out of the app, I also need to delete all the relevant data, including reloading the default picture for the picture label.
Here is my code below:
def log_out():
# Open the login window again after logging out
open_login_window(root, log_out_button, item_name_entry, name_entry,
address_entry, email_entry,
stock_status_label, radiobutton_1, radiobutton_2,
quantity_entry, unit_price_entry,
update_label, select_date_label, price_label)
# Clear all dashboard fields and reset UI
clear_dashboard()
# Disable dashboard buttons and input fields
# to prevent user interaction after logout
log_out_button.configure(state='disabled')
item_name_entry.configure(state='disabled')
name_entry.configure(state='disabled')
address_entry.configure(state='disabled')
email_entry.configure(state='disabled')
stock_status_label.configure(state='disabled')
radiobutton_1.configure(state='disabled')
radiobutton_2.configure(state='disabled')
quantity_entry.configure(state='disabled')
unit_price_entry.configure(state='disabled')
update_label.configure(state='disabled')
select_date_label.configure(state='disabled')
price_label.configure(state='disabled')
def clear_dashboard():
# Reset item selection dropdown
item_name_entry.set("Select an item")
# Clear text entry fields
name_entry.delete(0, 'end')
address_entry.delete("1.0", "end")
email_entry.delete(0, 'end')
# Reset reorder date and radio button selection
reorder_var.set('')
radio_var.set(0)
# Clear quantity and reset unit price
quantity_entry.delete(0, 'end')
unit_price_entry.set(0)
# Reset upload status label
update_label.configure(text="No file uploaded yet.")
select_date_label.configure(text="No date selected.")
# Restore the default placeholder image
# when the dashboard is cleared
picture_label.configure(
image=default_image,
text="Picture:")
# Keep a reference to the default image
# to avoid Tkinter pyimage errors
picture_label.image = default_image
price_label.configure(text="0.00")
Step 5: When logged in to the app
The logic for logging in to the toplevel.py file remains the same, except that in the previous tutorial, only the logout button was normal, but now all the widgets in step 4 above are set to normal too.
In addition, I also changed the function from destroy() to withdraw() in this tutorial.
The 'withdraw' function allows me to
- Hide the window temporarily
- Keep all widgets and variables alive
- Reopen the same window later quickly
The amendment to the toplevel.py file
# Open the login window and pass dashboard widgets
# so their states can be controlled after login/logout
def open_login_window(root, log_out_button, item_name_entry, name_entry,
address_entry, email_entry,
stock_status_label, radiobutton_1, radiobutton_2,
quantity_entry, unit_price_entry,
upload_button, update_label,
select_date_label, price_label):
def log_in():
value = login_signup_var.get()
email = login_email.get()
password = login_password.get()
confirm_password = confirm_password_entry.get()
# Load existing users
if os.path.exists('inventory/users.pkl'):
with open('inventory/users.pkl', 'rb') as f:
users = pickle.load(f)
else:
users = {}
# ------------------ LOGIN ------------------
if value == "Log In":
if email in users and users[email] == password:
CTkMessagebox(title="Success", message="Logged in successfully!",
icon="check")
log_out_button.configure(state='normal')
##### This part will change #####
# Enable dashboard buttons and input fields
# to allow user interaction after login
item_name_entry.configure(state='normal')
name_entry.configure(state='normal')
address_entry.configure(state='normal')
email_entry.configure(state='normal')
stock_status_label.configure(state='normal')
radiobutton_1.configure(state='normal')
radiobutton_2.configure(state='normal')
quantity_entry.configure(state='normal')
unit_price_entry.configure(state='normal')
upload_button.configure(state='normal')
update_label.configure(state='normal')
select_date_label.configure(state='normal')
price_label.configure(state='normal')
# Hide the window temporarily
toplevel.withdraw()
###### This part will change #####
return
if email == '' or password == '':
CTkMessagebox(title="Error", message="All fields are required!",
icon="cancel")
clear_fields()
return
else:
CTkMessagebox(title="Error", message="Invalid email or password!",
icon="cancel")
clear_fields()
return
# ------------------ SIGN UP ------------------
else:
if password != confirm_password:
CTkMessagebox(title="Error", message="Passwords do not match!",
icon="cancel")
clear_fields()
return
if email in users:
CTkMessagebox(title="Error", message="User already exists!",
icon="cancel")
clear_fields()
return
if email == '' or password == '' or confirm_password == '':
CTkMessagebox(title="Error", message="All fields are required!",
icon="cancel")
clear_fields()
return
else:
# Save new user
users[email] = password
with open('inventory/users.pkl', 'wb') as f:
pickle.dump(users, f)
clear_fields()
CTkMessagebox(title="Success",
message="Account created successfully!",
icon="check")
login_signup_var.set("Log In")
segmented_button_event("Log In")The state in the 'toplevel.py' file will take effect only if the function is also 'brought in' to app.py. Therefore, in app.py, I also need a necessary amendment, and below is my code:
# Import the login window function from the toplevel module
# This function is used to open the login/signup window
from toplevel import open_login_window
# Open the login window and pass all dashboard widgets
# so they can be enabled or disabled after login/logout
open_login_window(root, log_out_button, item_name_entry,
name_entry, address_entry, email_entry,
stock_status_label, radiobutton_1, radiobutton_2,
quantity_entry, unit_price_entry,
upload_button, update_label,
select_date_label, price_label)
Here is the demo of the app
In this tutorial, we built a modern "Inventory Management System" using Python with CustomTkinter. The application includes a secure login and signup system, a responsive dashboard interface, and full CRUD-style input handling for inventory items. Users can add product details, manage stock information, and upload images for each item with a clean preview system. The app also supports persistent file storage using UUID-based image naming to prevent conflicts and ensure reliable file management. With a structured UI, dynamic form controls, and safe image handling, this project demonstrates how to build a practical desktop application with a professional and user-friendly interface.
Published: May 2026
Last Updated: May 2026
About the Author
Kelvin Loh is a Python developer focused on Flask, desktop applications, and business automation solutions. He shares practical tutorials and real-world coding projects to help developers and small businesses build useful applications.




.gif)
Comments
Post a Comment