Finance Manager Example - Part 1

This example represents the part one of the tutorial series on creating a simple Finance Manager that allows users to manage their expenses and visualize them using a pie chart, using PySide6, SQLAlchemy, FastAPI, and Pydantic.

For more details, see the Finance Manager Tutorial - Part 1.

Download this example

import sys
from pathlib import Path
from PySide6.QtWidgets import QApplication
from PySide6.QtQml import QQmlApplicationEngine

from financemodel import FinanceModel  # noqa: F401

if __name__ == '__main__':
    app = QApplication(sys.argv)
    QApplication.setOrganizationName("QtProject")
    QApplication.setApplicationName("Finance Manager")
    engine = QQmlApplicationEngine()

    engine.addImportPath(Path(__file__).parent)
    engine.loadFromModule("Finance", "Main")

    if not engine.rootObjects():
        sys.exit(-1)

    exit_code = app.exec()
    del engine
    sys.exit(exit_code)
from datetime import datetime
from dataclasses import dataclass
from enum import IntEnum
from collections import defaultdict

from PySide6.QtCore import (QAbstractListModel, QEnum, Qt, QModelIndex, Slot,
                            QByteArray)
from PySide6.QtQml import QmlElement

QML_IMPORT_NAME = "Finance"
QML_IMPORT_MAJOR_VERSION = 1


@QmlElement
class FinanceModel(QAbstractListModel):

    @QEnum
    class FinanceRole(IntEnum):
        ItemNameRole = Qt.ItemDataRole.DisplayRole
        CategoryRole = Qt.ItemDataRole.UserRole
        CostRole = Qt.ItemDataRole.UserRole + 1
        DateRole = Qt.ItemDataRole.UserRole + 2
        MonthRole = Qt.ItemDataRole.UserRole + 3

    @dataclass
    class Finance:
        item_name: str
        category: str
        cost: float
        date: str

        @property
        def month(self):
            return datetime.strptime(self.date, "%d-%m-%Y").strftime("%B %Y")

    def __init__(self, parent=None) -> None:
        super().__init__(parent)
        self.m_finances = []
        self.m_finances.append(self.Finance("Mobile Prepaid", "Electronics", 20.00, "15-02-2024"))
        self.m_finances.append(self.Finance("Groceries-Feb-Week1", "Groceries", 60.75,
                                            "16-01-2024"))
        self.m_finances.append(self.Finance("Bus Ticket", "Transport", 5.50, "17-01-2024"))
        self.m_finances.append(self.Finance("Book", "Education", 25.00, "18-01-2024"))

    def rowCount(self, parent=QModelIndex()):
        return len(self.m_finances)

    def data(self, index: QModelIndex, role: int):
        row = index.row()
        if row < self.rowCount():
            finance = self.m_finances[row]
            if role == FinanceModel.FinanceRole.ItemNameRole:
                return finance.item_name
            if role == FinanceModel.FinanceRole.CategoryRole:
                return finance.category
            if role == FinanceModel.FinanceRole.CostRole:
                return finance.cost
            if role == FinanceModel.FinanceRole.DateRole:
                return finance.date
            if role == FinanceModel.FinanceRole.MonthRole:
                return finance.month
        return None

    @Slot(result=dict)
    def getCategoryData(self):
        category_data = defaultdict(float)
        for finance in self.m_finances:
            category_data[finance.category] += finance.cost
        return dict(category_data)

    def roleNames(self):
        roles = super().roleNames()
        roles[FinanceModel.FinanceRole.ItemNameRole] = QByteArray(b"item_name")
        roles[FinanceModel.FinanceRole.CategoryRole] = QByteArray(b"category")
        roles[FinanceModel.FinanceRole.CostRole] = QByteArray(b"cost")
        roles[FinanceModel.FinanceRole.DateRole] = QByteArray(b"date")
        roles[FinanceModel.FinanceRole.MonthRole] = QByteArray(b"month")
        return roles

    @Slot(int, result='QVariantMap')
    def get(self, row: int):
        finance = self.m_finances[row]
        return {"item_name": finance.item_name, "category": finance.category,
                "cost": finance.cost, "date": finance.date}

    @Slot(str, str, float, str)
    def append(self, item_name: str, category: str, cost: float, date: str):
        finance = self.Finance(item_name, category, cost, date)
        self.beginInsertRows(QModelIndex(), 0, 0)  # Insert at the front
        self.m_finances.insert(0, finance)  # Insert at the front of the list
        self.endInsertRows()
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

Dialog {
    id: dialog

    signal finished(string itemName, string category, real cost, string date)

    contentItem: ColumnLayout {
        id: form
        spacing: 10
        property alias itemName: itemName
        property alias category: category
        property alias cost: cost
        property alias date: date

        GridLayout {
            columns: 2
            columnSpacing: 20
            rowSpacing: 10
            Layout.fillWidth: true

            Label {
                text: qsTr("Item Name:")
                Layout.alignment: Qt.AlignLeft | Qt.AlignBaseline
            }

            TextField {
                id: itemName
                focus: true
                Layout.fillWidth: true
                Layout.alignment: Qt.AlignLeft | Qt.AlignBaseline
            }

            Label {
                text: qsTr("Category:")
                Layout.alignment: Qt.AlignLeft | Qt.AlignBaseline
            }

            TextField {
                id: category
                focus: true
                Layout.fillWidth: true
                Layout.alignment: Qt.AlignLeft | Qt.AlignBaseline
            }

            Label {
                text: qsTr("Cost:")
                Layout.alignment: Qt.AlignLeft | Qt.AlignBaseline
            }

            TextField {
                id: cost
                focus: true
                Layout.fillWidth: true
                Layout.alignment: Qt.AlignLeft | Qt.AlignBaseline
                placeholderText: qsTr("€")
                inputMethodHints: Qt.ImhFormattedNumbersOnly
            }

            Label {
                text: qsTr("Date:")
                Layout.alignment: Qt.AlignLeft | Qt.AlignBaseline
            }

            TextField {
                id: date
                Layout.fillWidth: true
                Layout.alignment: Qt.AlignLeft | Qt.AlignBaseline
                // placeholderText: qsTr("dd-mm-yyyy")
                validator: RegularExpressionValidator { regularExpression: /^[0-3]?\d-[01]?\d-\d{4}$/ }
                // code to add the - automatically
                onTextChanged: {
                    if (date.text.length === 2 || date.text.length === 5) {
                        date.text += "-"
                    }
                }
                Component.onCompleted: {
                var today = new Date();
                var day = String(today.getDate()).padStart(2, '0');
                var month = String(today.getMonth() + 1).padStart(2, '0'); // Months are zero-based
                var year = today.getFullYear();
                date.placeholderText = day + "-" + month + "-" + year;
                }
            }
        }
    }

    function createEntry() {
        form.itemName.clear()
        form.category.clear()
        form.cost.clear()
        form.date.clear()
        dialog.title = qsTr("Add Finance Item")
        dialog.open()
    }

    x: parent.width / 2 - width / 2
    y: parent.height / 2 - height / 2

    focus: true
    modal: true
    title: qsTr("Add Finance Item")
    standardButtons: Dialog.Ok | Dialog.Cancel

    Component.onCompleted: {
        dialog.visible = false
        Qt.inputMethod.visibleChanged.connect(adjustDialogPosition)
    }

    function adjustDialogPosition() {
        if (Qt.inputMethod.visible) {
            // If the keyboard is visible, move the dialog up
            dialog.y = parent.height / 4 - height / 2
        } else {
            // If the keyboard is not visible, center the dialog
            dialog.y = parent.height / 2 - height / 2
        }
    }

    onAccepted: {
        finished(form.itemName.text, form.category.text, parseFloat(form.cost.text), form.date.text)
    }
}
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import QtQuick.Controls.Material

ItemDelegate {
    id: delegate
    checkable: true
    width: parent.width
    height: Qt.platform.os == "android" ?
        Math.min(window.width, window.height) * 0.15 :
        Math.min(window.width, window.height) * 0.1

    contentItem:
    RowLayout {
        Label {
            id: dateLabel
            font.pixelSize: Qt.platform.os == "android" ?
                Math.min(window.width, window.height) * 0.03 :
                Math.min(window.width, window.height) * 0.02
            text: date
            elide: Text.ElideRight
            Layout.fillWidth: true
            Layout.preferredWidth: 1
            color: Material.primaryTextColor
        }

        ColumnLayout {
            spacing: 5
            Layout.fillWidth: true
            Layout.preferredWidth: 1

            Label {
                text: item_name
                color: "#5c8540"
                font.bold: true
                elide: Text.ElideRight
                font.pixelSize:  Qt.platform.os == "android" ?
                    Math.min(window.width, window.height) * 0.03 :
                    Math.min(window.width, window.height) * 0.02
                Layout.fillWidth: true
            }

            Label {
                text: category
                elide: Text.ElideRight
                Layout.fillWidth: true
                font.pixelSize:  Qt.platform.os == "android" ?
                    Math.min(window.width, window.height) * 0.03 :
                    Math.min(window.width, window.height) * 0.02
            }
        }

        Item {
        Layout.fillWidth: true  // This item will take up the remaining space
        }

        ColumnLayout {
            spacing: 5
            Layout.fillWidth: true
            Layout.preferredWidth: 1

            Label {
                text: "you spent:"
                color: "#5c8540"
                elide: Text.ElideRight
                Layout.fillWidth: true
                font.pixelSize:  Qt.platform.os == "android" ?
                    Math.min(window.width, window.height) * 0.03 :
                    Math.min(window.width, window.height) * 0.02
            }

            Label {
                text: cost + "€"
                elide: Text.ElideRight
                Layout.fillWidth: true
                font.pixelSize:  Qt.platform.os == "android" ?
                    Math.min(window.width, window.height) * 0.03 :
                    Math.min(window.width, window.height) * 0.02
            }
        }
    }
}
pragma ComponentBehavior: Bound
import QtQuick
import QtGraphs
import QtQuick.Controls.Material

Item {
    width: Screen.width
    height: Screen.height

    GraphsView {
        id: chart
        anchors.fill: parent
        antialiasing: true

        theme: GraphsTheme {
            colorScheme: Qt.Dark
            theme: GraphsTheme.Theme.QtGreenNeon
        }

        PieSeries {
            id: pieSeries
        }
    }

    Text {
        id: chartTitle
        text: "Total Expenses Breakdown by Category"
        color: "#5c8540"
        font.pixelSize: Qt.platform.os == "android" ?
            Math.min(window.width, window.height) * 0.04 :
            Math.min(window.width, window.height) * 0.03
        anchors.horizontalCenter: parent.horizontalCenter
        anchors.top: parent.top
        anchors.topMargin: 20
    }

    function updateChart(data) {
        pieSeries.clear()
        for (var category in data) {
            var slice = pieSeries.append(category, data[category])
            slice.label = category + ": " + data[category] + "€"
            slice.labelVisible = true
        }
    }
}
import QtQuick
import QtQuick.Controls
import QtQuick.Controls.Material

ListView {
    id: listView
    anchors.fill: parent
    height: parent.height
    property var financeModel

    delegate: FinanceDelegate {
        id: delegate
        width: listView.width
    }

    model: financeModel

    section.property: "month"  // Group items by the "month" property
    section.criteria: ViewSection.FullString
    section.delegate: Component {
        id: sectionHeading
        Rectangle {
            width: listView.width
            height:  Qt.platform.os == "android" ?
                Math.min(window.width, window.height) * 0.05 :
                Math.min(window.width, window.height) * 0.03
            color: "#5c8540"

            required property string section

            Text {
                text: parent.section
                font.bold: true
                // depending on the screen density, adjust the font size
                font.pixelSize: Qt.platform.os == "android" ?
                    Math.min(window.width, window.height) * 0.03 :
                    Math.min(window.width, window.height) * 0.02
                color: Material.primaryTextColor
            }
        }
    }

    ScrollBar.vertical: ScrollBar { }
}
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Controls.Material
import Finance

ApplicationWindow {
    id: window
    Material.theme: Material.Dark
    Material.accent: Material.Gray
    width: Screen.width * 0.3
    height: Screen.height * 0.5
    visible: true
    title: qsTr("Finance Manager")

    // Add a toolbar for the application, only visible on mobile
    header: ToolBar {
        Material.primary: "#5c8540"
        visible: Qt.platform.os == "android"
        RowLayout {
            anchors.fill: parent
            Label {
                text: qsTr("Finance Manager")
                font.pixelSize: 20
                Layout.alignment: Qt.AlignCenter
            }
        }
    }

    ColumnLayout {
        anchors.fill: parent

        TabBar {
            id: tabBar
            Layout.fillWidth: true

            TabButton {
                text: qsTr("Expenses")
                font.pixelSize: Qt.platform.os == "android" ?
                    Math.min(window.width, window.height) * 0.04 :
                    Math.min(window.width, window.height) * 0.02
                onClicked: stackView.currentIndex = 0
            }

            TabButton {
                text: qsTr("Charts")
                font.pixelSize: Qt.platform.os == "android" ?
                    Math.min(window.width, window.height) * 0.04 :
                    Math.min(window.width, window.height) * 0.02
                onClicked: stackView.currentIndex = 1
            }
        }

        StackLayout {
            id: stackView
            Layout.fillWidth: true
            Layout.fillHeight: true

            Item {
                id: expensesView
                Layout.fillWidth: true
                Layout.fillHeight: true

                FinanceView {
                    id: financeView
                    anchors.fill: parent
                    financeModel: finance_model
                }
            }

            Item {
                id: chartsView
                Layout.fillWidth: true
                Layout.fillHeight: true

                FinancePieChart {
                    id: financePieChart
                    anchors.fill: parent
                    Component.onCompleted: {
                        var categoryData = finance_model.getCategoryData()
                        updateChart(categoryData)
                    }
                }
            }
        }
    }

    // Model to store the finance data. Created from Python.
    FinanceModel {
        id: finance_model
    }

    // Add a dialog to add new entries
    AddDialog {
        id: addDialog
        onFinished: function(item_name, category, cost, date) {
            finance_model.append(item_name, category, cost, date)
            var categoryData = finance_model.getCategoryData()
            financePieChart.updateChart(categoryData)
        }
    }

    // Add a button to open the dialog
    ToolButton {
        id: roundButton
        text: qsTr("+")
        highlighted: true
        Material.elevation: 6
        width: Qt.platform.os === "android" ?
            Math.min(parent.width * 0.2, Screen.width * 0.15) :
            Math.min(parent.width * 0.060, Screen.width * 0.05)
        height: width  // Keep the button circular
        anchors.margins: 10
        anchors.right: parent.right
        anchors.bottom: parent.bottom
        background: Rectangle {
            color: "#5c8540"
            radius: roundButton.width / 2
        }
        font.pixelSize: width * 0.4
        onClicked: {
            addDialog.createEntry()
        }
    }
}
module Finance
Main 1.0 Main.qml
FinanceView 1.0 FinanceView.qml
FinancePieChart 1.0 FinancePieChart.qml
FinanceDelegate 1.0 FinanceDelegate.qml
AddDialog 1.0 AddDialog.qml
typeinfo financemodel.qmltypes