add balance prediction page and make it the default
This commit is contained in:
@@ -113,3 +113,66 @@ def predict_all(subjects: Iterable[Subject], past_days=30, future_days=60):
|
|||||||
prediction_list.append((subject, transactions, prediction_info))
|
prediction_list.append((subject, transactions, prediction_info))
|
||||||
|
|
||||||
return prediction_list
|
return prediction_list
|
||||||
|
|
||||||
|
|
||||||
|
min_pos_color = (Decimal("50"), Decimal("150"), Decimal("220"))
|
||||||
|
max_pos_color = (Decimal("50"), Decimal("170"), Decimal("20"))
|
||||||
|
min_neg_color = (Decimal("220"), Decimal("70"), Decimal("30"))
|
||||||
|
max_neg_color = (Decimal("180"), Decimal("160"), Decimal("30"))
|
||||||
|
|
||||||
|
global_minimum = Decimal("-300")
|
||||||
|
global_maximum = Decimal("300")
|
||||||
|
|
||||||
|
|
||||||
|
def limit(amount: Decimal, first_bound: Decimal, second_bound: Decimal):
|
||||||
|
lower = min(first_bound, second_bound)
|
||||||
|
upper = max(first_bound, second_bound)
|
||||||
|
return max(min(amount, upper), lower)
|
||||||
|
|
||||||
|
|
||||||
|
def get_color_for_amount(amount: Decimal, minimum: Decimal, maximum: Decimal):
|
||||||
|
if amount >= Decimal():
|
||||||
|
minimum = Decimal()
|
||||||
|
maximum = min(maximum, global_maximum)
|
||||||
|
rmin, gmin, bmin = min_pos_color
|
||||||
|
rmax, gmax, bmax = max_pos_color
|
||||||
|
else:
|
||||||
|
minimum = max(minimum, global_minimum)
|
||||||
|
maximum = Decimal()
|
||||||
|
rmin, gmin, bmin = min_neg_color
|
||||||
|
rmax, gmax, bmax = max_neg_color
|
||||||
|
assert minimum < maximum
|
||||||
|
percentage = (amount - minimum) / (maximum - minimum)
|
||||||
|
return "#%02x%02x%02x" % (
|
||||||
|
round(limit(rmin + (percentage * (rmax - rmin)), rmin, rmax)),
|
||||||
|
round(limit(gmin + (percentage * (gmax - gmin)), gmin, gmax)),
|
||||||
|
round(limit(bmin + (percentage * (bmax - bmin)), bmin, bmax)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def predict_balance(start_balance=Decimal("0")):
|
||||||
|
prediction_list = predict_all(Subject.objects.order_by("name"))
|
||||||
|
today = timezone.now().date()
|
||||||
|
future_transactions = []
|
||||||
|
minimum, maximum = Decimal(), Decimal()
|
||||||
|
for _, transactions, _ in prediction_list:
|
||||||
|
for transaction in transactions:
|
||||||
|
if transaction.booking_date > today:
|
||||||
|
future_transactions.append(transaction)
|
||||||
|
minimum = min(minimum, transaction.amount)
|
||||||
|
maximum = max(maximum, transaction.amount)
|
||||||
|
|
||||||
|
formatted_transactions = []
|
||||||
|
current_balance = start_balance
|
||||||
|
for transaction in sorted(future_transactions, key=lambda t: t.booking_date):
|
||||||
|
current_balance += transaction.amount
|
||||||
|
formatted_transactions.append({
|
||||||
|
"date": transaction.booking_date,
|
||||||
|
"subject": transaction.subject,
|
||||||
|
"amount": transaction.amount,
|
||||||
|
"balance": current_balance,
|
||||||
|
"predicted": transaction.pk is None,
|
||||||
|
"color": get_color_for_amount(transaction.amount, minimum, maximum),
|
||||||
|
})
|
||||||
|
|
||||||
|
return formatted_transactions
|
||||||
|
|||||||
@@ -28,19 +28,17 @@ body {
|
|||||||
color: #EEEEEE;
|
color: #EEEEEE;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, p, a {
|
|
||||||
margin: 0;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin-bottom: 12px;
|
margin: 0 0 12px;
|
||||||
|
padding: 0;
|
||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 6px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
#title, #navi, #content {
|
#title, #navi, #content {
|
||||||
flex: 0 1 0;
|
flex: 0 1 0;
|
||||||
width: 95%;
|
width: 95%;
|
||||||
@@ -51,7 +49,7 @@ h1 {
|
|||||||
#title {
|
#title {
|
||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #6699EE;
|
color: #77AAEE;
|
||||||
}
|
}
|
||||||
|
|
||||||
#navi {
|
#navi {
|
||||||
@@ -62,7 +60,8 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: #6699EE;
|
text-decoration: none;
|
||||||
|
color: #77AAEE;
|
||||||
transition: color 100ms;
|
transition: color 100ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,19 +69,20 @@ a:hover, a:focus {
|
|||||||
color: #EEEEEE;
|
color: #EEEEEE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
form label {
|
form label {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#login-form {
|
form input {
|
||||||
display: flex;
|
margin: 0 0 6px;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#id_username, #id_password, #login-warning, #login-button {
|
form p {
|
||||||
flex: 0 1 0;
|
color: #EE9966;
|
||||||
margin: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
table, thead, tbody, tfoot, tr, td {
|
table, thead, tbody, tfoot, tr, td {
|
||||||
@@ -103,7 +103,7 @@ thead {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tr {
|
tr {
|
||||||
border-bottom: 1px solid #444;
|
border-bottom: 1px solid #444444;
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
@@ -151,3 +151,81 @@ td {
|
|||||||
max-width: 240px;
|
max-width: 240px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#transaction-list {
|
||||||
|
margin: 12px 0;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-block {
|
||||||
|
flex: 0 1 0;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-block-first {
|
||||||
|
flex: 1 0 0;
|
||||||
|
margin: 0 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-block-second {
|
||||||
|
flex: 0 1 0;
|
||||||
|
margin: 0 0 0 8px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transaction-block-first, .transaction-block-second {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-block, .subject-block, .amount-block, .balance-block {
|
||||||
|
flex: 0 1 0;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-block, .amount-block, .balance-block {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-block, .balance-block {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-block {
|
||||||
|
color: #BBBBBB;
|
||||||
|
background-color: #222222;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-block {
|
||||||
|
color: #000000;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (min-width: 480px) {
|
||||||
|
.transaction-block-first, .transaction-block-second {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-block {
|
||||||
|
flex: 1 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-block, .amount-block {
|
||||||
|
margin: 0 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
57
core/templates/core/balance.html
Normal file
57
core/templates/core/balance.html
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{% extends "core/base.html" %}
|
||||||
|
|
||||||
|
{% block navi %}
|
||||||
|
<a href="{% url 'core:subjects' %}">subjects</a>
|
||||||
|
|
|
||||||
|
<a href="{% url 'admin:index' %}">admin</a>
|
||||||
|
|
|
||||||
|
<a href="{% url 'logout' %}">logout</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<h1>balance prediction</h1>
|
||||||
|
|
||||||
|
<form method="get" action="{% url 'core:balance' %}">
|
||||||
|
<label for="amount"></label>
|
||||||
|
<input id="amount" name="amount" type="number" step="0.01" placeholder="amount">
|
||||||
|
<input type="submit" value="predict"/>
|
||||||
|
{% if amount_error %}
|
||||||
|
<p>try a number, stupid</p>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p>starting with <b>{{ amount }}€</b> ...</p>
|
||||||
|
|
||||||
|
{% if future_transactions %}
|
||||||
|
<div id="transaction-list">
|
||||||
|
{% for transaction in future_transactions %}
|
||||||
|
<div class="transaction-block" style="
|
||||||
|
background: linear-gradient(90deg, transparent 10%, {{ transaction.color }} 100%);
|
||||||
|
border-left: 2px solid {{ transaction.color }};
|
||||||
|
">
|
||||||
|
<div class="transaction-block-first">
|
||||||
|
<div class="date-block" {% if transaction.predicted %}style="color: #77AAEE;"{% endif %}>
|
||||||
|
{% if transaction.predicted %}🔮{% else %}🗒{% endif %}
|
||||||
|
{{ transaction.date|date:"d.m.Y" }}
|
||||||
|
</div>
|
||||||
|
<div class="subject-block">
|
||||||
|
{{ transaction.subject }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="transaction-block-second">
|
||||||
|
<div class="amount-block">
|
||||||
|
{{ transaction.amount }}
|
||||||
|
</div>
|
||||||
|
<div class="balance-block">
|
||||||
|
{{ transaction.balance }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>no data to show :/</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
{% extends "core/base.html" %}
|
{% extends "core/base.html" %}
|
||||||
|
|
||||||
{% block navi %}
|
{% block navi %}
|
||||||
|
<a href="{% url 'core:balance' %}">balance</a>
|
||||||
|
|
|
||||||
<a href="{% url 'admin:index' %}">admin</a>
|
<a href="{% url 'admin:index' %}">admin</a>
|
||||||
|
|
|
|
||||||
<a href="{% url 'logout' %}">logout</a>
|
<a href="{% url 'logout' %}">logout</a>
|
||||||
@@ -26,7 +28,7 @@
|
|||||||
{% if forloop.counter == 1 %}
|
{% if forloop.counter == 1 %}
|
||||||
<td rowspan="{{ transactions|length }}" class="subject-cell">{{ subject.name }}</td>
|
<td rowspan="{{ transactions|length }}" class="subject-cell">{{ subject.name }}</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td class="date-cell">
|
<td class="date-cell" {% if not transaction.pk %}style="color: #77AAEE;"{% endif %}>
|
||||||
{% if transaction.pk %}🗒{% else %}🔮{% endif %}
|
{% if transaction.pk %}🗒{% else %}🔮{% endif %}
|
||||||
{{ transaction.booking_date|date:"d.m.y" }}
|
{{ transaction.booking_date|date:"d.m.y" }}
|
||||||
</td>
|
</td>
|
||||||
@@ -37,9 +39,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>
|
<p>no data to show :/</p>
|
||||||
no data to show :/
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -6,19 +6,17 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<form id="login-form" method="post" action="{% url 'login' %}">
|
<form method="post" action="{% url 'login' %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<label for="id_username">username</label>
|
<label for="username"></label>
|
||||||
<input id="id_username" name="username" type="text" placeholder="username">
|
<input id="username" name="username" type="text" placeholder="username">
|
||||||
<label for="id_password">password</label>
|
<label for="password">password</label>
|
||||||
<input id="id_password" name="password" type="password" placeholder="password">
|
<input id="password" name="password" type="password" placeholder="password">
|
||||||
{% if form.errors %}
|
<input type="submit" value="login"/>
|
||||||
<div id="login-warning">
|
|
||||||
nope.
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<input id="login-button" type="submit" value="login"/>
|
|
||||||
<input type="hidden" name="next" value="{{ next }}"/>
|
<input type="hidden" name="next" value="{{ next }}"/>
|
||||||
|
{% if form.errors %}
|
||||||
|
<p>nope.</p>
|
||||||
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ from django.contrib.auth.decorators import login_required
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from django.views.generic import RedirectView
|
from django.views.generic import RedirectView
|
||||||
|
|
||||||
from core.views import SubjectsView
|
from core.views import BalanceView, SubjectsView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", RedirectView.as_view(pattern_name="core:subjects", permanent=False), name="index"),
|
path("", RedirectView.as_view(pattern_name="core:balance", permanent=False), name="index"),
|
||||||
|
path("balance/", login_required(BalanceView.as_view()), name="balance"),
|
||||||
path("subjects/", login_required(SubjectsView.as_view()), name="subjects"),
|
path("subjects/", login_required(SubjectsView.as_view()), name="subjects"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,26 @@
|
|||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
from core.models import Subject
|
from core.models import Subject
|
||||||
from core.prediction import predict_all
|
from core.prediction import predict_all, predict_balance
|
||||||
|
|
||||||
|
|
||||||
|
class BalanceView(TemplateView):
|
||||||
|
template_name = "core/balance.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
amount, amount_error = Decimal(), False
|
||||||
|
if amount_query := self.request.GET.get("amount"):
|
||||||
|
try:
|
||||||
|
amount = Decimal(amount_query)
|
||||||
|
except InvalidOperation:
|
||||||
|
amount_error = True
|
||||||
|
context["amount"] = round(amount, 2)
|
||||||
|
context["amount_error"] = amount_error
|
||||||
|
context["future_transactions"] = predict_balance(amount)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
class SubjectsView(TemplateView):
|
class SubjectsView(TemplateView):
|
||||||
|
|||||||
Reference in New Issue
Block a user