Сегодня начнём делать собственное приложение для ведения бюджета — с графическим интерфейсом, базой данных и кросс-платформенностью. Для этого нам понадобится освежить знания про базы данных:
- База данных — это способ хранения данных в одном месте.
- Внутри базы могут храниться разные данные: фото, текст, музыка, числа, код, ссылки, цены и что угодно ещё.
- Когда говорят про базы данных, чаще всего имеют в виду табличные базы данных — те, где информация хранится в таблицах.
- Пример табличной базы — MySQL. Она многое умеет, к ней написано много документации и правил, поэтому начинают обычно с неё.
- Чтобы управлять данными в базе, используют специальный язык запросов к базе — SQL.
- У Python есть поддержка встраиваемой базы данных SQLite — она тоже поддерживает SQL, но для неё не нужно разворачивать и настраивать отдельную систему управления.
Ещё нам понадобится графический интерфейс. Для начала сделаем всё на простой библиотеке Tkinter, а потом, может, перейдём и на PySimpleGUI. Такой интерфейс нам нужен для того, чтобы управлять бюджетом можно было с помощью полей ввода и кнопок, а не из командной строки.
Всё будем писать на Python, поэтому его тоже нужно установить и настроить, если ещё не.
Как установить Python на компьютер и начать на нём писать
Логика проекта
Для начала мы сделаем простую версию программы, чтобы разобраться. У неё будет простой интерфейс и не будет поддержки горячих клавиш. Зато мы сразу проверим базу данных в деле.
Что программа будет уметь на старте:
- запоминать введённые покупки, их стоимость и комментарии к ним;
- удалять записи;
- искать по названию покупки;
- изменять выбранные записи;
- закрывать окно и спрашивать подтверждение выхода.
Это будет наша минимальная рабочая версия. Потом мы будем доводить её до ума.
Подключаем и настраиваем базу данных
Вся соль нашего приложения — в базе данных. В ней будут храниться все расходы, а в интерфейсе мы будем их выводить.
Чтобы было проще работать с базой, создадим отдельный класс — в нём мы опишем все действия, которые можно делать с базой данных, а потом будем вызывать их в программе.
👉 Если не знаете, что такое «класс» в программировании, вот вам простое объяснение.
В классе у нас будет два особых метода — конструктор и деструктор.
Конструктор выполняется в момент создания объекта. В нём мы пропишем название файла с базой данных и создадим таблицу, если её ещё нет.
Деструктор выполняет команды при завершении программы. Чтобы случайно не повредить базу, мы в деструкторе пропишем команду отсоединения от неё.
Все остальные методы — это просто обёртки SQL-команд. То есть смотрите: чтобы работать с базой данных, нужно давать ей особые команды. У них есть какой-то свой синтаксис. Нам не очень хочется каждый раз вспоминать этот синтаксис, поэтому мы «завернём» эти команды в более понятные нам.
Получается, что эти методы — это как бы переводчик с нашего языка на язык базы данных. Мы скажем База_данных.запомнить_трату(такую-то)
, а уже метод внутри этого объекта сам сформулирует правильный запрос, проверит подключение к базе, запишет строчку в базу, проверит эту строчку и т. д.
Читайте комментарии, чтобы разобраться, что происходит внутри каждого метода. Сейчас программа не делает ничего, кроме описания класса, поэтому при запуске мы тоже ничего не увидим.
# подключаем библиотеку для работы с базой данных
import sqlite3
# создаём класс для работы с базой данных
class DB:
# конструктор класса
def __init__(self):
# соединяемся с файлом базы данных
self.conn = sqlite3.connect("mybooks.db")
# создаём курсор для виртуального управления базой данных
self.cur = self.conn.cursor()
# если нужной нам таблицы в базе нет — создаём её
self.cur.execute(
"CREATE TABLE IF NOT EXISTS buy (id INTEGER PRIMARY KEY, product TEXT, price TEXT, comment TEXT)")
# сохраняем сделанные изменения в базе
self.conn.commit()
# деструктор класса
def __del__(self):
# отключаемся от базы при завершении работы
self.conn.close()
# просмотр всех записей
def view(self):
# выбираем все записи о покупках
self.cur.execute("SELECT * FROM buy")
# собираем все найденные записи в колонку со строками
rows = self.cur.fetchall()
# возвращаем сроки с записями расходов
return rows
# добавляем новую запись
def insert(self, product, price, comment):
# формируем запрос с добавлением новой записи в БД
self.cur.execute("INSERT INTO buy VALUES (NULL,?,?,?)", (product, price, comment,))
# сохраняем изменения
self.conn.commit()
# обновляем информацию о покупке
def update(self, id, product, price):
# формируем запрос на обновление записи в БД
self.cur.execute("UPDATE buy SET product=?, price=? WHERE id=?", (product, price, id,))
# сохраняем изменения
self.conn.commit()
# удаляем запись
def delete(self, id):
# формируем запрос на удаление выделенной записи по внутреннему порядковому номеру
self.cur.execute("DELETE FROM buy WHERE id=?", (id,))
# сохраняем изменения
self.conn.commit()
# ищем запись по названию покупки
def search(self, product="", price=""):
# формируем запрос на поиск по точному совпадению
self.cur.execute("SELECT * FROM buy WHERE product=?", (product,))
# формируем полученные строки и возвращаем их как ответ
rows = self.cur.fetchall()
return rows
# создаём экземпляр базы данных на основе класса
db = DB()
Создаём интерфейс
Мы хотим нарисовать графический интерфейс — то есть окно с кнопками, чтобы управлять базой не текстом, а понятными человеку движениями и кликами. Для создания графического интерфейса есть много библиотек, мы воспользуемся Tkinter.
В библиотеке Tkinter используется виртуальная сетка: каждую кнопку, поле ввода или что-то ещё можно расставить по этой сетке, а дальше библиотека сама посчитает координаты. За это отвечают параметры row и column, вы их увидите ниже в коде.
Единственное, где мы укажем явные размеры, — это блок с общим списком расходов. Это нужно для того, чтобы сетка не размещала всё максимально близко друг к другу:
list1 = Listbox(window, height=25, width=65)
Мы подписали каждый элемент в коде, чтобы было проще разобраться, что за что отвечает:
# создаём надписи для полей ввода и размещаем их по сетке
l1 = Label(window, text="Название")
l1.grid(row=0, column=0)
l2 = Label(window, text="Стоимость")
l2.grid(row=0, column=2)
l3 = Label(window, text="Комментарий")
l3.grid(row=1, column=0)
# создаём поле ввода названия покупки, говорим, что это будут строковые переменные и размещаем их тоже по сетке
product_text = StringVar()
e1 = Entry(window, textvariable=product_text)
e1.grid(row=0, column=1)
# то же самое для комментариев и цен
price_text = StringVar()
e2 = Entry(window, textvariable=price_text)
e2.grid(row=0, column=3)
comment_text = StringVar()
e3 = Entry(window, textvariable=comment_text)
e3.grid(row=1, column=1)
# создаём список, где появятся наши покупки, и сразу определяем его размеры в окне
list1 = Listbox(window, height=25, width=65)
list1.grid(row=2, column=0, rowspan=6, columnspan=2)
# на всякий случай добавим сбоку скролл, чтобы можно было быстро прокручивать длинные списки
sb1 = Scrollbar(window)
sb1.grid(row=2, column=2, rowspan=6)
# привязываем скролл к списку
list1.configure(yscrollcommand=sb1.set)
sb1.configure(command=list1.yview)
# создаём кнопки действий и привязываем их к своим функциям
# кнопки размещаем тоже по сетке
b1 = Button(window, text="Посмотреть все", width=12, command=print('view_command'))
b1.grid(row=2, column=3) #size of the button
b2 = Button(window, text="Поиск", width=12, command=print('search_command'))
b2.grid(row=3, column=3)
b3 = Button(window, text="Добавить", width=12, command=print('add_command'))
b3.grid(row=4, column=3)
b4 = Button(window, text="Обновить", width=12, command=print('update_command'))
b4.grid(row=5, column=3)
b5 = Button(window, text="Удалить", width=12, command=print('delete_command'))
b5.grid(row=6, column=3)
b6 = Button(window, text="Закрыть", width=12, command=print('on_closing'))
b6.grid(row=7, column=3)
Пишем логику работы
Сейчас у нас есть только интерфейс, который не умеет ничего. Даже при нажатии на кнопки мы получим только сообщение о том, какую функцию мы бы запустили, но самих функций у нас ещё нет.
Добавим внутреннюю логику в программу, а чтобы было понятнее, как всё устроено, разберём подробно функцию добавления новой записи.
Чтобы добавить новую запись о расходах, в программе должно появиться такое:
- С помощью метода .get() считываем, что написано в полях ввода в интерфейсе.
- Отправляем и сохраняем их в базе.
- Обновляем общий список покупок.
Запишем это в виде кода:
# обработчик нажатия на кнопку «Добавить»
def add_command():
# добавляем запись в БД
db.insert(product_text.get(), price_text.get(), comment_text.get())
# обновляем общий список в приложении
view_command()
В конце мы добавили функцию обновления списка, которой у нас ещё нет в коде, — view_command(). Её задача — очистить список и заполнить его новой информацией из базы данных. Добавим её в код:
# обработчик нажатия на кнопку «Посмотреть всё»
def view_command():
# очищаем список в приложении
list1.delete(0, END)
# проходим все записи в БД
for row in db.view():
# и сразу добавляем их на экран
list1.insert(END, row)
Нам осталось заменить команду print в обработчике действий кнопки на название нашей функции, чтобы кнопка работала как нужно:
b3 = Button(window, text="Добавить", width=12, command=add_command)
С этого момента мы можем добавлять новые записи о расходах, а программа будет сразу запоминать их в свою базу данных:
Для работы остальных кнопок нам понадобится функция, которая определит, какую именно запись в списке мы выделили. Ещё мы сразу будем отправлять выделенные данные в соответствующие поля ввода, чтобы было видно, что мы хотим изменить или удалить:
# заполняем поля ввода значениями выделенной позиции в общем списке
def get_selected_row(event):
# будем обращаться к глобальной переменной
global selected_tuple
# получаем позицию выделенной записи в списке
index = list1.curselection()[0] #this is the id of the selected tuple
# получаем значение выделенной записи
selected_tuple = list1.get(index)
# удаляем то, что было раньше в поле ввода
e1.delete(0, END)
# и добавляем туда текущее значение названия покупки
e1.insert(END, selected_tuple[1])
# делаем то же самое с другими полями
e2.delete(0, END)
e2.insert(END, selected_tuple[2])
e3.delete(0, END)
e3.insert(END, selected_tuple[3])
# привязываем выбор любого элемента списка к запуску функции выбора
list1.bind('<<ListboxSelect>>', get_selected_row)
Теперь у нас всё готово к тому, чтобы добавить жизнь во все остальные кнопки и привязать их к реальным функциям. Главное — не забудьте поменять вызовы в кнопках с print() на названия новых функций:
# обработчик нажатия на кнопку «Поиск»
def search_command():
# очищаем список в приложении
list1.delete(0, END)
# находим все записи по названию покупки
for row in db.search(product_text.get()):
# и добавляем их в список в приложение
list1.insert(END, row)
# обработчик нажатия на кнопку «Удалить»
def delete_command():
# удаляем запись из базы данных по индексу выделенного элемента
db.delete(selected_tuple[0])
# обновляем общий список расходов в приложении
view_command()
# обработчик нажатия на кнопку «Обновить»
def update_command():
# обновляем данные в БД о выделенной записи
db.update(selected_tuple[0], product_text.get(), price_text.get())
# обновляем общий список расходов в приложении
view_command()
Добавляем вопрос при выходе
Чтобы защитить приложение от случайного закрытия, добавим в него проверку при выходе — правда пользователь хочет это сделать или нет. Заодно правильно сообщим операционной системе о том, что мы закрыли приложение и можно освободить занятую им память.
Для этого добавим в код функцию и системное сообщение: при закрытии окна будет появляться сообщение с подтверждением. Если пользователь нажимает на «OK» — значит, всё в порядке и можно закрывать окно и освобождать память. В параметрах последней кнопки тоже заменим вывод текста на настоящую команду on_closing:
b6 = Button(window, text="Закрыть", width=12, command=on_closing)
# обрабатываем закрытие окна
def on_closing():
# показываем диалоговое окно с кнопкой
if messagebox.askokcancel("", "Закрыть программу?"):
# удаляем окно и освобождаем память
window.destroy()
# сообщаем системе о том, что делать, когда окно закрывается
window.protocol("WM_DELETE_WINDOW", on_closing)
# подключаем библиотеку для работы с базой данных
import sqlite3
# подключаем графическую библиотеку для создания интерфейсов
from tkinter import *
from tkinter import messagebox
# создаём класс для работы с базой данных
class DB:
# конструктор класса
def __init__(self):
# соединяемся с файлом базы данных
self.conn = sqlite3.connect("mybooks.db")
# создаём курсор для виртуального управления базой данных
self.cur = self.conn.cursor()
# если нужной нам таблицы в базе нет — создаём её
self.cur.execute(
"CREATE TABLE IF NOT EXISTS buy (id INTEGER PRIMARY KEY, product TEXT, price TEXT, comment TEXT)")
# сохраняем сделанные изменения в базе
self.conn.commit()
# деструктор класса
def __del__(self):
# отключаемся от базы при завершении работы
self.conn.close()
# просмотр всех записей
def view(self):
# выбираем все записи о покупках
self.cur.execute("SELECT * FROM buy")
# собираем все найденные записи в колонку со строками
rows = self.cur.fetchall()
# возвращаем сроки с записями расходов
return rows
# добавляем новую запись
def insert(self, product, price, comment):
# формируем запрос с добавлением новой записи в БД
self.cur.execute("INSERT INTO buy VALUES (NULL,?,?,?)", (product, price, comment,))
# сохраняем изменения
self.conn.commit()
# обновляем информацию о покупке
def update(self, id, product, price):
# формируем запрос на обновление записи в БД
self.cur.execute("UPDATE buy SET product=?, price=? WHERE id=?", (product, price, id,))
# сохраняем изменения
self.conn.commit()
# удаляем запись
def delete(self, id):
# формируем запрос на удаление выделенной записи по внутреннему порядковому номеру
self.cur.execute("DELETE FROM buy WHERE id=?", (id,))
# сохраняем изменения
self.conn.commit()
# ищем запись по названию покупки
def search(self, product="", price=""):
# формируем запрос на поиск по точному совпадению
self.cur.execute("SELECT * FROM buy WHERE product=?", (product,))
# формируем полученные строки и возвращаем их как ответ
rows = self.cur.fetchall()
return rows
# создаём новый экземпляр базы данных на основе класса
db = DB()
# заполняем поля ввода значениями выделенной позиции в общем списке
def get_selected_row(event):
# будем обращаться к глобальной переменной
global selected_tuple
# получаем позицию выделенной записи в списке
index = list1.curselection()[0] #this is the id of the selected tuple
# получаем значение выделенной записи
selected_tuple = list1.get(index)
# удаляем то, что было раньше в поле ввода
e1.delete(0, END)
# и добавляем туда текущее значение названия покупки
e1.insert(END, selected_tuple[1])
# делаем то же самое с другими полями
e2.delete(0, END)
e2.insert(END, selected_tuple[2])
e3.delete(0, END)
e3.insert(END, selected_tuple[3])
# обработчик нажатия на кнопку «Посмотреть всё»
def view_command():
# очищаем список в приложении
list1.delete(0, END)
# проходим все записи в БД
for row in db.view():
# и сразу добавляем их на экран
list1.insert(END, row)
# обработчик нажатия на кнопку «Поиск»
def search_command():
# очищаем список в приложении
list1.delete(0, END)
# находим все записи по названию покупки
for row in db.search(product_text.get()):
# и добавляем их в список в приложение
list1.insert(END, row)
# обработчик нажатия на кнопку «Добавить»
def add_command():
# добавляем запись в БД
db.insert(product_text.get(), price_text.get(), comment_text.get())
# обновляем общий список в приложении
view_command()
# обработчик нажатия на кнопку «Удалить»
def delete_command():
# удаляем запись из базы данных по индексу выделенного элемента
db.delete(selected_tuple[0])
# обновляем общий список расходов в приложении
view_command()
# обработчик нажатия на кнопку «Обновить»
def update_command():
# обновляем данные в БД о выделенной записи
db.update(selected_tuple[0], product_text.get(), price_text.get())
# обновляем общий список расходов в приложении
view_command()
# подключаем графическую библиотеку
window = Tk()
# заголовок окна
window.title("Бюджет 0.1")
# обрабатываем закрытие окна
def on_closing():
# показываем диалоговое окно с кнопкой
if messagebox.askokcancel("", "Закрыть программу?"):
# удаляем окно и освобождаем память
window.destroy()
# сообщаем системе о том, что делать, когда окно закрывается
window.protocol("WM_DELETE_WINDOW", on_closing)
# создаём надписи для полей ввода и размещаем их по сетке
l1 = Label(window, text="Название")
l1.grid(row=0, column=0)
l2 = Label(window, text="Стоимость")
l2.grid(row=0, column=2)
l3 = Label(window, text="Комментарий")
l3.grid(row=1, column=0)
# создаём поле ввода названия покупки, говорим, что это будут строковые переменные и размещаем их тоже по сетке
product_text = StringVar()
e1 = Entry(window, textvariable=product_text)
e1.grid(row=0, column=1)
# то же самое для комментариев и цен
price_text = StringVar()
e2 = Entry(window, textvariable=price_text)
e2.grid(row=0, column=3)
comment_text = StringVar()
e3 = Entry(window, textvariable=comment_text)
e3.grid(row=1, column=1)
# создаём список, где появятся наши покупки, и сразу определяем его размеры в окне
list1 = Listbox(window, height=25, width=65)
list1.grid(row=2, column=0, rowspan=6, columnspan=2)
# на всякий случай добавим сбоку скролл, чтобы можно было быстро прокручивать длинные списки
sb1 = Scrollbar(window)
sb1.grid(row=2, column=2, rowspan=6)
# привязываем скролл к списку
list1.configure(yscrollcommand=sb1.set)
sb1.configure(command=list1.yview)
# привязываем выбор любого элемента списка к запуску функции выбора
list1.bind('<<ListboxSelect>>', get_selected_row)
# создаём кнопки действий и привязываем их к своим функциям
# кнопки размещаем тоже по сетке
b1 = Button(window, text="Посмотреть все", width=12, command=view_command)
b1.grid(row=2, column=3) #size of the button
b2 = Button(window, text="Поиск", width=12, command=search_command)
b2.grid(row=3, column=3)
b3 = Button(window, text="Добавить", width=12, command=add_command)
b3.grid(row=4, column=3)
b4 = Button(window, text="Обновить", width=12, command=update_command)
b4.grid(row=5, column=3)
b5 = Button(window, text="Удалить", width=12, command=delete_command)
b5.grid(row=6, column=3)
b6 = Button(window, text="Закрыть", width=12, command=on_closing)
b6.grid(row=7, column=3)
# обновляем общий список расходов
view_command()
# пусть окно работает всё время до закрытия
window.mainloop()
Что дальше
У нас получилась минимальная версия программы по ведению бюджета. Она умеет делать только самые простые штуки, и пользоваться ей не очень удобно. В следующих сериях будем развивать программу:
- добавим поддержку горячих клавиш;
- сделаем более удобный интерфейс;
- добавим календарь, чтобы привязывать расходы к дате;
- добавим проверку на пустые значения названий и сумм;
- добавим поддержку категорий расходов;
- посчитаем, сколько уже потрачено в этом месяце и на что ушло больше всего;
- сделаем лимиты трат и настроим уведомления о них.
За один подход такое сделать сложно, поэтому будем обновлять программу постепенно. Подпишитесь, чтобы не пропустить выход новых частей.