Finance Manager Example - Part 3

This example represents the final part 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 3.

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)


# 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 uvicorn
from database import initialize_database


def main():
    # Initialize the database
    initialize_database()
    # Start the FastAPI endpoint
    uvicorn.run("rest_api:app", host="127.0.0.1", port=8000, reload=True)


if __name__ == "__main__":
    main()
# Copyright (C) 2024 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

import logging
from fastapi import FastAPI, Depends, HTTPException
from pydantic import BaseModel
from typing import Dict, Any
from sqlalchemy import orm
from database import Session, Finance

app = FastAPI()


class FinanceCreate(BaseModel):
    item_name: str
    category: str
    cost: float
    date: str


class FinanceRead(FinanceCreate):
    class Config:
        from_attributes = True


def get_db():
    db = Session()
    try:
        yield db
    finally:
        db.close()


@app.post("/finances/", response_model=FinanceRead)
def create_finance(finance: FinanceCreate, db: orm.Session = Depends(get_db)):
    print(f"Adding finance item: {finance}")
    db_finance = Finance(**finance.model_dump())
    db.add(db_finance)
    db.commit()
    db.refresh(db_finance)
    return db_finance


@app.get("/finances/", response_model=Dict[str, Any])
def read_finances(skip: int = 0, limit: int = 10, db: orm.Session = Depends(get_db)):
    try:
        total = db.query(Finance).count()
        finances = db.query(Finance).offset(skip).limit(limit).all()
        response = {
            "total": total,
            # Convert the list of Finance objects to a list of FinanceRead objects
            "items": [FinanceRead.from_orm(finance) for finance in finances]
        }
        logging.info(f"Response: {response}")
        return response
    except Exception as e:
        logging.error(f"Error occurred: {e}")
        raise HTTPException(status_code=500, detail="Internal Server Error")
// 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
# Copyright (C) 2024 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

import requests
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.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.m_finances = []
        self.fetchAllData()

    def fetchAllData(self):
        response = requests.get("http://127.0.0.1:8000/finances/")
        try:
            data = response.json()
        except requests.exceptions.JSONDecodeError:
            print("Failed to decode JSON response")
            return
        self.beginInsertRows(QModelIndex(), 0, len(data["items"]) - 1)
        self.m_finances.extend([self.Finance(**item) for item in data["items"]])
        self.endInsertRows()

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

    def data(self, index: QModelIndex, role: int):
        if not index.isValid() or index.row() >= self.rowCount():
            return None
        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

    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 = {"item_name": item_name, "category": category, "cost": cost, "date": date}
        response = requests.post("http://127.0.0.1:8000/finances/", json=finance)
        if response.status_code == 200:
            finance = response.json()
            self.beginInsertRows(QModelIndex(), 0, 0)
            self.m_finances.insert(0, self.Finance(**finance))
            self.endInsertRows()
        else:
            print("Failed to add finance item")

    @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)
# 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

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)
sqlalchemy
uvicorn
fastapi