From aadff671bdf0473b414ddbdfb6ead37c17896f5f Mon Sep 17 00:00:00 2001 From: Florian Hartmann Date: Tue, 18 Feb 2020 16:01:28 +0100 Subject: [PATCH] copy whole project from another repo --- .dockerignore | 5 + .gitignore | 3 + Dockerfile | 7 + docker-compose.yml | 32 +++ entrypoint.sh | 14 ++ financeplanner/__init__.py | 0 financeplanner/admin.py | 60 +++++ financeplanner/apps.py | 6 + financeplanner/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/createadmin.py | 20 ++ .../management/commands/filldummydata.py | 67 ++++++ financeplanner/migrations/0001_initial.py | 42 ++++ financeplanner/migrations/__init__.py | 0 financeplanner/models.py | 60 +++++ .../static/financeplanner/style.css | 212 ++++++++++++++++++ .../templates/financeplanner/index.html | 115 ++++++++++ .../templates/registration/logged_out.html | 25 +++ .../templates/registration/login.html | 40 ++++ financeplanner/urls.py | 7 + financeplanner/utils.py | 57 +++++ financeplanner/views.py | 174 ++++++++++++++ floplanner/__init__.py | 0 floplanner/settings.py | 106 +++++++++ floplanner/urls.py | 13 ++ floplanner/wsgi.py | 16 ++ manage.py | 21 ++ requirements.txt | 4 + uwsgi.ini | 6 + wait-for-it.sh | 178 +++++++++++++++ 30 files changed, 1290 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100755 entrypoint.sh create mode 100644 financeplanner/__init__.py create mode 100644 financeplanner/admin.py create mode 100644 financeplanner/apps.py create mode 100644 financeplanner/management/__init__.py create mode 100644 financeplanner/management/commands/__init__.py create mode 100644 financeplanner/management/commands/createadmin.py create mode 100644 financeplanner/management/commands/filldummydata.py create mode 100644 financeplanner/migrations/0001_initial.py create mode 100644 financeplanner/migrations/__init__.py create mode 100644 financeplanner/models.py create mode 100644 financeplanner/static/financeplanner/style.css create mode 100644 financeplanner/templates/financeplanner/index.html create mode 100644 financeplanner/templates/registration/logged_out.html create mode 100644 financeplanner/templates/registration/login.html create mode 100644 financeplanner/urls.py create mode 100644 financeplanner/utils.py create mode 100644 financeplanner/views.py create mode 100644 floplanner/__init__.py create mode 100644 floplanner/settings.py create mode 100644 floplanner/urls.py create mode 100644 floplanner/wsgi.py create mode 100755 manage.py create mode 100644 requirements.txt create mode 100644 uwsgi.ini create mode 100755 wait-for-it.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1259611 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.dockerignore +.gitignore +docker-compose.yml +Dockerfile +volumes diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9217319 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea +**/__pycache__ +volumes diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..86d555c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.8.1 +ENV PYTHONUNBUFFERED=1 +WORKDIR /code/ +COPY requirements.txt . +RUN pip install --no-cache-dir --requirement requirements.txt +COPY . . +ENTRYPOINT ["./entrypoint.sh"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2655263 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +version: "3.7" + +services: + + db: + image: postgres:12.2-alpine + environment: + POSTGRES_DB: "floplanner_db" + POSTGRES_USER: "floplanner_user" + POSTGRES_PASSWORD: "hhktinWzk1SRg7K6eW0e45hUNLn8ZU" + expose: + - "5432" + volumes: + - ./volumes/db/data:/var/lib/postgresql/data + - /etc/localtime:/etc/localtime:ro + + web: + build: + context: . + environment: + DB_HOST: "db" + DB_PORT: "5432" + DB_NAME: "floplanner_db" + DB_USER: "floplanner_user" + DB_PASSWORD: "hhktinWzk1SRg7K6eW0e45hUNLn8ZU" + ports: + - "80:8000" + links: + - db + volumes: + - .:/code + - /etc/localtime:/etc/localtime:ro diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..1beac02 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -e + +if [ "$DJANGO_PRODUCTION_MODE" == "true" ]; then + echo "starting production server ..." + ./wait-for-it.sh --host=${DB_HOST} --port=${DB_PORT} --strict --timeout=20 -- \ + uwsgi --ini uwsgi.ini + +else + echo "starting development server ..." + ./wait-for-it.sh --host=${DB_HOST} --port=${DB_PORT} --strict --timeout=20 -- \ + python manage.py runserver 0.0.0.0:8000 + +fi diff --git a/financeplanner/__init__.py b/financeplanner/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/financeplanner/admin.py b/financeplanner/admin.py new file mode 100644 index 0000000..af343f8 --- /dev/null +++ b/financeplanner/admin.py @@ -0,0 +1,60 @@ +from django.contrib import admin +from django.urls import reverse_lazy +from django.utils.safestring import mark_safe + +from financeplanner.models import Transaction, Balance +from financeplanner.utils import format_price, get_transaction_progress + + +class AdminSite(admin.AdminSite): + index_title = "FloPlanner" + 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" + + +def progress(transaction): + total, paid = get_transaction_progress(transaction) + if total: + percentage = int((paid / total) * 100) + else: + percentage = 0 + outer_style = ( + "width: 90%;", + "height: 10px;", + "background-color: #dd4646;", + ) + inner_style = ( + f"width: {percentage}%;", + "height: 100%;", + "background-color: #70bf2b;", + ) + return mark_safe( + f'
' + f'
' + f'
' + ) + + +progress.short_description = "progress" + + +@admin.register(Transaction, site=admin_site) +class TransactionAdmin(admin.ModelAdmin): + list_display = ("subject", amount, "booking_date", "recurring_months", "not_recurring_after", progress) + + +@admin.register(Balance, site=admin_site) +class BalanceAdmin(admin.ModelAdmin): + list_display = ("date", amount) + ordering = ("-date",) diff --git a/financeplanner/apps.py b/financeplanner/apps.py new file mode 100644 index 0000000..e2f55bb --- /dev/null +++ b/financeplanner/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class FinancePlannerConfig(AppConfig): + name = "financeplanner" + verbose_name = "FinancePlanner" diff --git a/financeplanner/management/__init__.py b/financeplanner/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/financeplanner/management/commands/__init__.py b/financeplanner/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/financeplanner/management/commands/createadmin.py b/financeplanner/management/commands/createadmin.py new file mode 100644 index 0000000..4e4ed8e --- /dev/null +++ b/financeplanner/management/commands/createadmin.py @@ -0,0 +1,20 @@ +from django.contrib.auth.management.commands import createsuperuser +from django.db import transaction + +USERNAME = "flo" + + +class Command(createsuperuser.Command): + help = "Used to create the superuser 'flo' with password '123'" + + @transaction.atomic() + def handle(self, *args, **options): + options.update({ + self.UserModel.USERNAME_FIELD: USERNAME, + "email": "flo@test.com", + "interactive": False, + }) + super().handle(*args, **options) + user = self.UserModel.objects.latest("pk") + user.set_password("123") + user.save() diff --git a/financeplanner/management/commands/filldummydata.py b/financeplanner/management/commands/filldummydata.py new file mode 100644 index 0000000..6e7b113 --- /dev/null +++ b/financeplanner/management/commands/filldummydata.py @@ -0,0 +1,67 @@ +from datetime import datetime, timedelta +from decimal import Decimal + +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 + +User = get_user_model() + + +class Command(BaseCommand): + + def handle(self, *args, **options): + user = User.objects.get(**{User.USERNAME_FIELD: USERNAME}) + + today = datetime.now().date() + + Transaction.objects.create( + user=user, + subject="simple transaction some days ago", + amount=Decimal("34.95"), + booking_date=today - timedelta(days=5), + ) + + Transaction.objects.create( + user=user, + subject="simple transaction a week ahead", + amount=Decimal("66.66"), + booking_date=today + timedelta(weeks=1), + ) + + Transaction.objects.create( + user=user, + subject="recurring stuff ending soon", + amount=Decimal("49"), + booking_date=today - timedelta(days=180), + recurring_months=1, + not_recurring_after=today + timedelta(weeks=6) + ) + + Transaction.objects.create( + user=user, + subject="recurring stuff beginning soon", + amount=Decimal("30"), + recurring_months=2, + booking_date=today + timedelta(weeks=2), + ) + + Balance.objects.create( + user=user, + date=today - timedelta(weeks=7), + amount=Decimal("600"), + ) + + Balance.objects.create( + user=user, + date=today - timedelta(weeks=3), + amount=Decimal("380"), + ) + + Balance.objects.create( + user=user, + date=today - timedelta(days=2), + amount=Decimal("450"), + ) diff --git a/financeplanner/migrations/0001_initial.py b/financeplanner/migrations/0001_initial.py new file mode 100644 index 0000000..afe0f6b --- /dev/null +++ b/financeplanner/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# 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')}, + }, + ), + ] diff --git a/financeplanner/migrations/__init__.py b/financeplanner/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/financeplanner/models.py b/financeplanner/models.py new file mode 100644 index 0000000..ae63f27 --- /dev/null +++ b/financeplanner/models.py @@ -0,0 +1,60 @@ +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}€" diff --git a/financeplanner/static/financeplanner/style.css b/financeplanner/static/financeplanner/style.css new file mode 100644 index 0000000..78ea7e5 --- /dev/null +++ b/financeplanner/static/financeplanner/style.css @@ -0,0 +1,212 @@ +* { + 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: 24px; + font-weight: normal; + color: #FFCC99; +} + +a { + color: #FFCC99; + text-decoration: none; + transition: color 150ms; +} + +a:hover, a:focus { + color: #CC6622; +} + +.mini-link { + font-size: 14px; +} + +.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; +} + +#graph { + flex: 0 0 300px; + width: 100%; + display: flex; + flex-direction: row; + align-items: end; + padding-bottom: 6px; + overflow-x: scroll; + transition: opacity 100ms; +} + +#graph.scrolling { + opacity: 75%; + cursor: grabbing; +} + +.graph-bar { + flex: 1 1 0; + min-width: 8px; + min-height: 8px; + margin: 1px; + border-radius: 2px; + opacity: 60%; +} + +.graph-bar.highlighted { + opacity: 100%; + border: 1px solid #FFFCF9; +} + +.graph-bar.strong { + opacity: 80%; +} + +.graph-bar.weak { + height: 40%; + opacity: 40%; +} + +.graph-bar.green { + background-color: #55DD22; +} + +.graph-bar.olive { + background-color: #99CC33; +} + +.graph-bar.yellow { + background-color: #CCCC33; +} + +.graph-bar.orange { + background-color: #CC9933; +} + +.graph-bar.red { + background-color: #DD5522; +} + +.graph-bar.grey { + background-color: #777766; +} + +.graph-bar:hover { + background-color: #FFFCF9; + box-shadow: #FFFCF9 0 0 12px; + opacity: 100%; +} + +.tooltip { + position: relative; + display: inline-block; +} + +.tooltip .tooltiptext { + visibility: hidden; + opacity: 90%; + width: 150px; + min-height: 50px; + background-color: #131211; + padding: 6px; + border-radius: 4px; + position: absolute; + bottom: 100%; + margin-bottom: 3px; + left: 50%; + margin-left: -75px; + z-index: 1; +} + +.tooltip:hover .tooltiptext { + visibility: visible; +} diff --git a/financeplanner/templates/financeplanner/index.html b/financeplanner/templates/financeplanner/index.html new file mode 100644 index 0000000..562eaea --- /dev/null +++ b/financeplanner/templates/financeplanner/index.html @@ -0,0 +1,115 @@ +{% load static %} + + + + + + FinancePlanner + + + + +

📊 FinancePlanner

+ +

+ Admin Panel + - + Logout +

+ +
+ {% if graph_data %} +
+ {% for date, stat in graph_data.items %} + {% if stat.div_percentage is None %} +
+ {{ date }}
unknown
+
+ {% else %} +
+ {{ date }}
{{ stat.amount }}€
+
+ {% endif %} + {% endfor %} +
+ {% else %} +

No graph data available.

+ {% endif %} +
+ +
+

Showing data from {{ range_start }} til {{ range_end }}:

+ +

Balances:

+ + {% if balance_list %} + + {% else %} +

No balances available.

+ {% endif %} + +

Transactions:

+ + {% if transaction_list %} + + {% else %} +

No transactions available.

+ {% endif %} + +

Calculated Transactions:

+ + {% if actual_transactions %} + + {% else %} +

No calculated transactions available.

+ {% endif %} +
+ + + + + diff --git a/financeplanner/templates/registration/logged_out.html b/financeplanner/templates/registration/logged_out.html new file mode 100644 index 0000000..12fafae --- /dev/null +++ b/financeplanner/templates/registration/logged_out.html @@ -0,0 +1,25 @@ +{% load static %} + + + + + + FloPlanner - Logout + + + + +

FloPlanner

+ +

+ Admin Panel + - + Login +

+ +
+

You have been logged out.

+
+ + + diff --git a/financeplanner/templates/registration/login.html b/financeplanner/templates/registration/login.html new file mode 100644 index 0000000..7e97535 --- /dev/null +++ b/financeplanner/templates/registration/login.html @@ -0,0 +1,40 @@ +{% load static %} + + + + + + FloPlanner - Login + + + + +

FloPlanner

+ +

+ Admin Panel +

+ +
+ +
+ + + diff --git a/financeplanner/urls.py b/financeplanner/urls.py new file mode 100644 index 0000000..83d1f6d --- /dev/null +++ b/financeplanner/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from financeplanner import views + +urlpatterns = [ + path("", views.index, name="index"), +] diff --git a/financeplanner/utils.py b/financeplanner/utils.py new file mode 100644 index 0000000..2f4b0f4 --- /dev/null +++ b/financeplanner/utils.py @@ -0,0 +1,57 @@ +from datetime import datetime +from decimal import Decimal + +from dateutil.relativedelta import relativedelta + + +def current_date(): + return datetime.now().date() + + +def format_date(d): + return d.strftime("%d.%m.%Y") + + +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)}€" + + +def _count_rates(start, end, months_delta): + delta = relativedelta(months=months_delta) + count = 0 + current = start + while current <= end: + count += 1 + current += delta + return count + + +def get_transaction_progress(transaction): + if rec_months := transaction.recurring_months: + start = transaction.booking_date + current = current_date() + if end := transaction.not_recurring_after: + total = _count_rates(start, end, rec_months) + if current >= end: + paid = total + else: + paid = _count_rates(start, current, rec_months) + else: + total = None + paid = _count_rates(start, current, rec_months) + else: + total = 1 + if transaction.booking_date < current_date(): + paid = 1 + else: + paid = 0 + return total, paid diff --git a/financeplanner/views.py b/financeplanner/views.py new file mode 100644 index 0000000..b5b9fab --- /dev/null +++ b/financeplanner/views.py @@ -0,0 +1,174 @@ +from datetime import datetime +from decimal import Decimal +from math import floor + +from dateutil.relativedelta import relativedelta +from django.contrib.auth.decorators import login_required +from django.db.models import Q +from django.shortcuts import render + +from financeplanner.utils import format_date + + +def _get_relevant_balances(user, start, end): + balances = user.balances.filter(date__range=(start, end)).order_by("date") + return list(balances) + + +def _get_relevant_transactions(user, start, end): + transactions = user.transactions.filter( + Q(booking_date__range=(start, end)) | + Q(booking_date__lte=end, recurring_months__isnull=False, not_recurring_after__gte=start) + ).order_by("booking_date") + return list(transactions) + + +def _calculate_actual_transactions(start, end, transaction_list): + result = [] + for trans in transaction_list: + current_calc_trans = {} + if trans.recurring_months: + current_date = trans.booking_date + step = 1 + limit = min(trans.not_recurring_after, end) if trans.not_recurring_after else end + while current_date < limit: + if current_date >= start: + result.append(( + current_date, + f"{trans.subject} #{step}", + trans.amount, + )) + current_date += relativedelta(months=trans.recurring_months) + step += 1 + else: + current_calc_trans[trans.booking_date] = trans.amount + result.append(( + trans.booking_date, + trans.subject, + trans.amount, + )) + return result + + +def _calculate_daily_stats(start, end, balance_list, actual_transactions): + stats = {} + + current_date = start + current_amount = None + while current_date < end: + current_stats = { + "balance": None, + "transactions": [], + "amount": None, + } + + relevant_balances = [bal for bal in balance_list if bal.date == current_date] + if relevant_balances: + assert len(relevant_balances) == 1, f"balances should be unique for user and date, " \ + f"but here are {relevant_balances}" + amount = relevant_balances[0].amount + current_stats["balance"] = relevant_balances[0].amount + current_amount = amount + + relevant_transactions = [tra for tra in actual_transactions if tra[0] == current_date] + for tra in relevant_transactions: + subject, amount = tra[1], tra[2] + current_stats["transactions"].append({ + "subject": subject, + "amount": amount, + }) + if current_amount is not None: + current_amount -= amount + + current_stats["amount"] = current_amount + stats[current_date] = current_stats + current_date += relativedelta(days=1) + + return stats + + +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) + + +def _calculate_scale(stats): + max_amount = max(stat["amount"] for stat in stats.values() if stat["amount"] is not None) + return _floor_to_first_two_places(max_amount * Decimal("1.3")) + + +def _build_graph_data(start, end, balance_list, actual_transactions): + """ + result has the format: + { + : { + "balance": , + "transactions": [ + { + "subject": , + "amount": , + }, + ... + ], + "amount": , + "div_percentage": , + "div_opacity_class": , + "div_color_class": , + }, + ... + } + """ + today = datetime.now().date() + stats = _calculate_daily_stats(start, end, balance_list, actual_transactions) + amount_limit = _calculate_scale(stats) + for date in stats.keys(): + amount = stats[date]["amount"] + if amount is None: + stats[date]["div_percentage"] = None + else: + percentage = int((amount / amount_limit) * 100) + stats[date]["div_percentage"] = percentage + if percentage >= 60: + stats[date]["div_color_class"] = "green" + elif percentage >= 45: + stats[date]["div_color_class"] = "olive" + elif percentage >= 30: + stats[date]["div_color_class"] = "yellow" + elif percentage >= 15: + stats[date]["div_color_class"] = "orange" + else: + stats[date]["div_color_class"] = "red" + + if date == today: + stats[date]["div_opacity_class"] = "highlighted" + elif stats[date]["balance"] or stats[date]["transactions"]: + stats[date]["div_opacity_class"] = "strong" + else: + stats[date]["div_opacity_class"] = "" + + return stats + + +@login_required +def index(request): + user = request.user + today = datetime.now().date() + range_start = today - relativedelta(days=30) + range_end = today + relativedelta(days=50) + balance_list = _get_relevant_balances(user, range_start, range_end) + transaction_list = _get_relevant_transactions(user, range_start, range_end) + actual_transactions = _calculate_actual_transactions(range_start, range_end, transaction_list) + graph_data = _build_graph_data(range_start, range_end, balance_list, actual_transactions) + context = { + "range_start": format_date(range_start), + "range_end": format_date(range_end), + "today": today, + "balance_list": balance_list, + "transaction_list": transaction_list, + "actual_transactions": actual_transactions, + "graph_data": graph_data, + } + return render(request, "financeplanner/index.html", context) diff --git a/floplanner/__init__.py b/floplanner/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/floplanner/settings.py b/floplanner/settings.py new file mode 100644 index 0000000..f1035b5 --- /dev/null +++ b/floplanner/settings.py @@ -0,0 +1,106 @@ +import os + +from django.urls import reverse_lazy + + +def _get_env_production_mode(): + env_var = os.environ.get("DJANGO_PRODUCTION_MODE", "false") + return env_var.lower() == "true" + + +def _get_env_secret_key(): + env_var = os.environ.get("DJANGO_SECRET_KEY") + assert env_var is not None, "DJANGO_SECRET_KEY environment variable must be set when using production mode" + assert 30 <= len(env_var) <= 80, "DJANGO_SECRET_KEY should be 30 to 80 characters long" + return env_var + + +def _get_env_allowed_hosts(): + env_var = os.environ.get("DJANGO_ALLOWED_HOSTS") + assert env_var is not None, "DJANGO_ALLOWED_HOSTS environment variable must be set when using production mode" + return [host.strip() for host in env_var.split(",")] + + +def _get_env_static_root(): + env_var = os.environ.get("DJANGO_STATIC_ROOT") + assert env_var is not None, "DJANGO_STATIC_ROOT environment variable must be set when using production mode" + return env_var + + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +PRODUCTION = _get_env_production_mode() + +if PRODUCTION: + SECRET_KEY = _get_env_secret_key() + DEBUG = False + ALLOWED_HOSTS = _get_env_allowed_hosts() + STATIC_ROOT = _get_env_static_root() +else: + SECRET_KEY = "QetNMYdSKo3kefmltcEeAu52HbyZBxXsROiYesIEwYwnX0rCuv" + DEBUG = True + ALLOWED_HOSTS = ["*"] + +INSTALLED_APPS = [ + "financeplanner", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "floplanner.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "floplanner.wsgi.application" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql_psycopg2", + "HOST": os.environ.get("DB_HOST"), + "PORT": os.environ.get("DB_PORT"), + "NAME": os.environ.get("DB_NAME"), + "USER": os.environ.get("DB_USER"), + "PASSWORD": os.environ.get("DB_PASSWORD"), + } +} + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, +] + +LOGIN_URL = reverse_lazy("login") +LOGIN_REDIRECT_URL = reverse_lazy("finance:index") + +STATIC_URL = "/static/" diff --git a/floplanner/urls.py b/floplanner/urls.py new file mode 100644 index 0000000..3cbebd9 --- /dev/null +++ b/floplanner/urls.py @@ -0,0 +1,13 @@ +from django.contrib.auth.views import LoginView, LogoutView +from django.urls import path, include +from django.views.generic import RedirectView + +from financeplanner.admin import admin_site + +urlpatterns = [ + path("admin/", admin_site.urls), + path("login/", LoginView.as_view(), name="login"), + path("logout/", LogoutView.as_view(), name="logout"), + path("finance/", include(("financeplanner.urls", "financeplanner"), namespace="finance")), + path("", RedirectView.as_view(pattern_name="finance:index", permanent=False), name="index"), +] diff --git a/floplanner/wsgi.py b/floplanner/wsgi.py new file mode 100644 index 0000000..407ff51 --- /dev/null +++ b/floplanner/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for floplanner project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'floplanner.settings') + +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..5c6458f --- /dev/null +++ b/manage.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'floplanner.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0339690 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +django==3.0.3 +psycopg2-binary==2.8.4 +uwsgi==2.0.18 +python-dateutil==2.8.1 diff --git a/uwsgi.ini b/uwsgi.ini new file mode 100644 index 0000000..6ece327 --- /dev/null +++ b/uwsgi.ini @@ -0,0 +1,6 @@ +[uwsgi] +chdir = /code +module = floplanner.wsgi:application +http = 0.0.0.0:8000 +workers = 2 +master = true diff --git a/wait-for-it.sh b/wait-for-it.sh new file mode 100755 index 0000000..071c2be --- /dev/null +++ b/wait-for-it.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# Use this script to test if a given TCP host/port are available + +WAITFORIT_cmdname=${0##*/} + +echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } + +usage() +{ + cat << USAGE >&2 +Usage: + $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] + -h HOST | --host=HOST Host or IP under test + -p PORT | --port=PORT TCP port under test + Alternatively, you specify the host and port as host:port + -s | --strict Only execute subcommand if the test succeeds + -q | --quiet Don't output any status messages + -t TIMEOUT | --timeout=TIMEOUT + Timeout in seconds, zero for no timeout + -- COMMAND ARGS Execute command with args after the test finishes +USAGE + exit 1 +} + +wait_for() +{ + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + else + echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" + fi + WAITFORIT_start_ts=$(date +%s) + while : + do + if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then + nc -z $WAITFORIT_HOST $WAITFORIT_PORT + WAITFORIT_result=$? + else + (echo > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 + WAITFORIT_result=$? + fi + if [[ $WAITFORIT_result -eq 0 ]]; then + WAITFORIT_end_ts=$(date +%s) + echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" + break + fi + sleep 1 + done + return $WAITFORIT_result +} + +wait_for_wrapper() +{ + # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 + if [[ $WAITFORIT_QUIET -eq 1 ]]; then + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + else + timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & + fi + WAITFORIT_PID=$! + trap "kill -INT -$WAITFORIT_PID" INT + wait $WAITFORIT_PID + WAITFORIT_RESULT=$? + if [[ $WAITFORIT_RESULT -ne 0 ]]; then + echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" + fi + return $WAITFORIT_RESULT +} + +# process arguments +while [[ $# -gt 0 ]] +do + case "$1" in + *:* ) + WAITFORIT_hostport=(${1//:/ }) + WAITFORIT_HOST=${WAITFORIT_hostport[0]} + WAITFORIT_PORT=${WAITFORIT_hostport[1]} + shift 1 + ;; + --child) + WAITFORIT_CHILD=1 + shift 1 + ;; + -q | --quiet) + WAITFORIT_QUIET=1 + shift 1 + ;; + -s | --strict) + WAITFORIT_STRICT=1 + shift 1 + ;; + -h) + WAITFORIT_HOST="$2" + if [[ $WAITFORIT_HOST == "" ]]; then break; fi + shift 2 + ;; + --host=*) + WAITFORIT_HOST="${1#*=}" + shift 1 + ;; + -p) + WAITFORIT_PORT="$2" + if [[ $WAITFORIT_PORT == "" ]]; then break; fi + shift 2 + ;; + --port=*) + WAITFORIT_PORT="${1#*=}" + shift 1 + ;; + -t) + WAITFORIT_TIMEOUT="$2" + if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi + shift 2 + ;; + --timeout=*) + WAITFORIT_TIMEOUT="${1#*=}" + shift 1 + ;; + --) + shift + WAITFORIT_CLI=("$@") + break + ;; + --help) + usage + ;; + *) + echoerr "Unknown argument: $1" + usage + ;; + esac +done + +if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then + echoerr "Error: you need to provide a host and port to test." + usage +fi + +WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} +WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} +WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} +WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} + +# check to see if timeout is from busybox? +WAITFORIT_TIMEOUT_PATH=$(type -p timeout) +WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) +if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then + WAITFORIT_ISBUSY=1 + WAITFORIT_BUSYTIMEFLAG="-t" + +else + WAITFORIT_ISBUSY=0 + WAITFORIT_BUSYTIMEFLAG="" +fi + +if [[ $WAITFORIT_CHILD -gt 0 ]]; then + wait_for + WAITFORIT_RESULT=$? + exit $WAITFORIT_RESULT +else + if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then + wait_for_wrapper + WAITFORIT_RESULT=$? + else + wait_for + WAITFORIT_RESULT=$? + fi +fi + +if [[ $WAITFORIT_CLI != "" ]]; then + if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then + echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" + exit $WAITFORIT_RESULT + fi + exec "${WAITFORIT_CLI[@]}" +else + exit $WAITFORIT_RESULT +fi