diff --git a/README.md b/README.md index e4e550d..a3f6f80 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,6 @@ - Übersicht auf Startseite - Monatsübersicht dieser/letzter Monat - Anzahl ungetaggte Transaktionen -- Importer über Webseite -- setup.py - Doku diff --git a/schmeckels/autosort.py b/schmeckels/autosort.py index e2656dd..f75c471 100644 --- a/schmeckels/autosort.py +++ b/schmeckels/autosort.py @@ -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 diff --git a/schmeckels/banks.py b/schmeckels/banks.py index 63a1043..c5fd4bd 100644 --- a/schmeckels/banks.py +++ b/schmeckels/banks.py @@ -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) diff --git a/schmeckels/cli.py b/schmeckels/cli.py index 163a073..f65b86e 100644 --- a/schmeckels/cli.py +++ b/schmeckels/cli.py @@ -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 diff --git a/schmeckels/helper.py b/schmeckels/helper.py index 185d4be..641cbe2 100644 --- a/schmeckels/helper.py +++ b/schmeckels/helper.py @@ -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 diff --git a/schmeckels/importer.py b/schmeckels/importer.py index d4133c0..6a5dd7d 100644 --- a/schmeckels/importer.py +++ b/schmeckels/importer.py @@ -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")) diff --git a/schmeckels/models.py b/schmeckels/models.py index 4535416..9701722 100644 --- a/schmeckels/models.py +++ b/schmeckels/models.py @@ -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"" + def toggle_reporting(self): + self.reporting = not self.reporting + def color(self): hash = md5(self.name.encode()).hexdigest()[-6:] return f"#{hash}" diff --git a/schmeckels/serve.py b/schmeckels/serve.py index 765930a..4ca0043 100644 --- a/schmeckels/serve.py +++ b/schmeckels/serve.py @@ -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//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) diff --git a/schmeckels/templates/base.html b/schmeckels/templates/base.html index b114fd6..8bc721c 100644 --- a/schmeckels/templates/base.html +++ b/schmeckels/templates/base.html @@ -7,7 +7,6 @@ Schmeckels - @@ -26,13 +25,27 @@
  • Tags
  • +
  • + Upload +
  • + {% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} {% block main %} {% endblock %}
    + + {% block script %} {% endblock %} diff --git a/schmeckels/templates/index.html b/schmeckels/templates/index.html index fee384d..898e55b 100644 --- a/schmeckels/templates/index.html +++ b/schmeckels/templates/index.html @@ -1,7 +1,84 @@ {% extends "base.html" %} {% block main %} -

    Start

    +

    Overview for {{ date.strftime("%B %Y") }}

    +

    Incoming

    +
    +
    + {% if tag_sum_in %} + + + + + + {% for tag,sum in tag_sum_in.items() %} + + + + + {% endfor %} +
    TagSum
    {{ tag.name }}{{ sum }} €
    + {% else %} + No tagged transactions for this month. + {% endif %} +
    +
    + +
    +
    + + +

    Outgoing

    +
    +
    + {% if tag_sum_out %} + + + + + + {% for tag,sum in tag_sum_out.items() %} + + + + + {% endfor %} +
    TagSum
    {{ tag.name }}{{ sum }} €
    + {% else %} + No tagged transactions for this month. + {% endif %} +
    +
    + +
    +
    {% endblock %} + +{% block script %} + +{% endblock %} diff --git a/schmeckels/templates/tags.html b/schmeckels/templates/tags.html index b54ee14..869e72f 100644 --- a/schmeckels/templates/tags.html +++ b/schmeckels/templates/tags.html @@ -7,13 +7,20 @@ diff --git a/schmeckels/templates/upload.html b/schmeckels/templates/upload.html new file mode 100644 index 0000000..990f745 --- /dev/null +++ b/schmeckels/templates/upload.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% block main %} +
    +

    + + +

    + +

    + + +

    + +

    + +

    +
    + +{% endblock %}