Compare commits

...
Sign in to create a new pull request.

5 commits
main ... plot

20 changed files with 119 additions and 141 deletions

3
nummi/api/admin.py Normal file
View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View file

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

3
nummi/api/models.py Normal file
View file

@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

3
nummi/api/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

11
nummi/api/urls.py Normal file
View file

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

61
nummi/api/views.py Normal file
View file

@ -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")
)
}
)

View file

@ -28,6 +28,7 @@ class CategoryForm(NummiForm):
fields = [
"name",
"icon",
"budget",
]

View file

@ -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);

View file

@ -20,8 +20,6 @@
{{ form }}
</form>
{% if form.instance.transactions %}
<img src="{% url "plot-category" form.instance.id %}"
alt="Graph representing value over time"/>
<h2>{% translate "Transactions" %}</h2>
{% include "main/table/transaction.html" %}
{% endif %}

View file

@ -10,6 +10,7 @@
<link rel="stylesheet"
href="{% static 'main/css/table.css' %}"
type="text/css"/>
<script src="{% static 'main/js/history_plot.js' %}" type="module"></script>
{% endblock %}
{% block body %}
{% if accounts %}
@ -35,6 +36,7 @@
{% endfor %}
</div>
{% endspaceless %}
<div id="history_plot"></div>
{% endif %}
{% if snapshots %}
<h2>{% translate "Snapshots" %}</h2>

View file

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

View file

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

View file

@ -1 +0,0 @@
# Register your models here.

View file

@ -1 +0,0 @@
# Create your models here.

View file

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

View file

@ -1 +0,0 @@
# Create your tests here.

View file

@ -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/<uuid>", views.category, name="plot-category"),
]

View file

@ -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"})