completely remove old financeplanner app
This commit is contained in:
@@ -1,33 +0,0 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
from financeplanner.models import Transaction, Balance
|
||||
from financeplanner.utils import format_price
|
||||
|
||||
|
||||
class AdminSite(admin.AdminSite):
|
||||
index_title = "FinancePlanner"
|
||||
site_title = "Admin Panel"
|
||||
site_header = "Admin Panel"
|
||||
site_url = reverse_lazy("finance:index")
|
||||
|
||||
|
||||
admin_site = AdminSite()
|
||||
|
||||
|
||||
def amount(obj):
|
||||
return format_price(obj.amount)
|
||||
|
||||
|
||||
amount.short_description = "amount"
|
||||
|
||||
|
||||
@admin.register(Transaction, site=admin_site)
|
||||
class TransactionAdmin(admin.ModelAdmin):
|
||||
list_display = ("subject", amount, "booking_date", "recurring_months", "not_recurring_after")
|
||||
|
||||
|
||||
@admin.register(Balance, site=admin_site)
|
||||
class BalanceAdmin(admin.ModelAdmin):
|
||||
list_display = ("date", amount)
|
||||
ordering = ("-date",)
|
||||
@@ -1,6 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class FinancePlannerConfig(AppConfig):
|
||||
name = "financeplanner"
|
||||
verbose_name = "FinancePlanner"
|
||||
@@ -1,245 +0,0 @@
|
||||
from decimal import Decimal
|
||||
from math import floor
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.db.models import Q
|
||||
|
||||
from financeplanner.utils import current_date
|
||||
|
||||
days_per_year = Decimal("365.25")
|
||||
days_per_month = days_per_year / Decimal("12")
|
||||
days_per_week = Decimal("7")
|
||||
|
||||
|
||||
def _floor_to_first_two_places(dec):
|
||||
if dec < 100:
|
||||
return 100
|
||||
exp = floor(dec.log10()) - 1
|
||||
factor = 10 ** exp
|
||||
return factor * int(dec / factor)
|
||||
|
||||
|
||||
class ActualTransaction:
|
||||
def __init__(self, date, subject, amount):
|
||||
self.date = date
|
||||
self.subject = subject
|
||||
self.amount = amount
|
||||
|
||||
def __str__(self):
|
||||
return f"ActualTransaction({self.date}, {self.subject}, {self.amount})"
|
||||
|
||||
|
||||
class DailyStat:
|
||||
def __init__(self, stat_id, date, balance_amount, actual_transactions, resulting_amount):
|
||||
self.id = stat_id
|
||||
self.date = date
|
||||
self.balance_amount = balance_amount
|
||||
self.actual_transactions = actual_transactions
|
||||
self.resulting_amount = resulting_amount
|
||||
self.highlighted = False
|
||||
self.percentage = 40
|
||||
self.resulting_amount_irregular = self.resulting_amount
|
||||
self.percentage_irregular = self.percentage
|
||||
|
||||
def __str__(self):
|
||||
return f"DailyStat({self.date}, {self.balance_amount}, " \
|
||||
f"{len(self.actual_transactions)} a. transactions, {self.resulting_amount})"
|
||||
|
||||
def generate_graph_bar_attributes(self, amount_scale, today):
|
||||
self.highlighted = self.date == today
|
||||
if self.resulting_amount is not None:
|
||||
self.percentage = int((self.resulting_amount / amount_scale) * 100)
|
||||
self.percentage_irregular = self.percentage
|
||||
|
||||
def generate_irregular_graph_bar_attributes(self, amount_scale):
|
||||
if self.resulting_amount_irregular is not None:
|
||||
self.percentage_irregular = int((self.resulting_amount_irregular / amount_scale) * 100)
|
||||
|
||||
|
||||
class Statistics:
|
||||
_min_daily_amount_scale = 500
|
||||
|
||||
def __init__(self, user):
|
||||
self.user = user
|
||||
self.today = current_date()
|
||||
self.calc_start = self.today - relativedelta(months=6)
|
||||
self.calc_end = self.today + relativedelta(months=6)
|
||||
self.real_calc_start = self.calc_start
|
||||
self.display_start = self.today - relativedelta(months=1)
|
||||
self.display_end = self.today + relativedelta(months=2)
|
||||
self.balances = []
|
||||
self.transactions = []
|
||||
self.actual_transactions = []
|
||||
self.daily_stats = []
|
||||
self.daily_amount_scale = self._min_daily_amount_scale
|
||||
self.avg_monthly_income = 0
|
||||
self.avg_monthly_expenses = 0
|
||||
self.avg_monthly_result = 0
|
||||
self.avg_daily_irregular_expenses = 0
|
||||
self.avg_weekly_irregular_expenses = 0
|
||||
self.avg_monthly_irregular_expenses = 0
|
||||
|
||||
self._fetch_relevant_balances()
|
||||
self._fetch_relevant_transactions()
|
||||
self._calculate_actual_transactions()
|
||||
self._calculate_daily_stats()
|
||||
self._calculate_daily_amount_scale()
|
||||
self._generate_graph_bar_attributes()
|
||||
self._calculate_analysis()
|
||||
self._add_statistics_considering_analysis()
|
||||
|
||||
def _fetch_relevant_balances(self):
|
||||
self.balances = []
|
||||
balances_until_start = self.user.balances.filter(date__lte=self.calc_start)
|
||||
if balances_until_start.exists():
|
||||
self.balances.append(balances_until_start.latest("date"))
|
||||
other_balances = self.user.balances.filter(date__gt=self.calc_start, date__lte=self.calc_end).order_by("date")
|
||||
self.balances += list(other_balances)
|
||||
if self.balances:
|
||||
self.real_calc_start = self.balances[0].date
|
||||
|
||||
def _fetch_relevant_transactions(self):
|
||||
one_time_in_range = Q(
|
||||
booking_date__gte=self.real_calc_start,
|
||||
booking_date__lte=self.calc_end,
|
||||
recurring_months__isnull=True,
|
||||
)
|
||||
endlessly_recurring = Q(
|
||||
booking_date__lte=self.calc_end,
|
||||
recurring_months__isnull=False,
|
||||
not_recurring_after__isnull=True,
|
||||
)
|
||||
recurring_and_ending_in_range = Q(
|
||||
booking_date__lte=self.calc_end,
|
||||
recurring_months__isnull=False,
|
||||
not_recurring_after__gte=self.real_calc_start,
|
||||
)
|
||||
transactions = self.user.transactions.filter(
|
||||
one_time_in_range | endlessly_recurring | recurring_and_ending_in_range
|
||||
).order_by("booking_date")
|
||||
self.transactions = list(transactions)
|
||||
|
||||
def _calculate_actual_transactions(self):
|
||||
actual_transactions = []
|
||||
for trans in self.transactions:
|
||||
if trans.recurring_months:
|
||||
iter_date = trans.booking_date
|
||||
iter_step = 1
|
||||
limit = min(trans.not_recurring_after, self.calc_end) if trans.not_recurring_after else self.calc_end
|
||||
while iter_date < limit:
|
||||
if iter_date >= self.real_calc_start:
|
||||
actual_transactions.append(ActualTransaction(
|
||||
date=iter_date,
|
||||
subject=f"{trans.subject} #{iter_step}",
|
||||
amount=trans.amount,
|
||||
))
|
||||
iter_date += relativedelta(months=trans.recurring_months)
|
||||
iter_step += 1
|
||||
else:
|
||||
actual_transactions.append(ActualTransaction(
|
||||
date=trans.booking_date,
|
||||
subject=trans.subject,
|
||||
amount=trans.amount,
|
||||
))
|
||||
self.actual_transactions = sorted(actual_transactions, key=lambda t: t.date)
|
||||
|
||||
def _calculate_daily_stats(self):
|
||||
self.daily_stats = []
|
||||
iter_id = 0
|
||||
iter_date = self.real_calc_start
|
||||
iter_amount = None
|
||||
while iter_date < self.calc_end:
|
||||
balance_amount, actual_transactions = None, []
|
||||
|
||||
relevant_balances = [bal for bal in self.balances if bal.date == iter_date]
|
||||
if relevant_balances:
|
||||
assert len(relevant_balances) == 1, \
|
||||
f"balances should be unique for user and date, but here are {relevant_balances}"
|
||||
balance_amount = relevant_balances[0].amount
|
||||
iter_amount = balance_amount
|
||||
|
||||
for transaction in self.actual_transactions:
|
||||
if transaction.date == iter_date:
|
||||
actual_transactions.append(transaction)
|
||||
if iter_amount is not None:
|
||||
iter_amount += transaction.amount
|
||||
|
||||
if iter_amount is not None:
|
||||
self.daily_stats.append(DailyStat(
|
||||
stat_id=iter_id,
|
||||
date=iter_date,
|
||||
balance_amount=balance_amount,
|
||||
actual_transactions=actual_transactions,
|
||||
resulting_amount=iter_amount,
|
||||
))
|
||||
iter_id += 1
|
||||
iter_date += relativedelta(days=1)
|
||||
|
||||
def _calculate_daily_amount_scale(self):
|
||||
amounts = [s.resulting_amount for s in self.daily_stats
|
||||
if s.resulting_amount is not None
|
||||
and self.display_start <= s.date <= self.display_end]
|
||||
if amounts:
|
||||
max_amount = max(amounts)
|
||||
scale = _floor_to_first_two_places(max_amount * Decimal("1.25"))
|
||||
self.daily_amount_scale = max(scale, self._min_daily_amount_scale)
|
||||
|
||||
def _generate_graph_bar_attributes(self):
|
||||
for stat in self.daily_stats:
|
||||
stat.generate_graph_bar_attributes(self.daily_amount_scale, self.today)
|
||||
|
||||
def _calculate_analysis(self):
|
||||
booked = Q(booking_date__lte=self.today)
|
||||
incoming = Q(amount__gt=0)
|
||||
outgoing = Q(amount__lt=0)
|
||||
recurring = Q(recurring_months__isnull=False) & (
|
||||
Q(not_recurring_after__isnull=True) | Q(not_recurring_after__gte=self.today))
|
||||
|
||||
in_trans = self.user.transactions.filter(booked & incoming & recurring)
|
||||
in_trans_per_month = [t.amount / t.recurring_months for t in in_trans]
|
||||
self.avg_monthly_income = sum(in_trans_per_month)
|
||||
|
||||
out_trans = self.user.transactions.filter(booked & outgoing & recurring)
|
||||
out_trans_per_month = [t.amount / t.recurring_months for t in out_trans]
|
||||
self.avg_monthly_expenses = sum(out_trans_per_month)
|
||||
|
||||
self.avg_monthly_result = self.avg_monthly_income + self.avg_monthly_expenses
|
||||
|
||||
daily_irregular_expenses = []
|
||||
if len(self.balances) > 1:
|
||||
for i in range(1, len(self.balances)):
|
||||
previous_balance = self.balances[i - 1]
|
||||
current_balance = self.balances[i]
|
||||
day_before = current_balance.date - relativedelta(days=1)
|
||||
relevant_stats = [s for s in self.daily_stats if s.date == day_before]
|
||||
if relevant_stats and day_before > previous_balance.date:
|
||||
assert len(relevant_stats) == 1, \
|
||||
f"daily stats should be unique per date, but here are {relevant_stats}"
|
||||
amount_diff = current_balance.amount - relevant_stats[0].resulting_amount
|
||||
time_diff = current_balance.date - previous_balance.date
|
||||
daily_irregular_expenses.append(amount_diff / time_diff.days)
|
||||
if daily_irregular_expenses:
|
||||
self.avg_daily_irregular_expenses = sum(daily_irregular_expenses) / len(daily_irregular_expenses)
|
||||
self.avg_weekly_irregular_expenses = self.avg_daily_irregular_expenses * days_per_week
|
||||
self.avg_monthly_irregular_expenses = self.avg_daily_irregular_expenses * days_per_month
|
||||
|
||||
self.avg_monthly_result_complete = self.avg_monthly_result + self.avg_monthly_irregular_expenses
|
||||
|
||||
def _add_statistics_considering_analysis(self):
|
||||
previous_original_amount = None
|
||||
iter_amount = None
|
||||
for stat in self.daily_stats:
|
||||
if stat.resulting_amount is not None:
|
||||
relevant_balances = [bal for bal in self.balances if bal.date == stat.date]
|
||||
if relevant_balances or previous_original_amount is None:
|
||||
iter_amount = stat.resulting_amount + self.avg_daily_irregular_expenses
|
||||
else:
|
||||
orig_diff = stat.resulting_amount - previous_original_amount
|
||||
new_diff = orig_diff + self.avg_daily_irregular_expenses
|
||||
iter_amount += new_diff
|
||||
stat.resulting_amount_irregular = iter_amount
|
||||
stat.generate_irregular_graph_bar_attributes(self.daily_amount_scale)
|
||||
previous_original_amount = stat.resulting_amount
|
||||
|
||||
def get_daily_stats_in_range(self):
|
||||
return [s for s in self.daily_stats if self.display_start <= s.date <= self.display_end]
|
||||
@@ -1,20 +0,0 @@
|
||||
from django.contrib.auth.management.commands import createsuperuser
|
||||
from django.db import transaction
|
||||
|
||||
USERNAME = "admin"
|
||||
|
||||
|
||||
class Command(createsuperuser.Command):
|
||||
help = "Used to create the superuser 'admin' with password '123'"
|
||||
|
||||
@transaction.atomic()
|
||||
def handle(self, *args, **options):
|
||||
options.update({
|
||||
self.UserModel.USERNAME_FIELD: USERNAME,
|
||||
"email": "admin@test.com",
|
||||
"interactive": False,
|
||||
})
|
||||
super().handle(*args, **options)
|
||||
user = self.UserModel.objects.latest("pk")
|
||||
user.set_password("123")
|
||||
user.save()
|
||||
@@ -1,68 +0,0 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management import BaseCommand
|
||||
|
||||
from financeplanner.management.commands.createadmin import USERNAME
|
||||
from financeplanner.models import Transaction, Balance
|
||||
from financeplanner.utils import current_date
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
||||
def handle(self, *args, **options):
|
||||
user = User.objects.get(**{User.USERNAME_FIELD: USERNAME})
|
||||
|
||||
today = current_date()
|
||||
|
||||
Transaction.objects.create(
|
||||
user=user,
|
||||
subject="simple transaction some days ago",
|
||||
amount=Decimal("34.95"),
|
||||
booking_date=today - relativedelta(days=5),
|
||||
)
|
||||
|
||||
Transaction.objects.create(
|
||||
user=user,
|
||||
subject="simple transaction a week ahead",
|
||||
amount=Decimal("66.66"),
|
||||
booking_date=today + relativedelta(weeks=1),
|
||||
)
|
||||
|
||||
Transaction.objects.create(
|
||||
user=user,
|
||||
subject="recurring stuff ending soon",
|
||||
amount=Decimal("49"),
|
||||
booking_date=today - relativedelta(days=180),
|
||||
recurring_months=1,
|
||||
not_recurring_after=today + relativedelta(weeks=6)
|
||||
)
|
||||
|
||||
Transaction.objects.create(
|
||||
user=user,
|
||||
subject="recurring stuff beginning soon",
|
||||
amount=Decimal("30"),
|
||||
recurring_months=2,
|
||||
booking_date=today + relativedelta(weeks=2),
|
||||
)
|
||||
|
||||
Balance.objects.create(
|
||||
user=user,
|
||||
date=today - relativedelta(weeks=7),
|
||||
amount=Decimal("600"),
|
||||
)
|
||||
|
||||
Balance.objects.create(
|
||||
user=user,
|
||||
date=today - relativedelta(weeks=3),
|
||||
amount=Decimal("380"),
|
||||
)
|
||||
|
||||
Balance.objects.create(
|
||||
user=user,
|
||||
date=today - relativedelta(days=2),
|
||||
amount=Decimal("450"),
|
||||
)
|
||||
@@ -1,42 +0,0 @@
|
||||
# Generated by Django 3.0.3 on 2020-02-18 08:56
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import financeplanner.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Transaction',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('subject', models.CharField(max_length=100, verbose_name='subject')),
|
||||
('amount', financeplanner.models.PriceField(decimal_places=2, max_digits=8, verbose_name='amount')),
|
||||
('booking_date', models.DateField(verbose_name='booking date')),
|
||||
('recurring_months', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='recurring months')),
|
||||
('not_recurring_after', models.DateField(blank=True, null=True, verbose_name='not recurring after')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Balance',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('date', models.DateField(verbose_name='date')),
|
||||
('amount', financeplanner.models.PriceField(decimal_places=2, max_digits=8, verbose_name='amount')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='balances', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('user', 'date')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,60 +0,0 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
|
||||
from financeplanner.utils import round_with_dec_places
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class PriceField(models.DecimalField):
|
||||
|
||||
def to_python(self, value):
|
||||
value = super(PriceField, self).to_python(value)
|
||||
return round_with_dec_places(value, self.decimal_places)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# allowing numbers until 999,999.99
|
||||
kwargs['max_digits'] = 8
|
||||
kwargs['decimal_places'] = 2
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class BaseModel(models.Model):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def save(self, force_insert=False, force_update=False, using=None, update_fields=None):
|
||||
self.full_clean()
|
||||
super().save(force_insert, force_update, using, update_fields)
|
||||
|
||||
|
||||
class Transaction(models.Model):
|
||||
user = models.ForeignKey(to=User, related_name="transactions", on_delete=models.CASCADE)
|
||||
subject = models.CharField("subject", max_length=100)
|
||||
amount = PriceField("amount")
|
||||
booking_date = models.DateField("booking date")
|
||||
recurring_months = models.PositiveSmallIntegerField("recurring months", null=True, blank=True)
|
||||
not_recurring_after = models.DateField("not recurring after", null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.subject}: {self.amount:.2f}€"
|
||||
|
||||
def clean(self):
|
||||
if self.recurring_months is not None and self.recurring_months < 1:
|
||||
raise ValidationError("recurring_months must be at least 1, if present")
|
||||
|
||||
if self.not_recurring_after and self.not_recurring_after <= self.booking_date:
|
||||
raise ValidationError("not_recurring_after must be later than booking_date")
|
||||
|
||||
|
||||
class Balance(models.Model):
|
||||
class Meta:
|
||||
unique_together = ("user", "date")
|
||||
|
||||
user = models.ForeignKey(to=User, related_name="balances", on_delete=models.CASCADE)
|
||||
date = models.DateField("date")
|
||||
amount = PriceField("amount")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.date}: {self.amount:.2f}€"
|
||||
@@ -1,203 +0,0 @@
|
||||
* {
|
||||
color: #FFFCF9;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background-color: #131211;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 18px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 1.4em;
|
||||
font-weight: normal;
|
||||
color: #FFCC99;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 6px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 1.1em;
|
||||
font-weight: normal;
|
||||
color: #FFCC99;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #FFCC99;
|
||||
text-decoration: none;
|
||||
transition: color 150ms;
|
||||
}
|
||||
|
||||
a:hover, a:focus {
|
||||
color: #CC6622;
|
||||
}
|
||||
|
||||
table {
|
||||
margin: 6px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 3px 12px 3px 6px;
|
||||
border: 1px solid #3F3D3B;
|
||||
}
|
||||
|
||||
thead {
|
||||
font-variant: small-caps;
|
||||
}
|
||||
|
||||
tr {
|
||||
background-color: rgba(255, 255, 255, 0);
|
||||
transition: background-color 100ms;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.mini-link {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.flex-col-centering {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.text-centering {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
flex-shrink: 1;
|
||||
width: 90%;
|
||||
max-width: 1200px;
|
||||
padding: 12px;
|
||||
margin-top: 12px;
|
||||
background-color: #282624;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
flex: 1;
|
||||
width: 95%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-line {
|
||||
flex: 1;
|
||||
width: 95%;
|
||||
margin: 6px;
|
||||
padding: 3px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login-label {
|
||||
flex-shrink: 1;
|
||||
margin-right: 6px;
|
||||
padding: 2px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.login-textbox {
|
||||
flex-grow: 1;
|
||||
padding: 2px;
|
||||
min-width: 60px;
|
||||
background-color: #DDDDDD;
|
||||
color: #131211;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.login-warning {
|
||||
flex: 1;
|
||||
padding: 2px;
|
||||
text-align: center;
|
||||
color: #CC6622;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
flex: 0 0 120px;
|
||||
padding: 4px;
|
||||
background-color: #202020;
|
||||
border: 1px solid #666666;
|
||||
border-radius: 4px;
|
||||
color: #FFCC99;
|
||||
transition: color 150ms, background-color 150ms;
|
||||
}
|
||||
|
||||
.login-button:hover, .login-button:focus {
|
||||
background-color: #131211;
|
||||
color: #CC6622;
|
||||
}
|
||||
|
||||
#big-stat-panel {
|
||||
width: 100%;
|
||||
opacity: 50%;
|
||||
font-size: 1.4em;
|
||||
height: 1.4em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#small-stat-panel {
|
||||
width: 100%;
|
||||
opacity: 50%;
|
||||
font-size: 0.8em;
|
||||
height: 0.8em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#graph {
|
||||
flex: 0 0 300px;
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: end;
|
||||
padding-bottom: 4px;
|
||||
overflow-x: auto;
|
||||
transition: opacity 100ms;
|
||||
}
|
||||
|
||||
#graph.scrolling {
|
||||
opacity: 75%;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.graph-bar {
|
||||
flex: 1 1 0;
|
||||
min-width: 10px;
|
||||
min-height: 10px;
|
||||
margin-right: 1px;
|
||||
border-radius: 2px;
|
||||
background-color: #FFCC99;
|
||||
z-index: 10;
|
||||
transition: height 250ms;
|
||||
}
|
||||
|
||||
.graph-bar.highlighted {
|
||||
background-color: #CC6622;
|
||||
}
|
||||
|
||||
.graph-bar:hover {
|
||||
background-color: #FFFCF9;
|
||||
box-shadow: #FFFCF9 0 0 12px;
|
||||
z-index: 11;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{% load static %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta charset="utf-8">
|
||||
<title>{% block title %}FinancePlanner{% endblock %}</title>
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'financeplanner/style.css' %}">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>📊 FinancePlanner 💰</h1>
|
||||
|
||||
<p>
|
||||
{% block navi %}{% endblock %}
|
||||
</p>
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,223 +0,0 @@
|
||||
{% extends "financeplanner/base.html" %}
|
||||
|
||||
{% load custom_tags %}
|
||||
|
||||
{% block navi %}
|
||||
<a href="{% url 'admin:index' %}" class="mini-link">Admin Panel</a>
|
||||
-
|
||||
<a href="{% url 'logout' %}" class="mini-link">Logout</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="main-container flex-col-centering">
|
||||
{% if daily_stats %}
|
||||
<div id="big-stat-panel"></div>
|
||||
<div id="small-stat-panel"></div>
|
||||
<div id="graph">
|
||||
{% for stat in daily_stats %}
|
||||
<div class="graph-bar{% if stat.highlighted %} highlighted{% endif %}"
|
||||
id="graph-bar-{{ stat.id }}">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>No enough data from {{ start|date:"d.m.y" }} til {{ end|date:"d.m.y" }} to show a graph.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="main-container">
|
||||
<h2>Analysis</h2>
|
||||
<p>current average amounts per month:</p>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>income</td>
|
||||
<td>{{ avg_monthly_income|euro }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>expenses</td>
|
||||
<td>{{ avg_monthly_expenses|euro }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>result</td>
|
||||
<td>{{ avg_monthly_result|euro }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>average irregular expenses based on the last half-a-year:</p>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>monthly</td>
|
||||
<td>{{ avg_monthly_irregular_expenses|euro }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>weekly</td>
|
||||
<td>{{ avg_weekly_irregular_expenses|euro }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>daily</td>
|
||||
<td>{{ avg_daily_irregular_expenses|euro }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
<input type="checkbox" id="irregular-expenses-checkbox">
|
||||
<label for="irregular-expenses-checkbox">take irregular expenses into account</label>
|
||||
</p>
|
||||
|
||||
<p>monthly result after irregular expenses: {{ avg_monthly_result_complete|euro }}</p>
|
||||
</div>
|
||||
|
||||
<div class="main-container">
|
||||
<h2>Relevant Balances</h2>
|
||||
{% if balances %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>date</td>
|
||||
<td>amount</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for balance in balances %}
|
||||
<tr>
|
||||
<td>{{ balance.date|date:"d.m.y" }}</td>
|
||||
<td>{{ balance.amount|euro }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>-</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="main-container">
|
||||
<h2>Relevant Stored Transactions</h2>
|
||||
{% if transactions %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>subject</td>
|
||||
<td>amount</td>
|
||||
<td>booking date</td>
|
||||
<td>recurring months</td>
|
||||
<td>not recurring after</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for transaction in transactions %}
|
||||
<tr>
|
||||
<td>{{ transaction.subject }}</td>
|
||||
<td>{{ transaction.amount|euro }}</td>
|
||||
<td>{{ transaction.booking_date|date:"d.m.y" }}</td>
|
||||
<td>{{ transaction.recurring_months }}</td>
|
||||
<td>{{ transaction.not_recurring_after|date:"d.m.y" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>-</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="main-container">
|
||||
<h2>Calculated Actual Transactions</h2>
|
||||
{% if actual_transactions %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>date</td>
|
||||
<td>subject</td>
|
||||
<td>amount</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for trans in actual_transactions %}
|
||||
<tr>
|
||||
<td>{{ trans.date|date:"d.m.y" }}</td>
|
||||
<td>{{ trans.subject }}</td>
|
||||
<td>{{ trans.amount|euro }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>-</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
const graph = document.getElementById('graph');
|
||||
const bigStatPanel = document.getElementById('big-stat-panel');
|
||||
const smallStatPanel = document.getElementById('small-stat-panel');
|
||||
const irrExpCheckbox = document.getElementById('irregular-expenses-checkbox');
|
||||
|
||||
{% for stat in daily_stats %}
|
||||
const graphBar{{ stat.id }} = document.getElementById('graph-bar-{{ stat.id }}');
|
||||
{% endfor %}
|
||||
|
||||
let scrolling = false;
|
||||
let initX;
|
||||
let initScrollLeft;
|
||||
|
||||
function startDragScroll(event) {
|
||||
scrolling = true;
|
||||
graph.classList.add('scrolling');
|
||||
initX = event.pageX - graph.offsetLeft;
|
||||
initScrollLeft = graph.scrollLeft;
|
||||
}
|
||||
|
||||
function stopDragScroll() {
|
||||
scrolling = false;
|
||||
graph.classList.remove('scrolling');
|
||||
}
|
||||
|
||||
function doDragScroll(event) {
|
||||
if (scrolling) {
|
||||
event.preventDefault();
|
||||
const newX = event.pageX - graph.offsetLeft;
|
||||
const diff = (newX - initX);
|
||||
graph.scrollLeft = initScrollLeft - diff;
|
||||
}
|
||||
}
|
||||
|
||||
function applyGraphBarHeights() {
|
||||
if (irrExpCheckbox.checked) {
|
||||
{% for stat in daily_stats %}
|
||||
graphBar{{ stat.id }}.style.height = '{{ stat.percentage_irregular }}%';
|
||||
{% endfor %}
|
||||
} else {
|
||||
{% for stat in daily_stats %}
|
||||
graphBar{{ stat.id }}.style.height = '{{ stat.percentage }}%';
|
||||
{% endfor %}
|
||||
}
|
||||
}
|
||||
|
||||
graph.addEventListener('mousedown', (event) => startDragScroll(event));
|
||||
graph.addEventListener('mouseleave', () => stopDragScroll());
|
||||
graph.addEventListener('mouseup', () => stopDragScroll());
|
||||
graph.addEventListener('mousemove', (event) => doDragScroll(event));
|
||||
irrExpCheckbox.addEventListener('change', () => applyGraphBarHeights());
|
||||
window.onload = applyGraphBarHeights;
|
||||
|
||||
{% for stat in daily_stats %}
|
||||
graphBar{{ stat.id }}.addEventListener('mouseover', () => function () {
|
||||
if (irrExpCheckbox.checked) {
|
||||
bigStatPanel.innerText = '{{ stat|stat_header:True }}';
|
||||
} else {
|
||||
bigStatPanel.innerText = '{{ stat|stat_header:False }}';
|
||||
}
|
||||
smallStatPanel.innerText = '{{ stat|stat_details }}';
|
||||
}());
|
||||
graphBar{{ stat.id }}.addEventListener('mouseleave', () => function () {
|
||||
bigStatPanel.innerText = '';
|
||||
smallStatPanel.innerText = '';
|
||||
}());
|
||||
{% endfor %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,15 +0,0 @@
|
||||
{% extends "financeplanner/base.html" %}
|
||||
|
||||
{% block title %}FinancePlanner - Logout{% endblock %}
|
||||
|
||||
{% block navi %}
|
||||
<a href="{% url 'admin:index' %}" class="mini-link">Admin Panel</a>
|
||||
-
|
||||
<a href="{% url 'login' %}" class="mini-link">Login</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="main-container text-centering">
|
||||
<p>You have been logged out.</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,30 +0,0 @@
|
||||
{% extends "financeplanner/base.html" %}
|
||||
|
||||
{% block title %}FinancePlanner - Login{% endblock %}
|
||||
|
||||
{% block navi %}
|
||||
<a href="{% url 'admin:index' %}" class="mini-link">Admin Panel</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="main-container flex-col-centering">
|
||||
<form id="login-form" method="post" class="login-form" action="{% url 'login' %}">
|
||||
{% csrf_token %}
|
||||
<p class="login-line">
|
||||
<label for="id_username" class="login-label">Username</label>
|
||||
<input id="id_username" name="username" type="text" class="login-textbox">
|
||||
</p>
|
||||
<p class="login-line">
|
||||
<label for="id_password" class="login-label">Password</label>
|
||||
<input id="id_password" name="password" type="password" class="login-textbox">
|
||||
</p>
|
||||
{% if form.errors %}
|
||||
<p class="login-line login-warning">Wrong credentials, please try again.</p>
|
||||
{% endif %}
|
||||
<p class="login-line">
|
||||
<input type="submit" value="Login" class="login-button"/>
|
||||
</p>
|
||||
<input type="hidden" name="next" value="{{ next }}"/>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,33 +0,0 @@
|
||||
from django import template
|
||||
from django.template.defaultfilters import date
|
||||
|
||||
from financeplanner.calc import DailyStat
|
||||
from financeplanner.utils import format_price
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter(name="euro")
|
||||
def euro(value):
|
||||
return format_price(value) or "-"
|
||||
|
||||
|
||||
@register.filter(name="stat_header")
|
||||
def stat_header(value: DailyStat, irregular):
|
||||
return " | ".join([
|
||||
date(value.date, "d.m.y"),
|
||||
euro(value.resulting_amount_irregular if irregular else value.resulting_amount)
|
||||
])
|
||||
|
||||
|
||||
@register.filter(name="stat_details")
|
||||
def stat_details(value: DailyStat):
|
||||
parts = []
|
||||
if value.balance_amount is not None:
|
||||
parts.append(f"Balance: {euro(value.balance_amount)}")
|
||||
for trans in value.actual_transactions:
|
||||
parts.append(f"{trans.subject}: {euro(trans.amount)}")
|
||||
result = " | ".join(parts) or "-"
|
||||
if len(result) > 80:
|
||||
return result[:47] + "..."
|
||||
return result
|
||||
@@ -1,7 +0,0 @@
|
||||
from django.urls import path
|
||||
|
||||
from financeplanner import views
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.index, name="index"),
|
||||
]
|
||||
@@ -1,20 +0,0 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def current_date():
|
||||
return timezone.now().date()
|
||||
|
||||
|
||||
def round_with_dec_places(number, places):
|
||||
if number is None:
|
||||
return None
|
||||
dec_num = number if isinstance(number, Decimal) else Decimal(number)
|
||||
return dec_num.quantize(Decimal(10) ** -places)
|
||||
|
||||
|
||||
def format_price(number):
|
||||
if number is None:
|
||||
return None
|
||||
return f"{round_with_dec_places(number, 2)} €"
|
||||
@@ -1,26 +0,0 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import render
|
||||
|
||||
from financeplanner.calc import Statistics
|
||||
|
||||
|
||||
@login_required
|
||||
def index(request):
|
||||
user = request.user
|
||||
statistics = Statistics(user)
|
||||
context = {
|
||||
"start": statistics.display_start,
|
||||
"end": statistics.display_end,
|
||||
"balances": statistics.balances,
|
||||
"transactions": statistics.transactions,
|
||||
"actual_transactions": statistics.actual_transactions,
|
||||
"daily_stats": statistics.get_daily_stats_in_range(),
|
||||
"avg_monthly_income": statistics.avg_monthly_income,
|
||||
"avg_monthly_expenses": statistics.avg_monthly_expenses,
|
||||
"avg_monthly_result": statistics.avg_monthly_result,
|
||||
"avg_daily_irregular_expenses": statistics.avg_daily_irregular_expenses,
|
||||
"avg_weekly_irregular_expenses": statistics.avg_weekly_irregular_expenses,
|
||||
"avg_monthly_irregular_expenses": statistics.avg_monthly_irregular_expenses,
|
||||
"avg_monthly_result_complete": statistics.avg_monthly_result_complete,
|
||||
}
|
||||
return render(request, "financeplanner/index.html", context)
|
||||
Reference in New Issue
Block a user