Finance Manager Example - Part 2

This example represents the part two 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 2.

Download this example

# Copyright (C) 2024 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

from sqlalchemy import create_engine, Column, Integer, String, Float
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
import os
import platform
from pathlib import Path

Base = declarative_base()


class Finance(Base):
    __tablename__ = 'finances'
    id = Column(Integer, primary_key=True)
    item_name = Column(String)
    category = Column(String)
    cost = Column(Float)
    date = Column(String)


# Check for an environment variable for the database path
env_db_path = os.getenv('FINANCE_MANAGER_DB_PATH')

if env_db_path:
    db_path = Path(env_db_path)
else:
    # Determine the application data directory based on the operating system using pathlib
    if platform.system() == 'Windows':
        app_data_location = Path(os.getenv('APPDATA')) / 'FinanceManager'
    elif platform.system() == 'Darwin':  # macOS
        app_data_location = Path.home() / 'Library' / 'Application Support' / 'FinanceManager'
    else:  # Linux and other Unix-like systems
        app_data_location = Path.home() / '.local' / 'share' / 'FinanceManager'

    db_path = app_data_location / 'finances.db'

DATABASE_URL = f'sqlite:///{db_path}'
engine = create_engine(DATABASE_URL)
Session = sessionmaker(bind=engine)

# Default data to be added to the database
default_data = [
    {"item_name": "Mobile Prepaid", "category": "Electronics", "cost": 20.00, "date": "15-02-2024"},
    {"item_name": "Groceries-Feb-Week1", "category": "Groceries", "cost": 60.75,
     "date": "16-01-2024"},
    {"item_name": "Bus Ticket", "category": "Transport", "cost": 5.50, "date": "17-01-2024"},
    {"item_name": "Book", "category": "Education", "cost": 25.00, "date": "18-01-2024"},
]


def initialize_database():
    if db_path.exists():
        print(f"Database '{db_path}' already exists.")
        return

    app_data_location.mkdir(parents=True, exist_ok=True)
    Base.metadata.create_all(engine)
    print(f"Database '{db_path}' created successfully.")
    session = Session()

    for data in default_data:
        finance = Finance(**data)
        session.add(finance)

    session.commit()
    print("Default data has been added to the database.")
# Copyright (C) 2024 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

import sys
from pathlib import Path

from PySide6.QtWidgets import QApplication
from PySide6.QtQml import QQmlApplicationEngine

from financemodel import FinanceModel  # noqa: F401
from database import initialize_database

if __name__ == '__main__':
    # Initialize the database if it does not exist
    initialize_database()

    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)
# Copyright (C) 2024 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

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
import database

QML_IMPORT_NAME = "Finance"
QML_IMPORT_MAJOR_VERSION = 1


@QmlElement
class FinanceModel(QAbstractListModel):

    @QEnum
    class FinanceRole(IntEnum):
        ItemNameRole = Qt.DisplayRole
        CategoryRole = Qt.UserRole
        CostRole = Qt.UserRole + 1
        DateRole = Qt.UserRole + 2
        MonthRole = Qt.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.session = database.Session()
        self.m_finances = self.load_finances()

    def load_finances(self):
        finances = []
        for finance in self.session.query(database.Finance).all():
            finances.append(self.Finance(finance.item_name, finance.category, finance.cost,
                                         finance.date))
        return finances

    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.session.add(database.Finance(item_name=item_name, category=category, cost=cost,
                                          date=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()
        self.session.commit()
// Copyright (C) 2024 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

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
                focus: true
                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)
    }
}
// Copyright (C) 2024 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

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.04 :
                    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
            }
        }
    }
}
// Copyright (C) 2024 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

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
        }
    }
}
// Copyright (C) 2024 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

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
                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 { }
}
// Copyright (C) 2024 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

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
sqlalchemy