diff --git a/nummi/plot/__init__.py b/nummi/api/__init__.py similarity index 100% rename from nummi/plot/__init__.py rename to nummi/api/__init__.py diff --git a/nummi/api/admin.py b/nummi/api/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/nummi/api/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/nummi/plot/apps.py b/nummi/api/apps.py similarity index 66% rename from nummi/plot/apps.py rename to nummi/api/apps.py index 1f7a601..878e7d5 100644 --- a/nummi/plot/apps.py +++ b/nummi/api/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig -class PlotConfig(AppConfig): +class ApiConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" - name = "plot" + name = "api" diff --git a/nummi/plot/migrations/__init__.py b/nummi/api/migrations/__init__.py similarity index 100% rename from nummi/plot/migrations/__init__.py rename to nummi/api/migrations/__init__.py diff --git a/nummi/api/models.py b/nummi/api/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/nummi/api/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/nummi/api/tests.py b/nummi/api/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/nummi/api/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/nummi/api/urls.py b/nummi/api/urls.py new file mode 100644 index 0000000..cb74a80 --- /dev/null +++ b/nummi/api/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("transactions", views.TransactionListView.as_view(), name="transactions"), + path("categories", views.CategoryListView.as_view(), name="categories"), + path("accounts", views.AccountListView.as_view(), name="accounts"), + path("snapshots", views.SnapshotListView.as_view(), name="snapshots"), + path("history", views.HistoryView.as_view(), name="history"), +] diff --git a/nummi/api/views.py b/nummi/api/views.py new file mode 100644 index 0000000..31024eb --- /dev/null +++ b/nummi/api/views.py @@ -0,0 +1,61 @@ +from django.db.models import Sum, FloatField, Q +from django.db.models.functions import TruncMonth +from django.http import JsonResponse +from django.views import View +from django.views.generic.list import MultipleObjectMixin + +from main.models import Account, Category, Snapshot, Transaction +from main.views import UserMixin + + +class TransactionListView(UserMixin, MultipleObjectMixin, View): + model = Transaction + + def get(self, request, *args, **kwargs): + return JsonResponse({"transactions": list(self.get_queryset().values())}) + + +class CategoryListView(UserMixin, MultipleObjectMixin, View): + model = Category + + def get(self, request, *args, **kwargs): + return JsonResponse({"categories": list(self.get_queryset().values())}) + + +class AccountListView(UserMixin, MultipleObjectMixin, View): + model = Account + + def get(self, request, *args, **kwargs): + return JsonResponse({"accounts": list(self.get_queryset().values())}) + + +class SnapshotListView(UserMixin, MultipleObjectMixin, View): + model = Snapshot + + def get(self, request, *args, **kwargs): + return JsonResponse({"snapshots": list(self.get_queryset().values())}) + + +class HistoryView(UserMixin, MultipleObjectMixin, View): + model = Transaction + + def get(self, request, *args, **kwargs): + return JsonResponse( + { + "data": list( + self.get_queryset() + .filter(category__budget=True) + .values(month=TruncMonth("date")) + .annotate( + sum_p=Sum( + "value", output_field=FloatField(), filter=Q(value__gt=0) + ), + sum_m=Sum( + "value", output_field=FloatField(), filter=Q(value__lt=0) + ), + sum=Sum("value", output_field=FloatField()), + ) + .order_by("month") + ) + } + ) diff --git a/nummi/main/forms.py b/nummi/main/forms.py index 146432a..4ae8339 100644 --- a/nummi/main/forms.py +++ b/nummi/main/forms.py @@ -28,6 +28,7 @@ class CategoryForm(NummiForm): fields = [ "name", "icon", + "budget", ] diff --git a/nummi/main/static/main/js/history_plot.js b/nummi/main/static/main/js/history_plot.js new file mode 100644 index 0000000..ddec5bd --- /dev/null +++ b/nummi/main/static/main/js/history_plot.js @@ -0,0 +1,31 @@ +import {json} from "https://cdn.skypack.dev/d3-fetch@3"; +import {timeParse} from "https://cdn.skypack.dev/d3-time-format@4"; +import * as Plot from "https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6/+esm"; + +const history = await json("/api/history"); +const parseDate = timeParse("%Y-%m-%d"); +console.log(history); +history["data"].forEach((d) => { + d.month = parseDate(d.month); +}); +const data = history["data"]; + +let history_plot = Plot.plot({ + x: { + round: true, + label: "Month", + grid: true, + }, + y: { + grid: true, + }, + marks: [ + Plot.areaY(data, {x: "month", y: "sum_p", interval: "month", curve: "bump-x", fill: "#aaccff"}), + Plot.areaY(data, {x: "month", y: "sum_m", interval: "month", curve: "bump-x", fill: "#ffccaa"}), + Plot.ruleY([0]), + Plot.lineY(data, {x: "month", y: "sum", interval: "month", curve: "bump-x", marker: "circle", strokeWidth: 2}), + ], + insetTop: 20, + insetBottom: 20, +}); +document.querySelector("#history_plot").append(history_plot); diff --git a/nummi/main/templates/main/category_form.html b/nummi/main/templates/main/category_form.html index b2c04cf..a3b327a 100644 --- a/nummi/main/templates/main/category_form.html +++ b/nummi/main/templates/main/category_form.html @@ -20,8 +20,6 @@ {{ form }} {% if form.instance.transactions %} - Graph representing value over time

{% translate "Transactions" %}

{% include "main/table/transaction.html" %} {% endif %} diff --git a/nummi/main/templates/main/index.html b/nummi/main/templates/main/index.html index 503d0fe..2dc4d85 100644 --- a/nummi/main/templates/main/index.html +++ b/nummi/main/templates/main/index.html @@ -10,6 +10,7 @@ + {% endblock %} {% block body %} {% if accounts %} @@ -35,6 +36,7 @@ {% endfor %} {% endspaceless %} +
{% endif %} {% if snapshots %}

{% translate "Snapshots" %}

diff --git a/nummi/main/views.py b/nummi/main/views.py index 9b078d7..b981fa8 100644 --- a/nummi/main/views.py +++ b/nummi/main/views.py @@ -233,9 +233,7 @@ class SnapshotUpdateView(NummiUpdateView): ) if _transactions: data["categories"] = ( - _transactions.values( - "category", "category__name", "category__icon" - ) + _transactions.values("category", "category__name", "category__icon") .annotate( sum=models.Sum("value"), sum_m=models.Sum("value", filter=models.Q(value__lt=0)), diff --git a/nummi/nummi/urls.py b/nummi/nummi/urls.py index 14d3517..372b4cb 100644 --- a/nummi/nummi/urls.py +++ b/nummi/nummi/urls.py @@ -19,7 +19,7 @@ from django.urls import include, path urlpatterns = i18n_patterns( path("", include("main.urls")), - path("plot/", include("plot.urls")), + path("api/", include("api.urls")), path("admin/", admin.site.urls), prefix_default_language=False, ) diff --git a/nummi/plot/admin.py b/nummi/plot/admin.py deleted file mode 100644 index 846f6b4..0000000 --- a/nummi/plot/admin.py +++ /dev/null @@ -1 +0,0 @@ -# Register your models here. diff --git a/nummi/plot/models.py b/nummi/plot/models.py deleted file mode 100644 index 6b20219..0000000 --- a/nummi/plot/models.py +++ /dev/null @@ -1 +0,0 @@ -# Create your models here. diff --git a/nummi/plot/nummi.mplstyle b/nummi/plot/nummi.mplstyle deleted file mode 100644 index b49262b..0000000 --- a/nummi/plot/nummi.mplstyle +++ /dev/null @@ -1,13 +0,0 @@ -font.family: Inter - -lines.linewidth: 2 - -figure.autolayout: True -figure.figsize: 8, 4 -figure.dpi: 300 - -axes.prop_cycle: cycler('color', ["66cc66", "338033", "99ff99", "802653", "cc6699"]) -axes.axisbelow: True -axes.grid: True - -svg.fonttype: none diff --git a/nummi/plot/tests.py b/nummi/plot/tests.py deleted file mode 100644 index a39b155..0000000 --- a/nummi/plot/tests.py +++ /dev/null @@ -1 +0,0 @@ -# Create your tests here. diff --git a/nummi/plot/urls.py b/nummi/plot/urls.py deleted file mode 100644 index 1412ba8..0000000 --- a/nummi/plot/urls.py +++ /dev/null @@ -1,9 +0,0 @@ -from django.urls import path - -from . import views - -urlpatterns = [ - path("timeline", views.timeline, name="plot-timeline"), - path("categories", views.categories, name="plot-categories"), - path("category/", views.category, name="plot-category"), -] diff --git a/nummi/plot/views.py b/nummi/plot/views.py deleted file mode 100644 index 46ebfa5..0000000 --- a/nummi/plot/views.py +++ /dev/null @@ -1,108 +0,0 @@ -import io - -import matplotlib -import matplotlib.pyplot as plt -from django.contrib.auth.decorators import login_required -from django.db import models -from django.http import HttpResponse -from django.shortcuts import get_object_or_404 -from django.utils.translation import gettext as _ -from matplotlib import dates as mdates - -from main.models import Category, Snapshot, Transaction - -matplotlib.use("Agg") -plt.style.use("./plot/nummi.mplstyle") - - -@login_required -def timeline(request): - _snapshots = Snapshot.objects.all() - - fig, ax = plt.subplots() - ax.step( - [s.date for s in _snapshots], - [s.value for s in _snapshots], - where="post", - ) - ax.set(ylabel=_("Snapshots"), ylim=0) - ax.xaxis.set_major_formatter( - mdates.ConciseDateFormatter(ax.xaxis.get_major_locator()) - ) - ax.autoscale(True, "x", True) - - _io = io.StringIO() - - fig.savefig(_io, format="svg") - - return HttpResponse(_io.getvalue(), headers={"Content-Type": "image/svg+xml"}) - - -@login_required -def categories(request): - _categories = Category.objects.filter(budget=True) - - fig, ax = plt.subplots(figsize=(8, _categories.count() / 4)) - ax.barh( - [str(c) for c in _categories][::-1], - [ - Transaction.objects.filter(category=c).aggregate(sum=models.Sum("value"))[ - "sum" - ] - for c in _categories - ][::-1], - ) - - _io = io.StringIO() - - fig.savefig(_io, format="svg") - - return HttpResponse(_io.getvalue(), headers={"Content-Type": "image/svg+xml"}) - - -@login_required -def category(request, uuid): - _category = get_object_or_404(Category, id=uuid) - _values_p = ( - Transaction.objects.filter(category=_category) - .filter(value__gt=0) - .annotate(m=models.functions.TruncMonth("date")) - .values("m") - .annotate(sum=models.Sum("value")) - .order_by("m") - ) - _values_m = ( - Transaction.objects.filter(category=_category) - .filter(value__lt=0) - .annotate(m=models.functions.TruncMonth("date")) - .values("m") - .annotate(sum=models.Sum("value")) - .order_by("m") - ) - - fig, ax = plt.subplots() - ax.bar( - [v["m"] for v in _values_p], - [v["sum"] for v in _values_p], - width=12, - color="#66cc66", - ) - ax.bar( - [v["m"] for v in _values_m], - [v["sum"] for v in _values_m], - width=12, - color="#cc6699", - ) - ax.xaxis.set_major_formatter( - mdates.ConciseDateFormatter(ax.xaxis.get_major_locator()) - ) - ax.autoscale(True, "x", True) - _ym, _yp = ax.get_ylim() - ax.set(ylim=(min(_ym, 0), max(_yp, 0))) - ax.set(ylabel=f"{_category.name} (€)") - - _io = io.StringIO() - - fig.savefig(_io, format="svg") - - return HttpResponse(_io.getvalue(), headers={"Content-Type": "image/svg+xml"})