Added charts for expenses

This commit is contained in:
Peter 2023-09-17 23:34:33 +08:00
parent a2a3870369
commit 0a28f2f27d
8 changed files with 259 additions and 14 deletions

131
.gitignore vendored
View File

@ -162,3 +162,134 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear # 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. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.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.*

9
README.md Normal file
View File

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

View File

@ -32,10 +32,6 @@ ALLOWED_HOSTS = []
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
"admin_tools",
"admin_tools.theming",
"admin_tools.menu",
"admin_tools.dashboard",
"django.contrib.admin", "django.contrib.admin",
"django.contrib.auth", "django.contrib.auth",
"django.contrib.contenttypes", "django.contrib.contenttypes",

View File

@ -17,6 +17,8 @@ Including another URLconf
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from expenses.models import Expense, Timesheet
urlpatterns = [ urlpatterns = [
path("admin/expenses/", include("expenses.admin_urls")), path("admin/expenses/", include("expenses.admin_urls")),
@ -24,6 +26,6 @@ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
] ]
admin.site.site_header = "Better Admin" admin.site.site_header = "Yet another money manager"
admin.site.index_title = "Better Admin" admin.site.index_title = f"Handling {Expense.objects.all().count() + Timesheet.objects.all().count()} documents..."
admin.site.site_title = "" admin.site.site_title = ""

View File

@ -1,4 +1,5 @@
from datetime import datetime from datetime import datetime, timedelta
import random
from typing import Any, Dict from typing import Any, Dict
from django.contrib import admin from django.contrib import admin
from django.contrib.admin.options import ModelAdmin from django.contrib.admin.options import ModelAdmin
@ -11,7 +12,7 @@ from django.db import models
from util import next_payday from util import next_payday
from .admin_base import AdminBase, DeletedListFilter, DeletableAdminForm 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 import forms
from django.utils import timezone from django.utils import timezone
from django.contrib.admin.widgets import AdminDateWidget, AdminSplitDateTime from django.contrib.admin.widgets import AdminDateWidget, AdminSplitDateTime
@ -110,10 +111,15 @@ class TimesheetAdmin(AdminBase, AdminChartMixin, ImportExportModelAdmin):
return {} return {}
# Cannot reorder the queryset at this point # 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 = [] labels = []
totals = [] totals = []
expenses_total = []
for b in months_between_dates(earliest, timezone.now()): for b in months_between_dates(earliest, timezone.now()):
labels.append(b.strftime("%b %Y")) labels.append(b.strftime("%b %Y"))
totals.append( 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 { return {
"labels": labels, "labels": labels,
@ -135,6 +150,11 @@ class TimesheetAdmin(AdminBase, AdminChartMixin, ImportExportModelAdmin):
"data": totals, "data": totals,
"backgroundColor": "#79aec8", "backgroundColor": "#79aec8",
}, },
{
"label": "Expenditure",
"data": expenses_total,
"backgroundColor": "#865137",
},
], ],
} }
@ -149,8 +169,8 @@ class ExpenseAdminForm(DeletableAdminForm):
class ExpenseAdmin(AdminBase, AdminChartMixin, ImportExportModelAdmin): class ExpenseAdmin(AdminBase, AdminChartMixin, ImportExportModelAdmin):
form = ExpenseAdminForm form = ExpenseAdminForm
list_display = ("date", "price", "description") list_display = ("date", "price", "description", "category")
list_filter = ("date", ("deleted", DeletedListFilter)) list_filter = ("date", ("deleted", DeletedListFilter), "category")
ordering = ("-date", "-deleted") ordering = ("-date", "-deleted")
readonly_fields = ("deleted",) readonly_fields = ("deleted",)
@ -163,3 +183,67 @@ class ExpenseAdmin(AdminBase, AdminChartMixin, ImportExportModelAdmin):
list_chart_data = {} list_chart_data = {}
list_chart_options = {"aspectRatio": 6} list_chart_options = {"aspectRatio": 6}
list_chart_config = None # Override the combined settings 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

View File

@ -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',
),
]

View File

@ -103,10 +103,9 @@ class Timesheet(DeletableModel):
class ExpenseCategory(DeletableModel): class ExpenseCategory(DeletableModel):
name = models.TextField(blank=True) name = models.TextField(blank=True)
description = models.TextField(blank=True) description = models.TextField(blank=True)
default_rate = models.BooleanField(default=False)
def __str__(self): def __str__(self):
return self.name return truncate_string(self.name, 48)
class Expense(DeletableModel): class Expense(DeletableModel):

View File

@ -4,6 +4,9 @@ backports.zoneinfo==0.2.1
defusedxml==0.7.1 defusedxml==0.7.1
diff-match-patch==20230430 diff-match-patch==20230430
Django==4.2.5 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-import-export==3.3.1
django-money==3.2.0 django-money==3.2.0
django-simple-history==3.4.0 django-simple-history==3.4.0
@ -11,6 +14,7 @@ et-xmlfile==1.1.0
MarkupPy==1.14 MarkupPy==1.14
odfpy==1.4.1 odfpy==1.4.1
openpyxl==3.1.2 openpyxl==3.1.2
pip-autoremove==0.10.0
py-moneyed==3.0 py-moneyed==3.0
pytz==2023.3.post1 pytz==2023.3.post1
PyYAML==6.0.1 PyYAML==6.0.1
@ -18,4 +22,3 @@ sqlparse==0.4.4
tablib==3.5.0 tablib==3.5.0
typing-extensions==4.7.1 typing-extensions==4.7.1
xlrd==2.0.1 xlrd==2.0.1
xlwt==1.3.0