Lot of fixing. Graphs on index page. Web importer

This commit is contained in:
Felix Breidenstein 2020-11-05 00:11:23 +01:00
parent 7a236a10e5
commit e88acbeb13
12 changed files with 263 additions and 47 deletions

View file

@ -8,8 +8,6 @@
- Übersicht auf Startseite
- Monatsübersicht dieser/letzter Monat
- Anzahl ungetaggte Transaktionen
- Importer über Webseite
- setup.py
- Doku

View file

@ -46,6 +46,7 @@ def command(profile, dry_run, verbose):
def apply_rules(t, rules):
tags = []
for r in rules:
iban = r.get("iban")
name_regex = r.get("name")
@ -63,4 +64,6 @@ def apply_rules(t, rules):
if not re.search(desc_regex, t.description):
continue
return r["tags"].split(",")
tags.extend(r["tags"].split(","))
return tags

View file

@ -1,9 +1,15 @@
#! /usr/bin/env python3
import mt940
import csv
from schmeckels.models import Transaction
from schmeckels import models
from datetime import datetime
SUPPORTED_BANKS = (
("dkb", "DKB"),
("sparkasse-mt940", "Sparkasse MT940"),
("bunq-csv", "Bunq CSV"),
)
class Bank(object):
"""
@ -38,7 +44,7 @@ class Sparkasse_MT940(Bank):
"""
def get_transactions(self):
transactions = mt940.parse(click.format_filename(filename))
transactions = mt940.parse(self.filename)
for t in transactions:
data = t.data
@ -47,7 +53,7 @@ class Sparkasse_MT940(Bank):
iban = data["applicant_iban"]
name = data["applicant_name"]
description = data["purpose"]
yield Transaction(date=date, name=name, iban=iban, amount=amount, description=description)
yield models.Transaction(date=date, name=name, iban=iban, amount=amount, description=description)
class Bunq(Bank):
@ -66,7 +72,7 @@ class Bunq(Bank):
name = line[5]
description = line[6]
yield Transaction(date=date, name=name, iban=iban, amount=amount, description=description)
yield models.Transaction(date=date, name=name, iban=iban, amount=amount, description=description)
class DKB(Bank):
@ -75,7 +81,7 @@ class DKB(Bank):
"""
def get_transactions(self):
with open(filename, encoding="ISO-8859-1") as fh:
with open(self.filename, encoding="ISO-8859-1") as fh:
csv_reader = csv.reader(fh, delimiter=";")
next(csv_reader, None)
for line in csv_reader:
@ -84,4 +90,4 @@ class DKB(Bank):
iban = line[5]
name = line[3]
description = line[4]
yield Transaction(date=date, name=name, iban=iban, amount=amount, description=description)
yield models.Transaction(date=date, name=name, iban=iban, amount=amount, description=description)

View file

@ -5,7 +5,7 @@ import sys
from pathlib import Path
from schmeckels import importer, serve, autosort, validate, info, sort, models #,report
from schmeckels import importer, serve, autosort, validate, info, sort, models # ,report
from schmeckels.helper import build_database_filename, create_dirs, build_rules_filename
from sqlalchemy import create_engine
@ -15,6 +15,7 @@ from sqlalchemy.ext.declarative import declarative_base
__version__ = "0.0.1"
@click.group()
def cli():
pass

View file

@ -8,6 +8,7 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
import schmeckels.models
import locale
from schmeckels import banks, models
try:
from yaml import CLoader as Loader
@ -45,7 +46,7 @@ def check_single_profile():
if len(files) == 1:
return files[0].split(".")[0]
else:
print("--profile is required when you have more than one database.")
print("--profile is required when you have more than one profile.")
sys.exit(1)
@ -88,7 +89,35 @@ def get_rules(profile_name):
def create_tag(name, profile, session):
t = session.query(models.Tag).filter(models.Tag.name == name).first()
if not t:
print(name)
t = models.Tag(name=name)
session.add(t)
session.commit()
return t
def import_transactions(session, filetype, filename):
if filetype not in [b[0] for b in banks.SUPPORTED_BANKS]:
raise ValueError("Unsupported filetype")
new = []
if filetype == "sparkasse-mt940":
bank = banks.Sparkasse_MT940(filename)
elif filetype == "bunq-csv":
bank = banks.Bunq(filename)
elif filetype == "dkb":
bank = banks.DKB(filename)
for t in bank.get_transactions():
if (
not session.query(models.Transaction)
.filter_by(iban=t.iban)
.filter_by(amount=t.amount)
.filter_by(date=t.date)
.filter_by(description=t.description)
.first()
):
new.append(t)
return new

View file

@ -3,38 +3,25 @@ import click
from sqlalchemy import create_engine, desc
from sqlalchemy.orm import sessionmaker
from schmeckels.models import Transaction
from schmeckels.helper import get_session
from schmeckels.helper import get_session, import_transactions
import sys
from schmeckels import banks
import colorful as cf
@click.command(name="import")
@click.option("--filetype", "-t", type=click.Choice(["dkb", "sparkasse-mt940", "bunq-csv"], case_sensitive=False))
@click.option("--filetype", "-t", type=click.Choice([b[0] for b in banks.SUPPORTED_BANKS], case_sensitive=False))
@click.option("--profile", "-p")
@click.option("--force", "-f", default=False, is_flag=True)
@click.option("--dry-run", default=False, is_flag=True)
@click.argument("filename", type=click.Path(exists=True))
def command(filename, profile, force, filetype):
def command(filetype, profile, dry_run, filename):
session = get_session(profile)
new = import_transactions(session, filetype, filename)
latest = session.query(Transaction).order_by(desc(Transaction.date)).first()
new = []
if filetype == "sparkasse-mt940":
bank = banks.Sparkasse_MT940(filename)
elif filetype == "bunq-csv":
bank = banks.Bunq(filename)
elif filetype == "dkb":
bank = banks.DKB(filename)
for t in bank.get_transactions():
if not force and latest and t.date < latest.date:
print("Found a transaction older than then newest transction in the DB.")
print("If you are sure that you want to import this file, use --force")
sys.exit(1)
new.append(t)
print(".", end="", flush=True)
session.bulk_save_objects(new)
session.commit()
print(f"Imported {len(new)} transactions")
if dry_run:
print(cf.yellow(f"Would have imported {len(new)} new transactions"))
else:
if len(new) > 0:
session.bulk_save_objects(new)
session.commit()
print(cf.green(f"Imported {len(new)} new transactions"))

View file

@ -1,8 +1,8 @@
#! /usr/bin/env python3
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Table
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Table, Boolean
from sqlalchemy.orm import relationship, backref
from schmeckels.helper import format_amount
from schmeckels import helper
from hashlib import md5
Base = declarative_base()
@ -32,7 +32,7 @@ class Transaction(Base):
return self.amount > 0
def pretty_amount(self):
return format_amount(self.amount)
return helper.format_amount(self.amount)
def get_date(self, format):
if format == "iso":
@ -47,12 +47,16 @@ class Tag(Base):
__tablename__ = "Tag"
id = Column(Integer, primary_key=True)
name = Column(String, unique=True)
reporting = Column(Boolean, default=True)
description = Column(String)
transactions = relationship("Transaction", secondary=association_table, back_populates="tags")
def __repr__(self):
return f"<Tag {self.name}>"
def toggle_reporting(self):
self.reporting = not self.reporting
def color(self):
hash = md5(self.name.encode()).hexdigest()[-6:]
return f"#{hash}"

View file

@ -1,10 +1,13 @@
#! /usr/bin/env python3
from flask import Flask, render_template, request, redirect
from flask import Flask, render_template, request, redirect, flash
from schmeckels.helper import get_session, format_amount
from datetime import datetime
from sqlalchemy import func
from datetime import datetime, timedelta, date
from sqlalchemy import func, and_
import click
import calendar
from schmeckels.models import Transaction, Tag
from schmeckels.helper import import_transactions
from schmeckels import banks
session = None
app = Flask(__name__)
@ -12,7 +15,37 @@ app = Flask(__name__)
@app.route("/")
def index():
return render_template("index.html")
tag_sum_in = {}
tag_sum_out = {}
# Get a list of all tags
tags = session.query(Tag).all()
# Get start and end of last month
now = datetime.now()
last_month = now - timedelta(days=now.day)
num_days = calendar.monthrange(last_month.year, 2)[1]
start_date = date(last_month.year, 2, 1)
end_date = date(last_month.year, 2, num_days)
for tag in tags:
tag_sum = (
session.query(func.sum(Transaction.amount))
.filter(and_(Transaction.date >= start_date, Transaction.date <= end_date))
.filter(Transaction.tags.any(id=tag.id))
.first()[0]
)
if tag_sum:
if tag_sum >= 0:
tag_sum_in[tag] = tag_sum
else:
tag_sum_out[tag] = tag_sum
tag_sum_in = {
k: format_amount(v) for k, v in sorted(tag_sum_in.items(), key=lambda item: float(item[1]), reverse=True)
}
tag_sum_out = {k: format_amount(v) for k, v in sorted(tag_sum_out.items(), key=lambda item: float(item[1]))}
return render_template("index.html", tag_sum_in=tag_sum_in, tag_sum_out=tag_sum_out, date=start_date)
@app.route("/tags")
@ -39,6 +72,17 @@ def delete_tag(name):
return redirect("/tags")
@app.route("/tag/<name>/toggle")
def toggle_tag(name):
x = session.query(Tag).filter(Tag.name == name).first()
if x:
x.toggle_reporting()
session.add(x)
session.commit()
return redirect("/tags")
@app.route("/transactions")
def transactions():
transactions = session.query(Transaction)
@ -67,9 +111,32 @@ def buksort():
return redirect("/transactions")
@app.route("/upload", methods=["GET", "POST"])
def upload():
global session
if request.method == "POST":
file = request.files.get("file")
if not file or file.filename == "":
flash("No file choosen")
return redirect(request.url)
path = f"/tmp/{file.filename}"
file.save(path)
new = import_transactions(session, request.form["bank"], path)
if len(new) > 0:
session.bulk_save_objects(new)
session.commit()
flash(f"Imported {len(new)} new transactions")
return render_template("upload.html", banks=banks.SUPPORTED_BANKS, transactions=new)
return render_template("upload.html", banks=banks.SUPPORTED_BANKS)
@click.command(name="serve")
@click.option("--profile", "-p")
def command(profile):
global session
session = get_session(profile)
app.secret_key = "WeDon'tHaveSessionsSoThisMustNotBeVerySecure!"
app.run(host="0.0.0.0", port=8080)

View file

@ -7,7 +7,6 @@
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Schmeckels</title>
<link href="https://unpkg.com/tailwindcss/dist/tailwind.min.css" rel="stylesheet">
<link href="https://unpkg.com/@tailwindcss/custom-forms/dist/custom-forms.min.css" rel="stylesheet">
<link href="/static/style.css" rel="stylesheet">
</head>
@ -26,13 +25,27 @@
<li class="mx-4">
<a class="text-blue-500 hover:text-blue-800" href="/tags">Tags</a>
</li>
<li class="mx-4">
<a class="text-blue-500 hover:text-blue-800" href="/upload">Upload</a>
</li>
</ul>
</div>
<div class="pt-2 container mx-auto">
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<div class="bg-blue-100 border-t border-b border-blue-500 text-blue-700 px-2 py-2 mb-5" role="alert">
<p class="font-bold">{{ message }}</p>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block main %} {% endblock %}
</div>
<script src='https://cdnjs.cloudflare.com/ajax/libs/Chart.js/1.0.2/Chart.min.js'></script>
{% block script %} {% endblock %}
</body>
</html>

View file

@ -1,7 +1,84 @@
{% extends "base.html" %}
{% block main %}
<h1 class="text-2xl font-bold text-indigo-500">Start</h1>
<h1 class="text-2xl font-bold text-indigo-500">Overview for {{ date.strftime("%B %Y") }} </h1>
<h2 class="text-xl font-bold text-green-500">Incoming</h2>
<div style="display:flex; flex-direction: row;">
<div>
{% if tag_sum_in %}
<table>
<tr>
<th>Tag</th>
<th>Sum</th>
</tr>
{% for tag,sum in tag_sum_in.items() %}
<tr>
<td>{{ tag.name }}</td>
<td class="text-right">{{ sum }} €</td>
</tr>
{% endfor %}
</table>
{% else %}
No tagged transactions for this month.
{% endif %}
</div>
<div>
<canvas id="chart_in" width="600" height="400"></canvas>
</div>
</div>
<h2 class="text-xl font-bold text-red-500">Outgoing</h2>
<div style="display:flex; flex-direction: row;">
<div>
{% if tag_sum_out %}
<table>
<tr>
<th>Tag</th>
<th>Sum</th>
</tr>
{% for tag,sum in tag_sum_out.items() %}
<tr>
<td>{{ tag.name }}</td>
<td class="text-right">{{ sum }} €</td>
</tr>
{% endfor %}
</table>
{% else %}
No tagged transactions for this month.
{% endif %}
</div>
<div>
<canvas id="chart_out" width="600" height="400"></canvas>
</div>
</div>
{% endblock %}
{% block script %}
<script>
var pieData_in = [
{% for tag, sum in tag_sum_in.items() %}
{
value: "{{sum}}".replace(",", ""),
label: "{{ tag.name }}",
color : "{{ tag.color() }}"
},
{% endfor %}
];
new Chart(document.getElementById("chart_in").getContext("2d")).Pie(pieData_in);
var pieData_out = [
{% for tag, sum in tag_sum_out.items() %}
{
value: "{{sum}}".replace(",", ""),
label: "{{ tag.name }}",
color : "{{ tag.color() }}"
},
{% endfor %}
];
new Chart(document.getElementById("chart_out").getContext("2d")).Pie(pieData_out);
</script>
{% endblock %}

View file

@ -7,13 +7,20 @@
<ul>
{% for tag in tags %}
<li class="p-2">
<span style="background-color: {{ tag.color() }};" class="text-white rounded p-1" >{{ tag.name }}</span>
<span style="background-color: {{ tag.color() }};" class="text-white rounded p-1">{{ tag.name }}</span>
<a class="float-right" href="/tag/{{ tag.name }}">
<button class="bg-blue-500 hover:bg-blue-700 text-white font-bold mx-2 y-1 px-2 rounded"> Transactions </button>
</a>
<a class="float-right" href="/tag/{{ tag.name }}/delete">
<button class="bg-red-500 hover:bg-red-700 text-white font-bold y-1 px-2 rounded"> Delete </button>
</a>
<a class="float-right" href="/tag/{{ tag.name }}/toggle">
{% if tag.reporting %}
<button class="bg-red-500 hover:bg-red-700 text-white font-bold y-1 px-2 rounded"> Remove from report </button>
{% else %}
<button class="bg-green-500 hover:bg-green-700 text-white font-bold y-1 px-2 rounded"> Add to reporting </button>
{% endif %}
</a>
</li>
{% endfor %}
</ul>

View file

@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block main %}
<form method="POST" enctype="multipart/form-data" class="max-w-xs">
<p class="bg-blue-100 p-2 mb-4">
<label class="block" for="bank">Choose your filetype</label>
<select id="bank" name="bank">
{% for bank in banks %}
<option value="{{bank[0]}}"> {{ bank[1] }}</option>
{% endfor %}
</select>
</p>
<p class="bg-blue-100 p-2 mb-4">
<label class="block" for="file">Choose your file</label>
<input name="file" type="file"></input>
</p>
<p class="mt-4 text-center">
<button class="bg-green-400 p-1" type="submit">Upload</button>
</p>
</form>
{% endblock %}