Введение #

Каждый год Томский Кванториум проводит конкурс программирования, в данном случае речь пойдёт о “Соревнование «Coding Fest. Семейный код» 2024”. Я с моей командой выбрали трек разработки приложения для настольных компьютеров.

Задача стояла:

Кейс «Приложение «Семейный бюджет»
Цель: создать приложения на ПК для ведения семейного бюджета с возможностью добавления доходов и расходов.
Функционал: в приложении должна быть реализована возможность вводить следующие данные: дата, сумма дохода, сумма расхода. Введенные данные заносятся в таблицу Excel, где в 1 столбце – дата, во 2 – сумма дохода, в 3 – сумма расходов, а в 4 должна вычисляться оставшаяся сумма. В приложении нужно добавить возможность вывести оставшуюся сумму, сумму доходов, сумму расходов за определенный промежуток времени и за все время.
Язык программирования: Python
Отправка работ: необходимо отправить архив, в названии которого нужно указать название вашей команды. В архиве должен быть файл с кодом и все файлы необходимые для его работы, в том числе файл Excel, а также текстовый файл, в котором будет описан его функционал и при необходимости краткая инструкция пользования.

Полезные ссылки:

  • Мастер класс: Профессия «Финансовый разработчик»: http://technopredki.ru/page56296793.html
  • Работа с библиотекой tkinter:https://python-scripts.com/tkinter
Критерий Описание Баллы
Функциональность Выполняет ли приложение все базовые функции? 0-20
Интуитивность интерфейса Насколько легко пользователям ориентироваться в приложении? 0-15
Удобство использования Насколько удобно добавлять,редактировать и удалять записи? 0-15
Работа с данными Поддерживает ли приложение импорт/экспорт данных Excel? 0-10
Отчетность и визуализация Представляет ли приложение информацию о бюджете в виде графиков и отчетов? 0-10
Обработка ошибок Как приложение обрабатывает некорректные данные или ошибки ввода? 0-10
Производительность Насколько быстро приложение работает (добавление/удаление записи, расчет и т.д.)? 0-10
Эстетика интерфейса Нравится ли внешний вид приложения (дизайн, шрифты, цветовая палитра)? 0-5
Дополнительные баллы Присуждаются по желанию эксперта 0-5
А и да..... Для всего проекта было дано 3 дня

Начало программирования #

Начала было положено выбором стэка, т.к. даже у организаторов была программа на Pandas, он и был выбран в качестве движка для программы. Для интерфейса же изначально был выбран tkinter.

Сначала мы начали выводом таблицы и разводкой интерфейса на удобные части. В результате этого процесса я заметил, что у tkinter не имеется удобного меню для выбора даты. Поэтому я провёл максимально быстрое мигрирование на pySide6(PyQT).

Первая версия интерфейса

В результате первого дня был сделан первый набросок интерфейса, импорт/экспорт(без проверки, она будет дальше), добавление и редактирование записей в отдельной вкладке

Код
import sys
import datetime
from dateutil import parser

import pandas
from pandas.tseries import offsets

from PySide6.QtWidgets import QApplication, QDialog, QMainWindow, QPushButton, QDialogButtonBox, QVBoxLayout, QLabel, QTableWidget, QMenu,QAbstractItemView
from PySide6.QtGui import QPalette, QColor
from PySide6.QtWidgets import QApplication, QTableWidgetItem
import PySide6.QtCore
from PySide6.QtCore import QDate, QRect
from PySide6 import QtWidgets
from PySide6.QtUiTools import QUiLoader

expenses = pandas.read_excel("FamilyBudget.xlsx")
expenses = expenses.sort_values("Дата", ascending=False)
expenses["Оставшаяся сумма"] = expenses["Сумма дохода"] - \
    expenses["Сумма расхода"]

expenses = expenses.groupby('Дата').sum().reset_index()
print(expenses.groupby('Дата').sum())
this_year = datetime.date.today() - offsets.YearBegin()
this_month = datetime.date.today() - offsets.MonthBegin()

today = datetime.datetime.today()+datetime.timedelta(days=1)
first = today.replace(day=1)
last_month = first - datetime.timedelta(days=1)

first_day_of_current_month = datetime.date.today().replace(day=1)
last_day_of_previous_month = first_day_of_current_month - \
    datetime.timedelta(days=1)

previous_year = datetime.datetime(today.year - 1, 1, 1)

def update_set_ui(currentRow, currentColumn, previousRow, previousColumn):
    print(currentRow, currentColumn, previousRow, previousColumn)


def button_clicked(window):
    start = datetime.datetime.combine(
        window.StartDate.date().toPython(), datetime.datetime.min.time())
    end = datetime.datetime.combine(
        window.EndDate.date().toPython(), datetime.datetime.min.time())
    summ = expenses.loc[(start < expenses["Дата"]) & (
        expenses["Дата"] < end), "Сумма дохода"].sum()

    summ_expens = expenses.loc[(start < expenses["Дата"]) & (
        expenses["Дата"] < end), "Сумма расхода"].sum()
    summ_res = expenses.loc[(start < expenses["Дата"]) & (
        expenses["Дата"] < end), "Оставшаяся сумма"].sum()
    dlg = CustomDialog(start, end, window, summ, summ_expens, summ_res)
    if dlg.exec():
        print("Success!")
    else:
        print("Cancel!")


def redact_element(window,):
    pass


class MyWindow(QTableWidget):
    def __init__(self,parent):
        super().__init__(parent)

        # Create the context menu and add some actions
        self.context_menu = QMenu(self)
        action1 = self.context_menu.addAction("Изменить")
        action2 = self.context_menu.addAction("Удалить")

        # Connect the actions to methods
        action1.triggered.connect(self.action1_triggered)
        action2.triggered.connect(self.action2_triggered)
        self.show()

    def contextMenuEvent(self, event):
        # Show the context menu
        self.context_menu.exec(event.globalPos())

    def action1_triggered(self):
        # Handle the "Action 1" action
        pass

    def action2_triggered(self):
        global  expenses
        print(super().currentRow())
        print(parser.parse(super().itemAt(0, super().currentRow()).text()))
        expenses = expenses.drop(expenses.isin([parser.parse(super().itemAt(0, super().currentRow()).text())]).any(axis=1).idxmax())
        self.render_table()
        # Handle the "Action 2" action
        pass

    def render_table(self):
        global expenses
        expenses_p = expenses.values.tolist()

        super().setRowCount(len(expenses_p))

        for y in range(len(expenses_p)):
            for x in range(4):
                super().setItem(
                    y, x, QTableWidgetItem(str(expenses_p[y][x])))

class CustomDialog(QDialog):
    def __init__(self, start, end, parent=None, incomes=0, expenses=0, results=0):
        super().__init__(parent)

        self.setWindowTitle("Отчёт по дате")

        QBtn = (
            QDialogButtonBox.StandardButton.Ok)

        self.buttonBox = QDialogButtonBox(QBtn)
        self.buttonBox.accepted.connect(self.accept)
        self.buttonBox.rejected.connect(self.reject)

        layout = QVBoxLayout()
        message = QLabel(f"За промежуток с {start} до {end}:\n доходов на {incomes} расходов на {
                         expenses}\nи итоговый бюджет на {results} рублей.")
        layout.addWidget(message)
        layout.addWidget(self.buttonBox)
        self.setLayout(layout)


def submit_info(window, date, plus, minus):
    add_new_thing(date, plus, minus)
    render_table(window)


def add_new_thing(date, plus, minus):
    global expenses

    expenses.loc[len(expenses)] = [datetime.datetime.combine(
        date.toPython(), datetime.datetime.min.time()), plus, minus, 0]


def render_table(window):
    expenses_p = expenses.values.tolist()

    window.budgetTable.setRowCount(len(expenses_p))

    for y in range(len(expenses_p)):
        for x in range(4):
            window.budgetTable.setItem(
                y, x, QTableWidgetItem(str(expenses_p[y][x])))


def set_time_using_choice(window):
    times = {0: (this_month, today),
             1: (last_month, this_month),
             2: (this_year, today),
             3: (previous_year, this_year),
             4: (expenses["Дата"].min(), expenses["Дата"].max())}

    choice = window.ChosedPeriod.currentIndex()

    window.StartDate.setDate(QDate(times[choice][0]))
    window.EndDate.setDate(QDate(times[choice][1]))



if __name__ == "__main__":
    app = QApplication(sys.argv)

    ui_file_name = "main.ui"
    ui_file = PySide6.QtCore.QFile(ui_file_name)
    if not ui_file.open(PySide6.QtCore.QFile.ReadOnly):
        print(f"Cannot open {ui_file_name}: {ui_file.errorString()}")
        sys.exit(-1)
    loader = QUiLoader()
    window = loader.load(ui_file)
    ui_file.close()
    window.setFixedSize(900, 700)

    set_time_using_choice(window)
    window.dateEdit.setDate(QDate(datetime.date.today()))
    window.AddValue.clicked.connect(lambda: submit_info(window,
                                                        window.dateEdit.date(), window.ExpenseValue.value(), window.IncomeValue.value()))
    window.ApplyPeriodButton.clicked.connect(
        lambda: set_time_using_choice(window))
    window.MakeReport.clicked.connect(lambda: button_clicked(window))
    window.DeleteButton.clicked.connect(lambda: window.budgetTable.action2_triggered())
    window.budgetTable = MyWindow(window.centralwidget)
    if (window.budgetTable.columnCount() < 4):
        window.budgetTable.setColumnCount(4)
    __qtablewidgetitem = QTableWidgetItem("Дата")
    window.budgetTable.setHorizontalHeaderItem(0, __qtablewidgetitem)
    __qtablewidgetitem1 = QTableWidgetItem("Доходы")
    window.budgetTable.setHorizontalHeaderItem(1, __qtablewidgetitem1)
    __qtablewidgetitem2 = QTableWidgetItem("Расходы")
    window.budgetTable.setHorizontalHeaderItem(2, __qtablewidgetitem2)
    __qtablewidgetitem3 = QTableWidgetItem("Разница")
    window.budgetTable.setHorizontalHeaderItem(3, __qtablewidgetitem3)
    window.budgetTable.setObjectName(u"budgetTable")
    window.budgetTable.setGeometry(QRect(10, 150, 891, 501))
    window.budgetTable.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
    window.budgetTable.setSortingEnabled(True)
    window.budgetTable.setColumnCount(4)
    window.budgetTable.horizontalHeader().setCascadingSectionResizes(True)
    window.budgetTable.horizontalHeader().setDefaultSectionSize(180)
    window.budgetTable.horizontalHeader().setProperty(u"showSortIndicator", True)
    window.budgetTable.horizontalHeader().setStretchLastSection(True)
    window.budgetTable.verticalHeader().setVisible(False)
    window.budgetTable.verticalHeader().setCascadingSectionResizes(False)
    window.budgetTable.verticalHeader().setMinimumSectionSize(40)
    window.budgetTable.verticalHeader().setHighlightSections(False)
    window.budgetTable.verticalHeader().setProperty(u"showSortIndicator", False)
    window.budgetTable.verticalHeader().setStretchLastSection(False)
    window.budgetTable.setAlternatingRowColors(True)

    window.budgetTable.setColumnCount(4)
    window.budgetTable.setSelectionBehavior(QtWidgets.QTableView.SelectRows)
    app.aboutToQuit.connect(lambda:
                            expenses.to_excel("FamilyBudget.xlsx", index=False))

    render_table(window)

    if not window:
        print(loader.errorString())
        sys.exit(-1)

    window.show()

    sys.exit(app.exec())

Второй день начался

Отчёты в программе #

Тестирование #

Конец #

![[1.png]]