From 48ba732038b23f24b32e2453639eccc15a1e77ac Mon Sep 17 00:00:00 2001 From: Felix Breidenstein Date: Sun, 29 Mar 2020 23:58:55 +0200 Subject: [PATCH] Implemented autosort command --- Pipfile | 3 +++ Pipfile.lock | 54 +++++++++++++++++++++++++++++++--------- autosort.py | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++ cli | 2 ++ helper.py | 60 ++++++++++++++++++++++++++++++++------------ 5 files changed, 162 insertions(+), 27 deletions(-) create mode 100644 autosort.py diff --git a/Pipfile b/Pipfile index dbaf9a8..97026f3 100644 --- a/Pipfile +++ b/Pipfile @@ -14,6 +14,9 @@ bottle = "*" mt-940 = "*" click = "*" xdg = "*" +pyyaml = "*" +colored = "*" +colorful = "*" [requires] python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock index 0c7fde8..cfa770f 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "45bd6b24d71ce0251c64ad6cad358882053f9cacc52bb418fce22889da6ff702" + "sha256": "816c7c2536e6f09c3088905953eb3d20b067dad888a1626eedb2ddef41c6b045" }, "pipfile-spec": 6, "requires": { @@ -32,21 +32,53 @@ "index": "pypi", "version": "==7.1.1" }, - "mt-940": { + "colored": { "hashes": [ - "sha256:95e55584908c7d51b8a08cfb55d72297f06385e40c9baf9258cdaaf26811aafa", - "sha256:fad1a00df51ede762d7d5e4d019de9ad86357d7556862b259ce7fba400ac2d6e" + "sha256:056fac09d9e39b34296e7618897ed1b8c274f98423770c2980d829fd670955ed" ], "index": "pypi", - "version": "==4.20.0" + "version": "==1.4.2" + }, + "colorful": { + "hashes": [ + "sha256:86848ad4e2eda60cd2519d8698945d22f6f6551e23e95f3f14dfbb60997807ea", + "sha256:8d264b52a39aae4c0ba3e2a46afbaec81b0559a99be0d2cfe2aba4cf94531348" + ], + "index": "pypi", + "version": "==0.5.4" + }, + "mt-940": { + "hashes": [ + "sha256:7cbd88fd7252d5a2694593633b31f819eb302423058fecb9f9959e74c01c2b86", + "sha256:81fbfe647ebf2fc9700d6f61e2ba4c9d6c5fab56ccde2b54144481497c64b65c" + ], + "index": "pypi", + "version": "==4.21.0" }, "prompt-toolkit": { "hashes": [ - "sha256:859e1b205b6cf6a51fa57fa34202e45365cf58f8338f0ee9f4e84a4165b37d5b", - "sha256:ebe6b1b08c888b84c50d7f93dee21a09af39860144ff6130aadbd61ae8d29783" + "sha256:563d1a4140b63ff9dd587bda9557cffb2fe73650205ab6f4383092fb882e7dc8", + "sha256:df7e9e63aea609b1da3a65641ceaf5bc7d05e0a04de5bd45d05dbeffbabf9e04" ], "index": "pypi", - "version": "==3.0.4" + "version": "==3.0.5" + }, + "pyyaml": { + "hashes": [ + "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", + "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", + "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", + "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", + "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", + "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", + "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", + "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", + "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", + "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", + "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" + ], + "index": "pypi", + "version": "==5.3.1" }, "sqlalchemy": { "hashes": [ @@ -57,10 +89,10 @@ }, "wcwidth": { "hashes": [ - "sha256:8fd29383f539be45b20bd4df0dc29c20ba48654a41e661925e612311e9f3c603", - "sha256:f28b3e8a6483e5d49e7f8949ac1a78314e740333ae305b4ba5defd3e74fb37a8" + "sha256:cafe2186b3c009a04067022ce1dcd79cb38d8d65ee4f4791b8888d6599d1bbe1", + "sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1" ], - "version": "==0.1.8" + "version": "==0.1.9" }, "xdg": { "hashes": [ diff --git a/autosort.py b/autosort.py new file mode 100644 index 0000000..f516720 --- /dev/null +++ b/autosort.py @@ -0,0 +1,70 @@ +#! /usr/bin/env python3 +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.ext.declarative import declarative_base +from models import Category, Transaction +from helper import get_session, add_category, get_rules +import sys +import click +import colorful as cf + +from prompt_toolkit.completion import FuzzyWordCompleter +from prompt_toolkit.shortcuts import prompt + +def print_verbose(verbose, string): + if verbose: + print(string) + + +@click.command(name="autosort") +@click.option("--profile", "-p") +@click.option("--dry-run", default=False, is_flag=True) +@click.option("--verbose", "-v", default=False, is_flag=True) +def command(profile, dry_run, verbose): + session = get_session(profile) + rules = get_rules(profile) + + unsorted = session.query(Transaction).filter(Transaction.category_id == None).all() + print("Found {} unsorted transcations".format(len(unsorted))) + matched = 0 + + for t in unsorted: + cat_name = apply_rules(t, rules) + if cat_name: + short_name = (t.name[:25] if len(t.name)>25 else t.name) + short_desc = (t.description[:45] if len(t.description)>50 else t.description) + print_verbose(verbose,"{:<25} | {:<50}| {:>10}€".format(short_name, short_desc, t.amount/100)) + print_verbose(verbose, cf.green(f"Matched '{cat_name}'")) + cat_id = session.query(Category).filter(Category.name == cat_name).first() + if not cat_id: + cat_id = add_category(cat_name, profile, session) + matched +=1 + t.category_id = cat_id + session.add(t) + print_verbose(verbose, "-"*90) + + print(f"Automatically matched {matched} transactions") + if not dry_run: + session.commit() + + +def apply_rules(t, rules): + for r in rules: + iban = r.get("iban") + name_regex = r.get("name_regex") + desc_regex = r.get("desc_regex") + + if iban: + if t.iban != iban: + continue + + if name_regex: + if not name_regex.search(t.name): + continue + + if desc_regex: + if not desc_regex.search(t.description): + continue + + return r["category"] + diff --git a/cli b/cli index 467fd33..7eaf0cf 100755 --- a/cli +++ b/cli @@ -6,6 +6,7 @@ import sys import sort import importer import serve +import autosort from helper import build_database_filename, create_dirs from sqlalchemy import create_engine @@ -38,4 +39,5 @@ if __name__ == '__main__': cli.add_command(sort.command) cli.add_command(importer.command) cli.add_command(serve.command) + cli.add_command(autosort.command) cli() diff --git a/helper.py b/helper.py index bb076e9..bf12a43 100644 --- a/helper.py +++ b/helper.py @@ -1,11 +1,19 @@ #! /usr/bin/env python3 import os import sys +import yaml +import re from xdg import XDG_CONFIG_HOME, XDG_DATA_HOME from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from models import Category, Transaction +try: + from yaml import CLoader as Loader, CDumper as Dumper +except ImportError: + from yaml import Loader, Dumper + + CONFIG_DIR = os.path.join(XDG_CONFIG_HOME, "schmeckels") DATA_DIR = os.path.join(XDG_DATA_HOME, "schmeckels") @@ -19,20 +27,23 @@ def create_dirs(): def build_database_filename(profile_name): - return f"{DATA_DIR}/{profile_name}.db" + return f"{DATA_DIR}/databases/{profile_name}.db" +def build_rules_filename(profile_name): + return f"{DATA_DIR}/rules/{profile_name}.yaml" + +def check_single_profile(): + files = os.listdir(f"{DATA_DIR}/databases") + if len(files) == 1: + return files.split(".")[0] + else: + print("--profile is required when you have more than one database.") + sys.exit(1) def get_session(profile_name): if not profile_name: - count = len(os.listdir(DATA_DIR)) - if count == 1: - filename = f"{DATA_DIR}/{os.listdir(DATA_DIR)[0]}" - else: - print("--profile is required when you have more than one database.") - sys.exit(1) - else: - filename = build_database_filename(profile_name) - + profile_name = check_single_profile() + filename = build_database_filename(profile_name) if os.path.exists(filename) and os.path.isfile(filename): engine = create_engine(f"sqlite:///{filename}") Session = sessionmaker(bind=engine) @@ -42,8 +53,25 @@ def get_session(profile_name): sys.exit(1) -def create_category(name, parent, profile): - session = get_session(profile) +def get_rules(profile_name): + if not profile_name: + profile_name = check_single_profile() + filename = build_rules_filename(profile_name) + if os.path.exists(filename) and os.path.isfile(filename): + with open(filename) as fh: + data = yaml.load(fh, Loader=Loader) + for rule in data: + if rule.get("name"): + rule["name_regex"] = re.compile(rule["name"]) + if rule.get("description"): + rule["desc_regex"] = re.compile(rule["description"]) + return data + else: + print(f"No rules for profile '{profile_name}'. Did you run 'init'?") + sys.exit(1) + + +def create_category(name, parent, profile, session): c = session.query(Category).filter(Category.name == name).first() if not c: c = Category(name=name, parent_id=parent) @@ -52,14 +80,14 @@ def create_category(name, parent, profile): return c.id -def add_category(name, profile): +def add_category(name, profile, session): parts = name.split(":") if len(parts) == 1: - return create_category(name, None, profile) + return create_category(name, None, profile, session) else: for i in range(len(parts) - 1): parent = parts[i] child = parts[i + 1] - parent_id = create_category(parent, None, profile) - id = create_category(child, parent_id, profile) + parent_id = create_category(parent, None, profile, session) + id = create_category(child, parent_id, profile, session) return id