2
\$\begingroup\$

This is my third project for review; I would like feedback on:

  • code readability
  • code efficiency
  • particularly good parts

The project is called BirthdayBackup. It's a GUI app that allows you manage a list of everyone you know with their email, name and birthday date. It can send a random birthday blessing to people whose birthday is today, just in case you forgot it.

main.py

from birthday_planner import BirthdayPlanner
from tkinter import Tk, Canvas, Frame
from buttons import TextButton
from settings import Settings
from data import Data


def Main():
    main_window = Tk()
    main_window.title("Birthday Backup")
    width = 800
    height = 600
    main_window.geometry(f'{width}'
                         f'x{height}'
                         f'+{int((main_window.winfo_screenwidth() / 2) - width / 2)}'
                         f'+{int((main_window.winfo_screenheight() / 2) - height / 2)}')

    data = Data()

    main_window.config(bg='#45FFCA')

    logo = Canvas(master=main_window, width=300, height=150, highlightthickness=0, bg=main_window['bg'])
    logo.create_image(150, 75, image=data.logo)
    logo.place(x=(width * 0.5) - 150, y=height * 0.08)

    menu_frame = Frame(master=main_window,
                       bg=main_window['bg'],
                       width=300,
                       height=300)
    menu_frame.place(x=(width * 0.5) - 150,
                     y=height * 0.35)
    menu_padding = {'x': 0,
                    'y': 30
                    }

    birthday_list_btn = TextButton(master=menu_frame,
                                   text='Birthday Planner',
                                   command=lambda: BirthdayPlanner(master=main_window,
                                                                   data=data))
    birthday_list_btn.pack(padx=menu_padding['x'],
                           pady=menu_padding['y'])

    settings_btn = TextButton(master=menu_frame,
                              text='Settings',
                              command=lambda: Settings(master=main_window,
                                                       data=data))
    settings_btn.pack(padx=menu_padding['x'],
                      pady=menu_padding['y'])

    exit_btn = TextButton(master=menu_frame,
                          text='Exit',
                          command=main_window.destroy)
    exit_btn.pack(padx=menu_padding['x'],
                  pady=menu_padding['y'])

    main_window.resizable(width=False,
                          height=False)
    main_window.mainloop()


if __name__ == '__main__':
    Main()

birthday_planner.py

from serach_window import SearchWindow
from add_window import AddWindow
from tkinter.ttk import Treeview
from buttons import ImageButton
from tkinter import Label,Frame
import tkinter as tk
import pandas as pd


class BirthdayPlanner(tk.Frame):

    def __init__(self, master, data):
        super().__init__(master=master)

        self['bg'] = self.master['bg']
        self['width'] = self.master.winfo_width()
        self['height'] = self.master.winfo_height()
        self.data = data

        self.header_frame = Frame(master=self,
                                  bg=master['bg'],
                                  width=self['width'],
                                  height=int(self['height'] * 0.1))
        self.header_frame.pack()

        self.return_btn = ImageButton(master=self.header_frame,
                                      image=self.data.buttons['return'],
                                      command=self.destroy)
        self.return_btn.place(x=10,
                              y=10)

        self.headline = Label(master=self.header_frame,
                              text='Plan Birthday Backups',
                              borderwidth=0,
                              bg=self.header_frame['bg'],
                              font=('david', 30))
        self.headline.place(x=self['width'] * 0.25,
                            y=10)

        self.trash_btn = ImageButton(master=self.header_frame,
                                     image=self.data.buttons['trash'],
                                     command=self._delete_item)
        self.trash_btn.place(x=self['width'] - 50,
                             y=10)

        self.search_btn = ImageButton(master=self.header_frame,
                                      image=self.data.buttons['search'],
                                      command=self._search_item)
        self.search_btn.place(x=self['width'] - 110,
                              y=10)

        self.add_btn = ImageButton(master=self.header_frame,
                                   image=self.data.buttons['add'],
                                   command=self._add_item)
        self.add_btn.place(x=self['width'] - 180,
                           y=10)

        self.main_frame = Frame(master=self, bg=master['bg'],
                                width=self['width'],
                                height=self['height'] * 0.9)
        self.main_frame.pack()

        self.columns = self.data.data_file.columns.tolist()
        self.table = Treeview(master=self.main_frame,
                              height=26)
        self.table['columns'] = self.columns
        self.table.column("#0",
                          width=0,
                          stretch=False)
        for col in self.table['columns']:
            self.table.heading(col,
                               text=col,
                               anchor='center')
            self.table.column(col, width=int(self['width'] / 5),
                              anchor='center')

        self.fill_table()
        self.table.pack()

        self.pack()

    def _delete_item(self):
        if self._check_window(SearchWindow) and self._check_window(AddWindow) and self.table.selection() != ():
            index = self.table.selection()[0]
            self.data.data_file = self.data.data_file.drop([int(index)])
            self.data.update_file()
            self.table.delete(index)

    def _search_item(self):
        if self._check_window(SearchWindow) and self._check_window(AddWindow):
            utility_window = SearchWindow(master=self,
                                          data=self.data)
            self.master.bind('<Button-1>',
                             lambda _e: utility_window.lift())

    def _add_item(self):
        if self._check_window(AddWindow) and self._check_window(SearchWindow):
            add_popup_window = AddWindow(master=self,
                                         data=self.data)
            self.master.bind('<Button-1>',
                             lambda _e: add_popup_window.lift())

    def _check_window(self, obj):
        if obj not in [type(item) for item in self.winfo_children()]:
            return True
        return False

    def fill_table(self):
        for index, data in self.data.data_file.iterrows():
            if pd.isna(data[self.columns[3]]):
                data[self.columns[3]] = None

            self.table.insert(parent='',
                              index='end',
                              iid=index,
                              values=(data[self.columns[0]].capitalize(),
                                      data[self.columns[1]],
                                      data[self.columns[2]],
                                      data[self.columns[3]],
                                      data[self.columns[4]]))

    def erase_binding(self):
        self.master.unbind('<Button-1>')

    def update_table(self):
        self.table.delete(*self.table.get_children())
        self.fill_table()

add_window.py

from tkinter import Label, Entry
from buttons import TextButton
import tkinter as tk
import re


class AddWindow(tk.Toplevel):
    def __init__(self, master, data):
        super().__init__(master=master)

        self['width'] = 300
        self['height'] = 200
        self['bg'] = master['bg']
        self.geometry(f'{self['width']}x{self['height']}+830+450')
        self.resizable(width=False,
                       height=False)

        self.data = data

        self.name_label = Label(master=self,
                                text='Name:',
                                font=(self.data.font, 12),
                                bg=self['bg'])
        self.name_label.place(x=self['width'] * 0.07,
                              y=self['height'] * 0.1)

        name_entry = Entry(master=self,
                           borderwidth=0,
                           justify='center',
                           width=27)
        name_entry.place(x=self['width'] * 0.42,
                         y=self['height'] * 0.1)

        email_label = Label(master=self,
                            text='Email:',
                            font=(self.data.font, 12),
                            bg=self['bg'])
        email_label.place(x=self['width'] * 0.07,
                          y=self['height'] * 0.325)

        email_entry = Entry(master=self,
                            borderwidth=0,
                            justify='center',
                            width=27)
        email_entry.place(x=self['width'] * 0.42,
                          y=self['height'] * 0.325)

        birthday_date_label = Label(master=self,
                                    text='Birthday date:',
                                    font=(self.data.font, 12),
                                    bg=self['bg'])
        birthday_date_label.place(x=self['width'] * 0.07,
                                  y=self['height'] * 0.55)

        birthday_date_entry = Entry(master=self,
                                    borderwidth=0,
                                    justify='center',
                                    width=27)
        birthday_date_entry.place(x=self['width'] * 0.42,
                                  y=self['height'] * 0.56)

        self.error_message = Label(master=self, text='',
                                   font=(self.data.font, 12),
                                   bg=self['bg'], fg='red')
        self.error_message.place(x=self['width'] * 0.1,
                                 y=self['height'] * 0.67)

        cancel_btn = TextButton(master=self,
                                text='Cancel',
                                size=14,
                                command=self._destroy)
        cancel_btn.place(x=self['width'] * 0.15,
                         y=self['height'] * 0.8)

        save_btn = TextButton(master=self,
                              text='Save',
                              size=14,
                              command=lambda: self._validate_entry(name_entry.get(), email_entry.get(),
                                                                   birthday_date_entry.get()))
        save_btn.place(x=self['width'] * 0.65,
                       y=self['height'] * 0.8)

        self.bind('<Return>',
                  lambda _e: self._validate_entry(name_entry.get(), email_entry.get(), birthday_date_entry.get()))
        self.focus()

    def _destroy(self):
        self.master.erase_binding()
        self.destroy()

    def _validate_entry(self, name, email, date):
        name_format = r'\b[A-Za-z]{2,15} [A-Za-z]{2,15}\b'
        mail_format = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b'
        date_format = r'\b[0-9]{2}+-[0-9]{2}+-[0-9]{4}\b'

        if re.fullmatch(name_format, name) and re.fullmatch(mail_format, email) and re.fullmatch(date_format, date):
            self.data.data_file.loc[len(self.data.data_file)] = [name, email, date, []]
            self.data.update_file()
            self.master.update_table()
            self._destroy()
        else:
            self.error_message.config(text='one or more fields are incorrectly filled')

search_window.py

from tkinter import Label, Entry
from buttons import TextButton
import tkinter as tk
import re


class SearchWindow(tk.Toplevel):
    def __init__(self, master, data):
        super().__init__(master=master)

        self['width'] = 300
        self['height'] = 200
        self['bg'] = master['bg']
        self.geometry(f'{self['width']}x{self['height']}+830+450')
        self.resizable(width=False,
                       height=False)

        self.data = data

        self.search_label = Label(master=self,
                                  text='Enter name:',
                                  font=(self.data.font, 16),
                                  borderwidth=0, bg=self['bg'])
        self.search_label.pack(pady=30)

        self.search_entry = Entry(master=self,
                                  highlightthickness=0,
                                  bg='white',
                                  justify='center')
        self.search_entry.pack()

        self.search_btn = TextButton(master=self,
                                     text='search',
                                     size=12,
                                     command=self._search_item)
        self.search_btn.pack(pady=30)

        self.error_message = Label(master=self,
                                   text='',
                                   font=(self.data.font, 14),
                                   bg=self['bg'],
                                   fg='red')
        self.error_message.pack()

        self.bind('<Return>',
                  lambda _e: self._search_item())
        self.focus()

    def _destroy(self):
        self.master.erase_binding()
        self.destroy()

    def _search_item(self):
        search_key = self.search_entry.get()
        if search_key.lower() in self.data.data_file['Name'].tolist():
            for widget in self.winfo_children():
                widget.destroy()

            data = self.data.data_file.loc[self.data.data_file.index[self.data.data_file['Name'] == search_key.lower()]]
            name_label = Label(master=self,
                               text='Name',
                               font=(self.data.font, 12)
                               , bg=self['bg'])
            name_label.place(x=self['width'] * 0.1,
                             y=self['height'] * 0.2)

            name_entry = Entry(master=self,
                               borderwidth=0,
                               justify='center')
            name_entry.insert(index=0,
                              string=data['Name'].tolist()[0])
            name_entry.place(x=self['width'] * 0.5,
                             y=self['height'] * 0.2)

            birthday_date_label = Label(master=self,
                                        text='Birthday date',
                                        font=(self.data.font, 12),
                                        bg=self['bg'])
            birthday_date_label.place(x=self['width'] * 0.1,
                                      y=self['height'] * 0.5)

            birthday_date_entry = Entry(master=self,
                                        borderwidth=0,
                                        justify='center')
            birthday_date_entry.insert(index=0,
                                       string=data['Birthday date'].tolist()[0])
            birthday_date_entry.place(x=self['width'] * 0.5,
                                      y=self['height'] * 0.5)

            cancel_btn = TextButton(master=self,
                                    text='Cancel',
                                    size=14,
                                    command=self._destroy)
            cancel_btn.place(x=self['width'] * 0.15,
                             y=self['height'] * 0.8)

            save_btn = TextButton(master=self,
                                  text='Save',
                                  size=14,
                                  command=lambda: self._save(search_key,
                                                             name_entry.get(),
                                                             birthday_date_entry.get()))
            save_btn.place(x=self['width'] * 0.65,
                           y=self['height'] * 0.8)
        else:
            self.error_message.config(text='Name does not exist in the list')

    def _save(self, key, name, date):
        date_format = r'\b[0-9]{2}+-[0-9]{2}+-[0-9]{4}\b'
        if re.fullmatch(date_format,date):
            data = self.data.data_file.loc[self.data.data_file.index[self.data.data_file['Name'] == key.lower()]].copy()
            data['Name'] = name
            data['Birthday date'] = date
            self.data.data_file.loc[self.data.data_file.index[self.data.data_file['Name'] == key.lower()]] = data

            self.data.update_file()
            self.master.update_table()
        self._destroy()

settings.py

from email_birthday_script import email_birthday_script
from buttons import ImageButton, TextButton
from tkinter import Frame, Label, Canvas
from plyer import notification
import tkinter as tk
import os


class Settings(tk.Frame):
    def __init__(self, master, data):
        super().__init__(master=master)

        self['bg'] = self.master['bg']
        self['width'] = self.master.winfo_width()
        self['height'] = self.master.winfo_height()
        self.pack_propagate(False)
        self.data = data

        self.header_frame = Frame(master=self,
                                  bg=master['bg'],
                                  width=self['width'],
                                  height=int(self['height'] * 0.1))
        self.header_frame.pack()

        self.return_btn = ImageButton(master=self.header_frame,
                                      image=self.data.buttons['return'],
                                      command=self.destroy)
        self.return_btn.place(x=10,
                              y=10)

        self.headline = Label(master=self.header_frame,
                              text='Settings',
                              borderwidth=0,
                              bg=self.header_frame['bg'],
                              font=('david', 30))
        self.headline.place(x=self['width'] * 0.42,
                            y=10)

        self.main_frame = Frame(master=self,
                                bg=master['bg'],
                                width=self['width'],
                                height=self['height'] * 0.9)
        self.main_frame.pack()

        if os.path.exists(f'{os.getenv("APPDATA")}\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\BirthdayPlanner.bat'):
            checkbox_state = 'checked_box'
            _command = self._cancel_run_on_startup
        else:
            checkbox_state = 'unchecked_box'
            _command = self._run_on_startup

        self.add_checkbox = Canvas(master=self.main_frame,
                                   width=80, height=80,
                                   highlightthickness=0,
                                   bg=self['bg'])
        self.add_checkbox.create_image(40, 40,
                                       image=self.data.images[checkbox_state],
                                       tags='image')
        self.add_checkbox.place(x=self['width'] * 0.3,
                                y=self['height'] * 0.17)

        self.auto_startup_btn = TextButton(master=self.main_frame,
                                           text='Run on startup',
                                           size=25,
                                           command=_command)
        self.auto_startup_btn.place(x=self['width'] * 0.39,
                                    y=self['height'] * 0.20)

        self.manual_email_btn = TextButton(master=self.main_frame,
                                           text='Send emails manual',
                                           size=25,
                                           command=email_birthday_script)
        self.manual_email_btn.place(x=self['width'] * 0.33,
                                    y=self['height'] * 0.40)


        self.pack()

    def _run_on_startup(self):
        startup_path = f'{os.getenv("APPDATA")}\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\BirthdayPlanner.bat'
        if not os.path.exists(startup_path):
            with open(startup_path, 'w+') as batFile:
                batFile.write(f'{os.getcwd()[:2]}\n'
                              f'cd {os.getcwd()[3:]}\n'
                              f'python email_birthday_script.py\n'
                              f'exit')
                notification.notify(title='BirthdayPlanner',
                                    message='Run on startup activated',
                                    app_icon=None,
                                    timeout=10)
                self.auto_startup_btn.config(command=self._cancel_run_on_startup)
                self.add_checkbox.itemconfigure(tagOrId='image',
                                                image=self.data.images['checked_box'])

    def _cancel_run_on_startup(self):
        path = f'{os.getenv("APPDATA")}\\Microsoft\\Windows\\Start Menu\\Programs\\Startup\\BirthdayPlanner.bat'
        if os.path.exists(path):
            os.remove(path)
            notification.notify(title='BirthdayPlanner',
                                message='Run on startup cancaled',
                                app_icon=None,
                                timeout=10)
        self.auto_startup_btn.config(command=self._run_on_startup)
        self.add_checkbox.itemconfigure(tagOrId='image',
                                        image=self.data.images['unchecked_box'])

data.py

from tkinter import PhotoImage
import pandas as pd


class Data:
    def __init__(self):
        self.font = 'David'
        self.logo = PhotoImage(file='resources/Logo.png')
        self.buttons = {'return': PhotoImage(file='resources/return.png'),
                        'trash': PhotoImage(file='resources/trash.png'),
                        'search': PhotoImage(file='resources/search.png'),
                        'add': PhotoImage(file='resources/add.png')
                        }
        self.images = {'checked_box': PhotoImage(file='resources/checked.png'),
                       'unchecked_box': PhotoImage(file='resources/unchecked.png')
                       }
        self.data_file = pd.read_csv(filepath_or_buffer=f'resources/birthdays.csv',
                                     index_col=False).copy()

    def update_file(self):
        self.data_file.to_csv(path_or_buf=f'resources/birthdays.csv',
                              index=False)

buttons.py

import tkinter as tk


class TextButton(tk.Button):
    def __init__(self, master, text, command=print, size=30):
        super().__init__(master=master,
                         text=text,
                         borderwidth=0,
                         anchor='center',
                         command=command)

        self['bg'] = self.master['bg']
        self['font'] = ('David', size)
        self['activebackground'] = self.master['bg']


class ImageButton(tk.Button):
    def __init__(self, master, image, command=print):
        super().__init__(master=master,
                         image=image,
                         borderwidth=0,
                         anchor='center',
                         command=command)
        self['bg'] = self.master['bg']
        self['activebackground'] = self.master['bg']

email_birthday_script.py

from plyer import notification
import datetime as dt
import pandas as pd
import smtplib
import random


def _send_email(name, age, email, letter_id):
    sender_mail = 'EMAIL'
    password = 'PASSWORD'
    with open(f'resources/letter_{letter_id}.txt', encoding='UTF-8') as letter:
        letter = letter.read().replace('[NAME]',name).replace('[AGE]',age)
        with smtplib.SMTP("smtp.gmail.com") as smtp:
            smtp.starttls()
            smtp.login(user=sender_mail,
                       password=password)
            smtp.sendmail(from_addr=sender_mail,
                          to_addrs=email,
                          msg=f"Subject:Happy birthday\n\n{letter}".encode('UTF-8'))


def _get_letter_id(black_list):
    letter_id = random.randint(1, 5)
    while letter_id in black_list:
        letter_id = random.randint(1, 5)
        if len(black_list) >= 5:
            break
    return letter_id


def email_birthday_script():
    time = dt.datetime.now()
    current_date_segments = str(time.date()).split('-')
    current_date_segments = current_date_segments[::-1]

    data = pd.read_csv(f'resources\\birthdays.csv').copy()

    for date in data['Birthday date']:
        birthday_date_segments = date.split('-')
        if (current_date_segments[1] == birthday_date_segments[1]
                and current_date_segments[0] == birthday_date_segments[0]):
            row_data = data.loc[data['Birthday date'] == date].copy()
            age = str(int(current_date_segments[2]) - int(birthday_date_segments[2]))
            name = row_data['Name'].tolist()[0]
            email = row_data['Email'].tolist()[0]
            id_list = row_data['Letters sent(id)'].tolist()[0]
            mail_sent_today = row_data['Sent today'].tolist()[0]

            if pd.isna(id_list) or len(id_list) == 5:
                id_list = []
            else:
                id_list = [int(char) for char in id_list if char in range(1, 5)]
            letter_id = _get_letter_id(id_list)
            if mail_sent_today == 'N':
                _send_email(name=name,
                            age=age,
                            email=email,
                            letter_id=letter_id)
                id_list.append(letter_id)
                data.at[data.index[data['Birthday date'] == date][0], 'Letters sent(id)'] = id_list
                data.at[data.index[data['Birthday date'] == date][0], 'Sent today'] = 'Y'
                data.to_csv(path_or_buf='resources/birthdays.csv',
                            index=False)
                notification.notify(title='BirthdayPlanner',
                                    message=f'Email has been sent to {name}',
                                    app_icon=None,
                                    timeout=10)
            else:
                notification.notify(title='BirthdayPlanner',
                                    message=f'Email has been sent today already {name}',
                                    app_icon=None,
                                    timeout=10)
        else:
            data.at[data.index[data['Birthday date'] == date][0], 'Sent today'] = 'N'


if __name__ == '__main__':
    email_birthday_script()

Full code with resources is on GitHub - this version corresponds to commit 742ebd3b.

Other projects for review:

\$\endgroup\$
3
  • \$\begingroup\$ If the program is run multiple times on the same day, will it send multiple e-mails? \$\endgroup\$
    – Mast
    Jan 11 at 23:12
  • \$\begingroup\$ actually it does, infact if u add the program to your startup apps (you can do so from the settings page) and your pc will turn on twice at that date it will send 2 emails. i have thought about it, and wanted to add a feature that lets to bind the script with windows task scheduler but then i thought if the pc will be turned off at that time it wont send the email, eventually i came with it that the best idea is to run that code on a cloud or a server that can execute the task each day at the same time no matter what i have read a little bit about python anywhere API but i didnt kept further \$\endgroup\$
    – sean
    Jan 11 at 23:22
  • \$\begingroup\$ i have edited the code and added another column on the birthdays..csv file that marks if any mail has been sent today if so mark them with Y each time the list get checked if the date is not today the person will be marked with N once email has been sent it will change to Y and prevent sending 2 emails at the same day, and at the next day it will go back to N, still maybe not in this project but i think it will be good to user to use cloud service (such as python anywhere) and with python anywhere api make an option inisde the program to set specific time it will run by the cloud each day \$\endgroup\$
    – sean
    Jan 11 at 23:55

1 Answer 1

1
\$\begingroup\$

Code Efficiency

Pandas: Your use of Pandas for data handling is efficient and effective. However, for very large datasets, consider the implications on performance and perhaps look into optimizing data reads and writes.

Resource Loading: In data.py, resources like images are loaded during the initialization of the Data class. If these resources are large, this could be inefficient. Lazy loading might be a more efficient approach.

Error Handling: There's a lack of error handling throughout the application. Consider adding try-except blocks, especially around file I/O operations and email sending, to make the application more robust.

Additional Suggestions

Data Validation: More rigorous validation for inputs like email, name, and date could be implemented to ensure data integrity.

Security: Storing email credentials directly in the script (email_birthday_script.py) is not secure. Consider using environment variables or a secure credential management system.

Overall, you've done a great job. It's a well-structured and functional application with a clear and useful purpose. With some enhancements in error handling, code documentation, and security, it could be even better!

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.