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