From 0a28f2f27defaa72664549d887282cbcc979f612 Mon Sep 17 00:00:00 2001 From: Peter Date: Sun, 17 Sep 2023 23:34:33 +0800 Subject: [PATCH] Added charts for expenses --- .gitignore | 131 ++++++++++++++++++ README.md | 9 ++ budget/settings.py | 4 - budget/urls.py | 6 +- expenses/admin.py | 94 ++++++++++++- ...e_expensecategory_default_rate_and_more.py | 21 +++ expenses/models.py | 3 +- requirements.txt | 5 +- 8 files changed, 259 insertions(+), 14 deletions(-) create mode 100644 README.md create mode 100644 expenses/migrations/0003_remove_expensecategory_default_rate_and_more.py diff --git a/.gitignore b/.gitignore index 6178c5f..f91a118 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,134 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..510558e --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# Money manager + +## I'm sick of using excel! + +Personal money dashboard I made for myself. I will add more features when I need to, for example a journalling system or location server. I also anticipate adding a proper frontend to this app, to get over Django's admin interface limitations. + +It's not the greatest code, since it tries to extend the django admin interface, which isn't designed for adding all of these features. For example, I want to make the charts show data from two tables, which required janking it in a way that is probably not how the library is meant to be used. + +However, after searching for an hour I still think the Django admin interface is the most tightly integrated system for making a simple CRUD application. For example, if I were to use Django and an external frontend for the admin interface, I would need to add stuff like login which of course adds time, and to be honest I've had my fair share of CRUD apps this month. I also think most modern interfaces have way too much whitespace compared to the Django admin interface, which is nice and dense. diff --git a/budget/settings.py b/budget/settings.py index fcd60b3..f2ac41c 100644 --- a/budget/settings.py +++ b/budget/settings.py @@ -32,10 +32,6 @@ ALLOWED_HOSTS = [] # Application definition INSTALLED_APPS = [ - "admin_tools", - "admin_tools.theming", - "admin_tools.menu", - "admin_tools.dashboard", "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", diff --git a/budget/urls.py b/budget/urls.py index 45e76cd..4012472 100644 --- a/budget/urls.py +++ b/budget/urls.py @@ -17,6 +17,8 @@ Including another URLconf from django.contrib import admin from django.urls import include, path +from expenses.models import Expense, Timesheet + urlpatterns = [ path("admin/expenses/", include("expenses.admin_urls")), @@ -24,6 +26,6 @@ urlpatterns = [ path("admin/", admin.site.urls), ] -admin.site.site_header = "Better Admin" -admin.site.index_title = "Better Admin" +admin.site.site_header = "Yet another money manager" +admin.site.index_title = f"Handling {Expense.objects.all().count() + Timesheet.objects.all().count()} documents..." admin.site.site_title = "" diff --git a/expenses/admin.py b/expenses/admin.py index 02940a2..568b0b9 100644 --- a/expenses/admin.py +++ b/expenses/admin.py @@ -1,4 +1,5 @@ -from datetime import datetime +from datetime import datetime, timedelta +import random from typing import Any, Dict from django.contrib import admin from django.contrib.admin.options import ModelAdmin @@ -11,7 +12,7 @@ from django.db import models from util import next_payday from .admin_base import AdminBase, DeletedListFilter, DeletableAdminForm -from .models import Expense, Timesheet, TimesheetRate +from .models import Expense, ExpenseCategory, Timesheet, TimesheetRate from django import forms from django.utils import timezone from django.contrib.admin.widgets import AdminDateWidget, AdminSplitDateTime @@ -110,10 +111,15 @@ class TimesheetAdmin(AdminBase, AdminChartMixin, ImportExportModelAdmin): return {} # Cannot reorder the queryset at this point - earliest = min([x.shift_start for x in queryset]) + earliest = min([x.shift_start for x in queryset]).replace(day=1) + + expenses_in_range = Expense.objects.filter( + date__range=[earliest, timezone.now()] + ) labels = [] totals = [] + expenses_total = [] for b in months_between_dates(earliest, timezone.now()): labels.append(b.strftime("%b %Y")) totals.append( @@ -126,6 +132,15 @@ class TimesheetAdmin(AdminBase, AdminChartMixin, ImportExportModelAdmin): ] ) ) + expenses_total.append( + sum( + [ + convert_money(x.price, "AUD").amount + for x in expenses_in_range + if x.date.year == b.year and x.date.month == b.month + ] + ) + ) return { "labels": labels, @@ -135,6 +150,11 @@ class TimesheetAdmin(AdminBase, AdminChartMixin, ImportExportModelAdmin): "data": totals, "backgroundColor": "#79aec8", }, + { + "label": "Expenditure", + "data": expenses_total, + "backgroundColor": "#865137", + }, ], } @@ -149,8 +169,8 @@ class ExpenseAdminForm(DeletableAdminForm): class ExpenseAdmin(AdminBase, AdminChartMixin, ImportExportModelAdmin): form = ExpenseAdminForm - list_display = ("date", "price", "description") - list_filter = ("date", ("deleted", DeletedListFilter)) + list_display = ("date", "price", "description", "category") + list_filter = ("date", ("deleted", DeletedListFilter), "category") ordering = ("-date", "-deleted") readonly_fields = ("deleted",) @@ -163,3 +183,67 @@ class ExpenseAdmin(AdminBase, AdminChartMixin, ImportExportModelAdmin): list_chart_data = {} list_chart_options = {"aspectRatio": 6} list_chart_config = None # Override the combined settings + + def get_list_chart_data(self, queryset): + if not queryset: + return {} + + # Cannot reorder the queryset at this point + earliest = min([x.date for x in queryset]).replace(day=1) + timesheets = Timesheet.objects.filter( + shift_start__range=[earliest, timezone.now()] + ) + + labels = [] + totals = [] + expenses_total = [] + expenses = { + k: { + "label": k, + "data": [0], + "backgroundColor": f"#{random.Random(x=1).randrange(0x1000000):06x}", + } + for k in set([x.category.name for x in queryset]) + } + for b in months_between_dates(earliest, timezone.now().date()): + labels.append(b.strftime("%b %Y")) + totals.append( + sum( + [ + convert_money(x.total, "AUD").amount + for x in timesheets + if x.shift_start.year == b.year + and x.shift_start.month == b.month + ] + ) + ) + expenses_total.append(0) + for x in queryset: + if x.date.year == b.year and x.date.month == b.month: + expenses_total[-1] += convert_money(x.price, "AUD").amount + expenses[x.category.name]["data"][-1] += convert_money( + x.price, "AUD" + ).amount + + print(list(expenses)) + return { + "labels": labels, + "datasets": [ + { + "label": "Income (Pre-tax)", + "data": totals, + "backgroundColor": "#79aec8", + }, + { + "label": "Expenditure", + "data": expenses_total, + "backgroundColor": "#865137", + }, + ] + + list(expenses.values()), + } + + +@admin.register(ExpenseCategory) +class ExpenseCategoryAdmin(AdminBase): + pass diff --git a/expenses/migrations/0003_remove_expensecategory_default_rate_and_more.py b/expenses/migrations/0003_remove_expensecategory_default_rate_and_more.py new file mode 100644 index 0000000..24492c6 --- /dev/null +++ b/expenses/migrations/0003_remove_expensecategory_default_rate_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.5 on 2023-09-17 14:44 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('expenses', '0002_expensecategory_historicalexpensecategory_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='expensecategory', + name='default_rate', + ), + migrations.RemoveField( + model_name='historicalexpensecategory', + name='default_rate', + ), + ] diff --git a/expenses/models.py b/expenses/models.py index 841b5f1..65acb4a 100644 --- a/expenses/models.py +++ b/expenses/models.py @@ -103,10 +103,9 @@ class Timesheet(DeletableModel): class ExpenseCategory(DeletableModel): name = models.TextField(blank=True) description = models.TextField(blank=True) - default_rate = models.BooleanField(default=False) def __str__(self): - return self.name + return truncate_string(self.name, 48) class Expense(DeletableModel): diff --git a/requirements.txt b/requirements.txt index 3ebaeea..13379a9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,9 @@ backports.zoneinfo==0.2.1 defusedxml==0.7.1 diff-match-patch==20230430 Django==4.2.5 +django-admincharts==0.4.1 +django-computedfields==0.2.3 +django-fast-update==0.2.3 django-import-export==3.3.1 django-money==3.2.0 django-simple-history==3.4.0 @@ -11,6 +14,7 @@ et-xmlfile==1.1.0 MarkupPy==1.14 odfpy==1.4.1 openpyxl==3.1.2 +pip-autoremove==0.10.0 py-moneyed==3.0 pytz==2023.3.post1 PyYAML==6.0.1 @@ -18,4 +22,3 @@ sqlparse==0.4.4 tablib==3.5.0 typing-extensions==4.7.1 xlrd==2.0.1 -xlwt==1.3.0