Skip to content

Commit

Permalink
Merge pull request #3 from isaacnorman82/populate_example_data
Browse files Browse the repository at this point in the history
Populate example data
  • Loading branch information
isaacnorman82 authored Aug 13, 2024
2 parents a22e6d4 + e4fc2bd commit f405309
Show file tree
Hide file tree
Showing 17 changed files with 734 additions and 129 deletions.
89 changes: 88 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,88 @@
# finances
# Finances - By Isaac Norman

This readme is just a placeholder of enough info to know what this repo is and run it with sample data.

# About

I had a spreadsheet for a long time that I kept all my personal finances in. It was good, but I always found it annoying to keep up to date, copying and pasting stuff in. Also, with my excel skills at least, it didn't really scale well if I wanted to make a new visualisation or remap data.

I finally had a bit of time and thought I would kill two birds with one stone: replace the spreadsheet with a nice app and also create a project that demonstrated my skills.

I have managed to create an app that should replace the spreadsheet. However, it's taken a lot longer than I really had time for and it's the opposite of polished code I'd love to show off. I'm mostly a backend developer and a LOT of this work was front end. One day I'll make the code nice but that's not now, so no judging.

# What does it do?

Basically you can ingest different data files - ofx, csv - and build up a database of accounts, transactions and data points (currently about job/salary/tax) and then view it all in a web front end.

## Features
- Summary Page
- Balance over time
- Wealth pie chart
- Stacked account bar chart (accounts summed over time)
- Accounts Page
- View all accounts and navigate to Account Details
- Account Details Page
- List of monthly transactions (like on your banking app)
- Balance over time
- value vs contributions for account types that grow such as share ISAs, houses etc.
- link to account login (i.e. your banking web page)
- Taxable Income Page
- visualisations on tax, income, salary and career
- General Features
- Interpolation of missing transactional data (i.e. if you only have the odd value of a pension/asset it will work out the missing values)
- All filterable by account type and time range

# What is incomplete?
- The code is extremely prototype - was rushed to just get functional.
- Most of the ingest currently happens via python, the UI for this needs implementing
- Editing accounts and manually inputing transactions
- A proper packed build of the front and back end
- A proper docker image for the front and back end
- Tests are basically a skeleton. They need writing around the sample data.
- Plus lots more

# What's the architecture?

Backend: Python FastAPI, SqlAlchemy, Pydantic, Alembic
Frontend: Vue 3, Vuetify 3, TypeScript
DB: Dockerised Postgres

# How do I run it?

I will in future build both the front and backend and include in docker images, but for now:

- check out the code
- create a python venv and install the requirements.txt
- run the docker compose:
```
docker compose up -d
```
- initialise the db with alembic:
```
alembic upgrade head
```
- optionally load some sample data (recommend if you want to just give it a quick try)
```
python load_sample_data.py
```
- run the backend
```
fastapi dev backend/main.py
```
- install deps for the frontend
```
cd frontend
npm install
```
- run the frontend
```
cd frontend
npm run dev
```
- navigate to the frontend web address: http://localhost:3000/
- if you want to erase the db and start again
```
docker compose down db
docker volume rm finances_db
docker compose up db -d
```
53 changes: 53 additions & 0 deletions backend/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,33 @@ def get_accounts(
return [api_models.Account.model_validate(result) for result in results]


def create_accounts(
db_session: Session,
accounts: Union[api_models.Account, List[api_models.Account]],
as_db_model: bool = False,
):
if not isinstance(accounts, list):
accounts = [accounts]

new_accounts: List[db_models.Account] = [
db_models.Account(**account.model_dump()) for account in accounts
]
db_session.add_all(new_accounts)
db_session.commit()
for new_account in new_accounts:
db_session.refresh(new_account)

if not as_db_model:
new_accounts = [
api_models.Account.model_validate(new_account) for new_account in new_accounts
]

if len(new_accounts) == 1:
new_accounts = new_accounts[0]

return new_accounts


def get_transactions(
db_session: Session,
account_id: int,
Expand Down Expand Up @@ -80,6 +107,32 @@ def get_transactions(
return [api_models.Transaction.model_validate(result) for result in results]


def create_transactions(
db_session: Session,
transactions: Union[api_models.Transaction, List[api_models.Transaction]],
as_db_model: bool = False,
):
if not isinstance(transactions, list):
transactions = [transactions]

new_transactions: List[db_models.Transaction] = [
db_models.Transaction(**transaction.model_dump()) for transaction in transactions
]
db_session.add_all(new_transactions)
db_session.commit()

if not as_db_model:
return [
api_models.Transaction.model_validate(new_transaction)
for new_transaction in new_transactions
]

if len(new_transactions) == 1:
new_transactions = new_transactions[0]

return new_transactions


def get_last_transaction_dates(db_session: Session) -> Dict[int, datetime]:
# Query to get the last transaction date for each account_id
results = (
Expand Down
8 changes: 1 addition & 7 deletions backend/main.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
# from contextlib import asynccontextmanager
import logging
import traceback
from typing import Union

from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
Expand All @@ -11,8 +9,7 @@
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.base import BaseHTTPMiddleware

from backend import __version__, db_models
from backend.db import engine
from backend import __version__
from backend.rest_api import get_api_router

logging.basicConfig(
Expand All @@ -21,9 +18,6 @@
logger = logging.getLogger(__name__)
logging.getLogger("ofxtools").setLevel(logging.ERROR)

# create everything if not using alembic
# db_models.Base.metadata.create_all(bind=engine)


def _configure_app() -> FastAPI:
version = f"v{__version__}"
Expand Down
4 changes: 3 additions & 1 deletion backend/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
black==24.4.2
isort==5.13.2
SQLAlchemy==2.0.30
SQLAlchemy-Utils==0.41.2
fastapi==0.111.0
psycopg2==2.9.9
python-dateutil==2.9.0.post0
ofxtools==0.9.5
httpx==0.27.0
alembic==1.13.2
pytest==8.3.2
pytest-asyncio==0.23.8
pytest-asyncio==0.23.8
pytest-postgresql==6.0.0
17 changes: 2 additions & 15 deletions backend/rest_api/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import Dict, List, Optional, Union

from dateutil import parser
from fastapi import APIRouter, Depends, HTTPException, Path, UploadFile, status
from fastapi import APIRouter, Body, Depends, HTTPException, Path, UploadFile, status
from sqlalchemy.orm import Session

from backend import api_models, crud, db_models
Expand Down Expand Up @@ -133,20 +133,7 @@ def api_create_account(
accounts: Union[api_models.AccountCreate, List[api_models.AccountCreate]],
db_session: Session = Depends(get_db_session),
):
if not isinstance(accounts, list):
accounts = [accounts]

new_accounts: List[db_models.Account] = [
db_models.Account(**account.model_dump()) for account in accounts
]
db_session.add_all(new_accounts)
db_session.commit()
for new_account in new_accounts:
db_session.refresh(new_account)

if len(new_accounts) == 1:
new_accounts = new_accounts[0]
return new_accounts
return crud.create_accounts(db_session=db_session, accounts=accounts)


@router.get(
Expand Down
File renamed without changes.
93 changes: 93 additions & 0 deletions backend/test/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import os
import uuid
from datetime import datetime
from decimal import Decimal
from typing import List

import pytest
from pydantic import BaseModel
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy_utils import create_database, database_exists, drop_database

from backend import crud
from backend.api_models import AccountCreate, AccountType, IngestType
from backend.db import Base, get_db_session
from backend.main import app
from backend.test.sample_data_utils import (
create_sample_accounts,
create_sample_transactions,
)


@pytest.fixture(scope="session")
def unique_test_db_name(request):
worker_id = getattr(request.config, "workerinput", {}).get("workerid", "master")
return f"test_db_{worker_id}_{uuid.uuid4().hex}"


@pytest.fixture(scope="session")
def db_engine(unique_test_db_name):
base_url = "postgresql://postgres:[email protected]/"
test_db_url = f"{base_url}{unique_test_db_name}"

if not database_exists(test_db_url):
create_database(test_db_url)

engine = create_engine(test_db_url)
yield engine

engine.dispose() # Ensure all connections are closed
drop_database(test_db_url)


@pytest.fixture(scope="function", autouse=True)
def db_session(db_engine):
"""
Automatically use the db_session fixture in all tests.
"""
Base.metadata.create_all(bind=db_engine)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=db_engine)
session = TestingSessionLocal()

yield session

session.close() # Ensure session is closed after each test
Base.metadata.drop_all(bind=db_engine) # Cleanup tables after each test


@pytest.fixture(scope="function", autouse=True)
def setup_test_environment(db_session):
"""
Override the FastAPI dependencies to use the test database session.
"""

def get_db_override():
yield db_session

app.dependency_overrides[get_db_session] = get_db_override

yield # This allows the fixture to run both at setup and teardown

app.dependency_overrides.clear() # Clear overrides after the session


@pytest.fixture(scope="session")
def sample_accounts():
return create_sample_accounts()


@pytest.fixture(scope="session")
def sample_transactions():
return create_sample_transactions()


@pytest.fixture(scope="function")
def insert_sample_accounts(db_session, sample_accounts):
crud.create_accounts(db_session=db_session, accounts=sample_accounts)


@pytest.fixture(scope="function")
def insert_sample_accounts_and_transactions(db_session, sample_accounts, sample_transactions):
crud.create_accounts(db_session=db_session, accounts=sample_accounts)
crud.create_transactions(db_session=db_session, transactions=sample_transactions)
Loading

0 comments on commit f405309

Please sign in to comment.