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.
# 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