From 7405107142ad0c352bf5ba93f5e2d80f008710bd Mon Sep 17 00:00:00 2001 From: Pitastic Date: Sat, 11 Apr 2026 21:24:55 +0200 Subject: [PATCH 1/8] Create an export function for settings --- app/routes.py | 34 ++++++++++++++++++++++++++++++++++ app/static/js/index.js | 29 +++++++++++++++++++++++++++++ app/templates/index.html | 22 ++++++++++++++++++++++ 3 files changed, 85 insertions(+) diff --git a/app/routes.py b/app/routes.py index d21d393..d1e11e0 100644 --- a/app/routes.py +++ b/app/routes.py @@ -498,8 +498,42 @@ def uploadRules(metadata): # Import and cleanup result = parent.db_handler.import_metadata(path, metatype=metadata) os.remove(path) + if result.get('error'): + return {'error': f"Die Datei konnte nicht importiert werden: {result.get('error')}"}, 400 + return result, 201 if result.get('inserted') else 200 + @current_app.route('/api/export/metadata/', methods=['GET']) + def exportMetadata(metatype): + """ + Endpunkt für das Exportieren von Metadaten. + + Args (uri): + metatype (str): Type of Metadata to export + Returns: + json: Informationen zur Datei und Ergebnis der Untersuchung. + """ + if metatype not in ['rule', 'parser', 'config']: + return {'error': 'Ungültiger Metadatentyp (rule, parser, config)'}, 400 + + meta = parent.db_handler.filter_metadata({ + 'key': 'metatype', + 'value': metatype + }) + + # Strip uuids for export + for m in meta: + m.pop('uuid', None) + + # Create file response + response = make_response(json.dumps(meta, indent=4)) + response.headers['Content-Type'] = 'application/json' + response.headers['Content-Disposition'] = ( + f'attachment; filename={metatype}_export.json' + ) + return response + + @current_app.route('/api/deleteDatabase/', methods=['DELETE']) def deleteDatabase(iban): """ diff --git a/app/static/js/index.js b/app/static/js/index.js index 2687e5a..d6dba06 100644 --- a/app/static/js/index.js +++ b/app/static/js/index.js @@ -250,6 +250,35 @@ function importSettings() { }, true); } +/** + * Sends a request to the server to export settings of the selected type. + * The type is selected via the select input element 'export-setting-type'. + */ +function exportSettings() { + const settings_type = document.getElementById('export-setting-type').value; + if (!settings_type) { + alert('Please select a settings type to export.'); + return; + } + + apiGet('export/metadata/' + settings_type, {}, function (response, error) { + if (error) { + showAjaxError(error, response); + } else { + const blob = new Blob([JSON.stringify(response, null, 4)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${settings_type}_export.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + }); +} + + /** * Sends transactions in a file or a batch of files to the server for upload. * The file is selected via the file input element 'file-input' (multiple) diff --git a/app/templates/index.html b/app/templates/index.html index e77dd8e..fe8b88e 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -240,6 +240,28 @@

Settings

+
+ + Export in eine Datei: + +

+ Exportiere eine Art von Einstellungen in eine .json Datei, um sie über das + Serververzeichnis /app/settings/* oder in der Oberfläche wieder zu importieren. +

+

+ Namensgleiche Einstellungen werden überschrieben, neue hinzugefügt. +

+

+ +

+
+ +
+
From 86860cf8751c441d0ed61330ecdf7c633a7e4bde Mon Sep 17 00:00:00 2001 From: Pitastic Date: Sat, 25 Apr 2026 21:12:21 +0200 Subject: [PATCH 2/8] Header in Mobile Ansicht wieder da; close #72 --- app/static/css/grid.css | 72 ++++++++++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 23 deletions(-) diff --git a/app/static/css/grid.css b/app/static/css/grid.css index c169753..8738de7 100644 --- a/app/static/css/grid.css +++ b/app/static/css/grid.css @@ -78,38 +78,59 @@ display: grid; grid-template-columns: 1fr; } - .transactions thead { - display: none; - } - .transactions tr { - display: grid; - grid-template-columns: 1fr 5fr 5fr 1fr; - grid-template-rows: 1fr 1fr; - gap: 0.25em; - grid-template-areas: - "checkbox dates category amount" - "button betreff betreff betreff"; - padding: 1em 0; - } - .transactions td { - display: block; - border: none; - padding-top: 0em; - padding-bottom: 0em; - } + .transactions thead { + padding: 1em 0; + } + .transactions thead tr { + display: grid; + grid-template-columns: 1fr 3fr 6fr 1fr; + grid-template-rows: 1fr; + gap: 0.25em; + grid-template-areas: + "checkbox dates betreff amount"; + padding: 1em 0; + } + .transactions thead th { + border: none; + } + .transactions thead th:first-child { + padding-left: 0; + } + .transactions tbody tr { + display: grid; + grid-template-columns: 1fr 5fr 5fr 1fr; + grid-template-rows: 1fr 1fr; + gap: 0.25em; + grid-template-areas: + "checkbox dates category amount" + "button betreff betreff betreff"; + padding: 1em 0; + } + .transactions td { + display: block; + border: none; + padding-top: 0em; + padding-bottom: 0em; + } /* Naming and Styling Cells */ - .transactions tr td:nth-child(1){ + .transactions tr td:nth-child(1), + .transactions tr th:nth-child(1){ padding-left: 0; grid-area: checkbox; } - .transactions tr td:nth-child(2){grid-area: dates;} - .transactions tr td:nth-child(3){ + .transactions tr td:nth-child(2), + .transactions tr th:nth-child(2) { + grid-area: dates; + } + .transactions tr td:nth-child(3), + .transactions tr th:nth-child(3){ padding-right: 0; grid-area: betreff; } .transactions tr td:nth-child(4){grid-area: category;} - .transactions tr td:nth-child(6){ + .transactions tr td:nth-child(6), + .transactions tr th:nth-child(6){ padding-right: 0; grid-area: amount; } @@ -117,6 +138,11 @@ padding-left: 0; grid-area: button; } + .transactions tr th:nth-child(4), + .transactions tr th:nth-child(7) { + display: none; + } + /* TODO: #48 ,TX Details PopUp #dynamic-results tr:last-child th, From 617d73721b9a3d6a24f2e018085551926141b198 Mon Sep 17 00:00:00 2001 From: Pitastic Date: Sun, 26 Apr 2026 21:26:25 +0200 Subject: [PATCH 3/8] Create specific test for issue #73; Fix Pytest's create_app import Co-authored-by: Copilot --- app/server.py | 19 +++++++++++-------- tests/config.py | 12 ++++++------ tests/conftest.py | 4 ++++ tests/start_test.sh | 9 --------- tests/test_integ_basics.py | 38 ++++++++++++++++++++++++++++++++++++-- 5 files changed, 57 insertions(+), 25 deletions(-) delete mode 100644 tests/start_test.sh diff --git a/app/server.py b/app/server.py index 348de9b..7f3bcae 100755 --- a/app/server.py +++ b/app/server.py @@ -56,12 +56,15 @@ def create_app(config_path: str) -> Flask: return app -config = os.path.join( - os.path.dirname(os.path.abspath(__file__)), - 'config.py' -) -application = create_app(config) -if __name__ == "__main__": - # Run the application directly if executed as a standalone script - application.run(host='0.0.0.0', port=8000, debug=True) +# Only create the application if not in a test environment +if os.getenv('PYTEST_MODE') is None: # Or another test-detection method + config = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + 'config.py' + ) + application = create_app(config) + + if __name__ == "__main__": + # Run the application directly if executed as a standalone script + application.run(host='0.0.0.0', port=8000, debug=True) diff --git a/tests/config.py b/tests/config.py index 8e9a37a..d2a8e85 100644 --- a/tests/config.py +++ b/tests/config.py @@ -12,13 +12,13 @@ PASSWORD = os.getenv('AUTH_PASSWORD', 'change_this_password') # - Database Backend ('tiny' or 'mongo') -#DATABASE_BACKEND = 'mongo' -DATABASE_BACKEND = 'tiny' +DATABASE_BACKEND = 'mongo' +#DATABASE_BACKEND = 'tiny' -#DATABASE_URI = 'mongodb://testuser:testpassword@localhost:27017' # For mongo (URI) -DATABASE_URI = '/tmp/pynance-test' # For tiny (/path/to/) +DATABASE_URI = 'mongodb://testuser:testpassword@localhost:27017' # For mongo (URI) +#DATABASE_URI = '/tmp/pynance-test' # For tiny (/path/to/) # For tiny: Filename ('testdata.json') # For mongo: Collection name ('testdata') -#DATABASE_NAME = 'testdata' -DATABASE_NAME = 'testdata.json' +DATABASE_NAME = 'testdata' +#DATABASE_NAME = 'testdata.json' diff --git a/tests/conftest.py b/tests/conftest.py index 90822ae..7593fe7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,9 @@ parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.append(parent_dir) +# Set Env before more imports +os.environ['PYTEST_MODE'] = '1' + from helper import MockDatabase from app.server import create_app @@ -22,6 +25,7 @@ def test_app(): # Config root_path = os.path.dirname(os.path.realpath(__file__)) + print(f"Root Path: {root_path}") config_path = os.path.join( root_path, 'config.py' diff --git a/tests/start_test.sh b/tests/start_test.sh deleted file mode 100644 index d2b7113..0000000 --- a/tests/start_test.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash - -SCRIPT_PATH="`dirname \"$0\"`" -ROOT_PATH="`( cd \"$SCRIPT_PATH\" && pwd )`" -cd $ROOT_PATH/../ - -docker network create test-network 2> /dev/null - -docker run --network test-network -t --rm -v .:/app test_app "$@" \ No newline at end of file diff --git a/tests/test_integ_basics.py b/tests/test_integ_basics.py index 6792dee..e5534cc 100644 --- a/tests/test_integ_basics.py +++ b/tests/test_integ_basics.py @@ -6,6 +6,7 @@ import sys import io from bs4 import BeautifulSoup +import pytest # Add Parent for importing from 'app.py' parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -766,6 +767,13 @@ def test_iban_filtering_tags(test_app): assert len(rows) == 1, \ f"Es wurden {len(rows)} Einträge gefunden, statt der erwarteten 1" + +def test_iban_filtering_tags_equal(test_app): + """Testet die Gleichheitsfilter für Tags""" + with test_app.app_context(): + + with test_app.test_client() as client: + # exact (==) result = client.get( r"/DE89370400440532013000?tags=Test_SECONDARY_2%2CReplaced_TAG&tag_mode=exact") @@ -776,6 +784,31 @@ def test_iban_filtering_tags(test_app): assert len(rows) == 1, \ f"Es wurden {len(rows)} Einträge gefunden, statt der erwarteten 1" + # add a third tag to one the above result entries + new_tag = { + 'tags': ['Test_SECONDARY_3'], + 't_ids': ["6884802db5e07ee68a68e2c64f9c0cdd", + "fdd4649484137572ac642e2c0f34f9af"] + } + r = client.put( + "/api/setManualTags/DE89370400440532013000", + json=new_tag + ) + assert r.status_code == 200, \ + "Der API Endpoint zum Setzen von Tags ist nicht (richtig) erreichbar" + + # test again exact (==) + result = client.get( + r"/DE89370400440532013000?tags=Test_SECONDARY_2%2CTest_SECONDARY_3&tag_mode=exact") + soup = BeautifulSoup(result.text, features="html.parser") + rows = soup.css.select('table.transactions tr[name] td input.row-checkbox') + assert result.status_code == 200, \ + "Die Ergebnisseite mit den Transaktionen ist nicht (richtig) erreichbar" + assert len(rows) == 1, \ + f"Es wurden {len(rows)} Einträge gefunden, statt der erwarteten 1" + assert "fdd4649484137572ac642e2c0f34f9af" == rows[0].attrs.get('data-txuuid'), \ + "Der gefundene Eintrag war nicht der erwartete" + # exact (== no tags) result = client.get( r"/DE89370400440532013000?tags=&tag_mode=exact") @@ -786,6 +819,7 @@ def test_iban_filtering_tags(test_app): assert len(rows) == 0, \ f"Es wurden {len(rows)} Einträge gefunden, statt der erwarteten 0" + def test_statsapi(test_app): """Testet den API-Endpoint für die Statistiken""" with test_app.app_context(): @@ -819,7 +853,7 @@ def test_statspage(test_app): "Die Statistikseite ist nicht (richtig) erreichbar" soup = BeautifulSoup(result.text, features="html.parser") table_rows = soup.css.select('table.ranking tr') - assert len(table_rows) == 8, \ + assert len(table_rows) == 9, \ "Es wurde nicht die richtige Anzahl an Einträgen im Ranking der Kategorien gefunden" # ...mit Filter @@ -829,7 +863,7 @@ def test_statspage(test_app): "Die Statistikseite ist nicht (richtig) erreichbar" soup = BeautifulSoup(result.text, features="html.parser") table_rows = soup.css.select('table.ranking tr') - assert len(table_rows) == 5, \ + assert len(table_rows) == 6, \ "Es wurde nicht die richtige Anzahl an Einträgen im Ranking der Kategorien gefunden" From e7c45234aa5bbd41c424cb00036e1442628afe08 Mon Sep 17 00:00:00 2001 From: Pitastic Date: Tue, 28 Apr 2026 21:18:50 +0200 Subject: [PATCH 4/8] Clarify and split UseCase Co-authored-by: Copilot --- tests/test_integ_basics.py | 88 -------------------- tests/test_integ_filter.py | 165 +++++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 88 deletions(-) create mode 100644 tests/test_integ_filter.py diff --git a/tests/test_integ_basics.py b/tests/test_integ_basics.py index e5534cc..d209074 100644 --- a/tests/test_integ_basics.py +++ b/tests/test_integ_basics.py @@ -6,7 +6,6 @@ import sys import io from bs4 import BeautifulSoup -import pytest # Add Parent for importing from 'app.py' parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -733,93 +732,6 @@ def test_iban_filtering(test_app): f"Es wurden {len(rows)} Einträge gefunden, statt der erwarteten 1" -def test_iban_filtering_tags(test_app): - """Eigene Funktion zum Testen der aufwendigeren Tag-Filter""" - with test_app.app_context(): - - with test_app.test_client() as client: - - # in - result = client.get(r"/DE89370400440532013000?tags=Supermarkt%2CStadt&tag_mode=in") - soup = BeautifulSoup(result.text, features="html.parser") - rows = soup.css.select('table.transactions tr[name] td input.row-checkbox') - assert result.status_code == 200, \ - "Die Ergebnisseite mit den Transaktionen ist nicht (richtig) erreichbar" - assert len(rows) == 2, \ - f"Es wurden {len(rows)} Einträge gefunden, statt der erwarteten 2" - - # notin - result = client.get(r"/DE89370400440532013000?tags=Supermarkt%2CStadt&tag_mode=notin") - soup = BeautifulSoup(result.text, features="html.parser") - rows = soup.css.select('table.transactions tr[name] td input.row-checkbox') - assert result.status_code == 200, \ - "Die Ergebnisseite mit den Transaktionen ist nicht (richtig) erreichbar" - assert len(rows) == 3, \ - f"Es wurden {len(rows)} Einträge gefunden, statt der erwarteten 3" - - # all - result = client.get( - r"/DE89370400440532013000?tags=Test_SECONDARY_2%2CReplaced_TAG&tag_mode=all") - soup = BeautifulSoup(result.text, features="html.parser") - rows = soup.css.select('table.transactions tr[name] td input.row-checkbox') - assert result.status_code == 200, \ - "Die Ergebnisseite mit den Transaktionen ist nicht (richtig) erreichbar" - assert len(rows) == 1, \ - f"Es wurden {len(rows)} Einträge gefunden, statt der erwarteten 1" - - -def test_iban_filtering_tags_equal(test_app): - """Testet die Gleichheitsfilter für Tags""" - with test_app.app_context(): - - with test_app.test_client() as client: - - # exact (==) - result = client.get( - r"/DE89370400440532013000?tags=Test_SECONDARY_2%2CReplaced_TAG&tag_mode=exact") - soup = BeautifulSoup(result.text, features="html.parser") - rows = soup.css.select('table.transactions tr[name] td input.row-checkbox') - assert result.status_code == 200, \ - "Die Ergebnisseite mit den Transaktionen ist nicht (richtig) erreichbar" - assert len(rows) == 1, \ - f"Es wurden {len(rows)} Einträge gefunden, statt der erwarteten 1" - - # add a third tag to one the above result entries - new_tag = { - 'tags': ['Test_SECONDARY_3'], - 't_ids': ["6884802db5e07ee68a68e2c64f9c0cdd", - "fdd4649484137572ac642e2c0f34f9af"] - } - r = client.put( - "/api/setManualTags/DE89370400440532013000", - json=new_tag - ) - assert r.status_code == 200, \ - "Der API Endpoint zum Setzen von Tags ist nicht (richtig) erreichbar" - - # test again exact (==) - result = client.get( - r"/DE89370400440532013000?tags=Test_SECONDARY_2%2CTest_SECONDARY_3&tag_mode=exact") - soup = BeautifulSoup(result.text, features="html.parser") - rows = soup.css.select('table.transactions tr[name] td input.row-checkbox') - assert result.status_code == 200, \ - "Die Ergebnisseite mit den Transaktionen ist nicht (richtig) erreichbar" - assert len(rows) == 1, \ - f"Es wurden {len(rows)} Einträge gefunden, statt der erwarteten 1" - assert "fdd4649484137572ac642e2c0f34f9af" == rows[0].attrs.get('data-txuuid'), \ - "Der gefundene Eintrag war nicht der erwartete" - - # exact (== no tags) - result = client.get( - r"/DE89370400440532013000?tags=&tag_mode=exact") - soup = BeautifulSoup(result.text, features="html.parser") - rows = soup.css.select('table.transactions tr[name] td input.row-checkbox') - assert result.status_code == 200, \ - "Die Ergebnisseite mit den Transaktionen ist nicht (richtig) erreichbar" - assert len(rows) == 0, \ - f"Es wurden {len(rows)} Einträge gefunden, statt der erwarteten 0" - - def test_statsapi(test_app): """Testet den API-Endpoint für die Statistiken""" with test_app.app_context(): diff --git a/tests/test_integ_filter.py b/tests/test_integ_filter.py new file mode 100644 index 0000000..965c630 --- /dev/null +++ b/tests/test_integ_filter.py @@ -0,0 +1,165 @@ +#!/usr/bin/python3 # pylint: disable=invalid-name +"""Testmodul für die Filterung von Transaktionen""" + +import os +import sys +import io +from bs4 import BeautifulSoup + +# Add Parent for importing from 'app.py' +parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.append(parent_dir) + +from helper import get_testfile_contents + +EXAMPLE_CSV = os.path.join( + os.path.dirname(os.path.realpath(__file__)), + 'input_commerzbank.csv' +) + + +def test_truncate_and_upload(test_app): + """Leert die Datenbank und lädt Beispieldaten hoch""" + with test_app.app_context(): + + with test_app.test_client() as client: + result = client.delete("/api/deleteDatabase/DE89370400440532013000") + assert result.status_code == 200, "Fehler beim Leeren der Datenbank" + + # Prepare File + content = get_testfile_contents(EXAMPLE_CSV, binary=True) + files = { + 'file-batch': (io.BytesIO(content), 'input_commerzbank.csv'), + 'bank': 'Commerzbank' + } + # Post File + result = client.post( + "/api/upload/DE89370400440532013000", + data=files, content_type='multipart/form-data' + ) + + # Check Response + assert result.status_code == 201, \ + f"Die Seite hat den Upload nicht wie erwartet verarbeitet: {result.text}" + assert result.json.get('filename') == 'input_commerzbank.csv', \ + "Angaben zum Upload wurden nicht gefunden" + + # Add Tags ("786e1d4e16832aa321a0176c854fe087" : ohne Tags) + new_tag = { + 'tags': ['TestTag1', 'TestTag2'], + 't_ids': ["6884802db5e07ee68a68e2c64f9c0cdd", + "fdd4649484137572ac642e2c0f34f9af"] + } + r = client.put( + "/api/setManualTags/DE89370400440532013000", + json=new_tag + ) + r = r.json + assert r.get('updated') == 2, "Der Eintrag wurde nicht aktualisiert" + + new_tag = { + 'tags': ['TestTag2'], + 't_ids': ["524a0184ca2ba4a5e438f362da95cffc"] + } + r = client.put( + "/api/setManualTags/DE89370400440532013000", + json=new_tag + ) + r = r.json + assert r.get('updated') == 1, "Der Eintrag wurde nicht aktualisiert" + + new_tag = { + 'tags': ['TestTag1', 'TestTag3', 'TestTag4'], + 't_ids': ["cf1fb4e6c131570e4f3b2ac857dead40"] + } + r = client.put( + "/api/setManualTags/DE89370400440532013000", + json=new_tag + ) + r = r.json + assert r.get('updated') == 1, "Der Eintrag wurde nicht aktualisiert" + + +def test_iban_filtering_tags_in(test_app): + """Tag-Filter: in """ + with test_app.app_context(): + + with test_app.test_client() as client: + + # in + result = client.get(r"/DE89370400440532013000?tags=TestTag1%2CTestTag3&tag_mode=in") + soup = BeautifulSoup(result.text, features="html.parser") + rows = soup.css.select('table.transactions tr[name] td input.row-checkbox') + assert result.status_code == 200, \ + "Die Ergebnisseite mit den Transaktionen ist nicht (richtig) erreichbar" + assert len(rows) == 3, \ + f"Es wurden {len(rows)} Einträge gefunden, statt der erwarteten 4" + + +def test_iban_filtering_tags_notin(test_app): + """Tag-Filter: not-in """ + with test_app.app_context(): + + with test_app.test_client() as client: + + result = client.get(r"/DE89370400440532013000?tags=TestTag1%2CTestTag3&tag_mode=notin") + soup = BeautifulSoup(result.text, features="html.parser") + rows = soup.css.select('table.transactions tr[name] td input.row-checkbox') + assert result.status_code == 200, \ + "Die Ergebnisseite mit den Transaktionen ist nicht (richtig) erreichbar" + assert len(rows) == 2, \ + f"Es wurden {len(rows)} Einträge gefunden, statt der erwarteten 1" + + +def test_iban_filtering_tags_all(test_app): + """Tag-Filter: all """ + with test_app.app_context(): + + with test_app.test_client() as client: + + # all + result = client.get( + r"/DE89370400440532013000?tags=TestTag1%2CTestTag3%2CTestTag4&tag_mode=all") + soup = BeautifulSoup(result.text, features="html.parser") + rows = soup.css.select('table.transactions tr[name] td input.row-checkbox') + assert result.status_code == 200, \ + "Die Ergebnisseite mit den Transaktionen ist nicht (richtig) erreichbar" + assert len(rows) == 1, \ + f"Es wurden {len(rows)} Einträge gefunden, statt der erwarteten 1" + + +def test_iban_filtering_tags_equal(test_app): + """Tag-Filter: exact (==) """ + with test_app.app_context(): + + with test_app.test_client() as client: + + # exact (== no tags) + result = client.get( + r"/DE89370400440532013000?tags=&tag_mode=exact") + soup = BeautifulSoup(result.text, features="html.parser") + rows = soup.css.select('table.transactions tr[name] td input.row-checkbox') + assert result.status_code == 200, \ + "Die Ergebnisseite mit den Transaktionen ist nicht (richtig) erreichbar" + assert len(rows) == 1, \ + f"Es wurden {len(rows)} Einträge gefunden, statt der erwarteten 1" + + # exact (==) + result = client.get( + r"/DE89370400440532013000?tags=TestTag1%2CTestTag2&tag_mode=exact") + soup = BeautifulSoup(result.text, features="html.parser") + rows = soup.css.select('table.transactions tr[name] td input.row-checkbox') + assert result.status_code == 200, \ + "Die Ergebnisseite mit den Transaktionen ist nicht (richtig) erreichbar" + assert len(rows) == 2, \ + f"Es wurden {len(rows)} Einträge gefunden, statt der erwarteten 2" + + result = client.get( + r"/DE89370400440532013000?tags=TestTag1%2CTestTag3&tag_mode=exact") + soup = BeautifulSoup(result.text, features="html.parser") + rows = soup.css.select('table.transactions tr[name] td input.row-checkbox') + assert result.status_code == 200, \ + "Die Ergebnisseite mit den Transaktionen ist nicht (richtig) erreichbar" + assert len(rows) == 0, \ + f"Es wurden {len(rows)} Einträge gefunden, statt der erwarteten 0" + From 166c54b9a80eb472d48ff68d1c8ccef7af52db9f Mon Sep 17 00:00:00 2001 From: Pitastic Date: Wed, 29 Apr 2026 20:21:49 +0200 Subject: [PATCH 5/8] simply close #73 --- handler/MongoDb.py | 7 +++++-- tests/test_integ_basics.py | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/handler/MongoDb.py b/handler/MongoDb.py index 1148541..56813dd 100644 --- a/handler/MongoDb.py +++ b/handler/MongoDb.py @@ -309,8 +309,11 @@ def _form_condition(self, condition): # Empty lists stmt = {'$size': 0} else: - # Lists with exact members - stmt = {'$all': condition.get('value')} + # Lists with exact members and exact length + stmt = { + '$all': condition.get('value'), + '$size': len(condition.get('value')) + } # Nested or Plain Key condition_key = condition.get('key') diff --git a/tests/test_integ_basics.py b/tests/test_integ_basics.py index d209074..9d45080 100644 --- a/tests/test_integ_basics.py +++ b/tests/test_integ_basics.py @@ -765,7 +765,7 @@ def test_statspage(test_app): "Die Statistikseite ist nicht (richtig) erreichbar" soup = BeautifulSoup(result.text, features="html.parser") table_rows = soup.css.select('table.ranking tr') - assert len(table_rows) == 9, \ + assert len(table_rows) == 8, \ "Es wurde nicht die richtige Anzahl an Einträgen im Ranking der Kategorien gefunden" # ...mit Filter @@ -775,7 +775,7 @@ def test_statspage(test_app): "Die Statistikseite ist nicht (richtig) erreichbar" soup = BeautifulSoup(result.text, features="html.parser") table_rows = soup.css.select('table.ranking tr') - assert len(table_rows) == 6, \ + assert len(table_rows) == 5, \ "Es wurde nicht die richtige Anzahl an Einträgen im Ranking der Kategorien gefunden" From a84061231b3d86f0308256b665633e0cac216b0c Mon Sep 17 00:00:00 2001 From: Pitastic Date: Wed, 29 Apr 2026 20:41:19 +0200 Subject: [PATCH 6/8] =?UTF-8?q?TinyDB=20muss=20f=C3=BCr=20Git=20Wokflows?= =?UTF-8?q?=20default=20sein?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/config.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/config.py b/tests/config.py index d2a8e85..8e9a37a 100644 --- a/tests/config.py +++ b/tests/config.py @@ -12,13 +12,13 @@ PASSWORD = os.getenv('AUTH_PASSWORD', 'change_this_password') # - Database Backend ('tiny' or 'mongo') -DATABASE_BACKEND = 'mongo' -#DATABASE_BACKEND = 'tiny' +#DATABASE_BACKEND = 'mongo' +DATABASE_BACKEND = 'tiny' -DATABASE_URI = 'mongodb://testuser:testpassword@localhost:27017' # For mongo (URI) -#DATABASE_URI = '/tmp/pynance-test' # For tiny (/path/to/) +#DATABASE_URI = 'mongodb://testuser:testpassword@localhost:27017' # For mongo (URI) +DATABASE_URI = '/tmp/pynance-test' # For tiny (/path/to/) # For tiny: Filename ('testdata.json') # For mongo: Collection name ('testdata') -DATABASE_NAME = 'testdata' -#DATABASE_NAME = 'testdata.json' +#DATABASE_NAME = 'testdata' +DATABASE_NAME = 'testdata.json' From b1da0b9d962c33c1f2b4b6a344938f4ed1d0b5b1 Mon Sep 17 00:00:00 2001 From: Pitastic Date: Fri, 8 May 2026 21:11:52 +0200 Subject: [PATCH 7/8] Sort By Amount --- app/routes.py | 10 ++++++--- app/static/js/functions.js | 43 ++++++++++++++++++++++++++++++++++---- app/templates/macros.html | 13 +++++++++--- app/ui.py | 4 ++++ handler/BaseDb.py | 7 ++++--- tests/test_unit_sorting.py | 35 +++++++++++++++++++++++++++++++ 6 files changed, 99 insertions(+), 13 deletions(-) diff --git a/app/routes.py b/app/routes.py index d1e11e0..212bfc4 100644 --- a/app/routes.py +++ b/app/routes.py @@ -128,7 +128,9 @@ def iban(iban) -> str: amount_min, float (query): Betragsfilter (größer gleich amount_min) amount_max, float (query): Betragsfilter (kleiner gleich amount_max) page, int (query): Seite für die Paginierung (default: 1) - descending, bool (query): Sortierreihenfolge nach Datum (default: True) + sort_by, str (query): Schlüssel, nach dem sortiert werden soll + (default: date_tx) + descending, bool (query): Sortierreihenfolge (default: True, absteigend) Returns: html: Startseite mit Navigation """ @@ -141,7 +143,10 @@ def iban(iban) -> str: # Table with Transactions current_app.logger.debug(f"Using condition filter: {condition}") sort_order = request.args.get('descending', 'true').lower() == 'true' - rows = parent.db_handler.select(iban, condition, descending=sort_order) + sort_by = request.args.get('sort_by', 'date_tx') + rows = parent.db_handler.select( + iban, condition, descending=sort_order, sort_by=sort_by + ) # If pagination is requested, do not serve the whole page and all metadata entries_per_page = 50 @@ -533,7 +538,6 @@ def exportMetadata(metatype): ) return response - @current_app.route('/api/deleteDatabase/', methods=['DELETE']) def deleteDatabase(iban): """ diff --git a/app/static/js/functions.js b/app/static/js/functions.js index 5e2ddce..fd0bba5 100644 --- a/app/static/js/functions.js +++ b/app/static/js/functions.js @@ -17,6 +17,26 @@ document.addEventListener('DOMContentLoaded', function () { } } + // Event handlers for sorting dropdowns to ensure only one is active + const filterDescending = document.getElementById('filter-sort-date'); + const filterSortAmount = document.getElementById('filter-sort-amount'); + + if (filterDescending) { + filterDescending.addEventListener('change', function() { + if (this.value !== '') { + filterSortAmount.value = ''; + } + }); + } + + if (filterSortAmount) { + filterSortAmount.addEventListener('change', function() { + if (this.value !== '') { + filterDescending.value = ''; + } + }); + } + }); // ---------------------------------------------------------------------------- @@ -104,12 +124,27 @@ function getFilteredList() { arg_concat = '&'; } - let sort_order = document.getElementById('filter-descending').value; - if (sort_order) { - query_args = query_args + arg_concat + 'descending=' + sort_order; - arg_concat = '&'; + // Handle sorting: amount or date + const sort_amount = document.getElementById('filter-sort-amount').value; + const sort_date = document.getElementById('filter-sort-date').value; + + let sort_by_value = 'date_tx'; + let descending_value = 'true'; + + if (sort_amount) { + // Sort by amount + sort_by_value = 'amount'; + descending_value = sort_amount; + } else if (sort_date) { + // Sort by date + sort_by_value = 'date_tx'; + descending_value = sort_date; } + query_args = query_args + arg_concat + 'sort_by=' + sort_by_value; + arg_concat = '&'; + query_args = query_args + arg_concat + 'descending=' + descending_value; + return query_args; } diff --git a/app/templates/macros.html b/app/templates/macros.html index f6e9fdb..00b7b06 100644 --- a/app/templates/macros.html +++ b/app/templates/macros.html @@ -75,9 +75,10 @@

Transaktionen filtern

value="" {% endif %} /> - + + + @@ -142,6 +143,12 @@

Transaktionen filtern

{% else %} value="" /> {% endif %} + + diff --git a/app/ui.py b/app/ui.py index e6d7d81..fe5d459 100644 --- a/app/ui.py +++ b/app/ui.py @@ -123,6 +123,10 @@ def filter_to_condition(self, get_args: dict) -> list: continue # - Sort direction + sort_by = get_args.get('sort_by') + if sort_by is not None: + frontend_filters['sort_by'] = sort_by + sort_desc = get_args.get('descending') if sort_desc is not None: frontend_filters['descending'] = sort_desc diff --git a/handler/BaseDb.py b/handler/BaseDb.py index 4b7f078..23e9aea 100644 --- a/handler/BaseDb.py +++ b/handler/BaseDb.py @@ -47,7 +47,7 @@ def add_iban_group(self, groupname: str, ibans: list): return self.set_metadata(new_group, overwrite=True) def select(self, collection:str, condition: dict|list[dict]=None, multi: str='AND', - descending: bool=True): + descending: bool=True, sort_by: str='date_tx'): """ Handler für das Vorbereiten der '_select' Methode, welche Datensätze aus der Datenbank selektiert, die die angegebene Bedingung erfüllen. @@ -67,6 +67,7 @@ def select(self, collection:str, condition: dict|list[dict]=None, multi: str='AN werden diese logisch wie hier angegeben verknüpft. Default: 'AND' descending (bool): Wenn True, werden die Ergebnisse aufsteigend nach Datum sortiert. Default: True. + sort_by (str): Schlüssel, nach dem sortiert werden soll. Default: 'date_tx' Returns: dict: - result, list: Liste der ausgewählten Datensätze @@ -90,8 +91,8 @@ def select(self, collection:str, condition: dict|list[dict]=None, multi: str='AN result_list = self._select(collection, condition, multi) - # Sort the result by date_tx - result_list = sorted(result_list, reverse=descending, key=lambda x: x.get('date_tx', 0)) + # Sort the result by the specified field + result_list = sorted(result_list, reverse=descending, key=lambda x: x.get(sort_by, 0)) for r in result_list: # Format Datestrings diff --git a/tests/test_unit_sorting.py b/tests/test_unit_sorting.py index b2e9a3c..4bd2657 100644 --- a/tests/test_unit_sorting.py +++ b/tests/test_unit_sorting.py @@ -96,3 +96,38 @@ def test_sort_entries_date_desc(test_app): dates = [entry['date_tx'] for entry in sorted_entries] assert dates == sorted(dates, reverse=True), \ "Die Einträge sind nicht korrekt nach Datum absteigend sortiert." + +def test_sort_entries_amount_asc(test_app): + """ + Testet das sortierte Ausgeben von Einträgen nach Betrag aufsteigend. + """ + with test_app.app_context(): + group_name = "testgroup" + + # Get entries in ascending order by amount + sorted_entries = test_app.host.db_handler.select( + group_name, sort_by='amount', descending=False + ) + + # Check sorted form of retrieved entries + amounts = [entry['amount'] for entry in sorted_entries] + assert amounts == sorted(amounts), \ + "Die Einträge sind nicht korrekt nach Betrag aufsteigend sortiert." + + +def test_sort_entries_amount_desc(test_app): + """ + Testet das sortierte Ausgeben von Einträgen nach Betrag absteigend. + """ + with test_app.app_context(): + group_name = "testgroup" + + # Get entries in descending order by amount + sorted_entries = test_app.host.db_handler.select( + group_name, sort_by='amount', descending=True + ) + + # Check sorted form of retrieved entries + amounts = [entry['amount'] for entry in sorted_entries] + assert amounts == sorted(amounts, reverse=True), \ + "Die Einträge sind nicht korrekt nach Betrag absteigend sortiert." From a3826acbbf35a3130d4c3d9b7a21947a5afec322 Mon Sep 17 00:00:00 2001 From: Pitastic Date: Tue, 12 May 2026 21:23:57 +0200 Subject: [PATCH 8/8] =?UTF-8?q?Top=20Stats=20Berechnung=20eingef=C3=BChrt.?= =?UTF-8?q?=20Werte=20noch=20nicht=20stabil....?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/routes.py | 67 ++++------ handler/BaseDb.py | 111 ++++++++++++++-- settings/rule/00-default-categories.json | 2 +- tests/input_generic2.csv | 11 +- tests/test_integ_more_routes.py | 28 ++++ tests/test_integ_more_rules.py | 78 ----------- tests/test_unit_route_stats.py | 157 +++++++++++++++++++++++ 7 files changed, 319 insertions(+), 135 deletions(-) delete mode 100644 tests/test_integ_more_rules.py create mode 100644 tests/test_unit_route_stats.py diff --git a/app/routes.py b/app/routes.py index 212bfc4..6a16b65 100644 --- a/app/routes.py +++ b/app/routes.py @@ -261,39 +261,9 @@ def show_stats(iban) -> str: return "", 404 # Check filter args - condition, frontend_filters = parent.filter_to_condition(request.args) - # Table with Transactions - current_app.logger.debug(f"Using condition filter: {condition}") - rows = parent.db_handler.select(iban, condition) - - # Calculate TOP categories and tags - sums = {'categories': {}, 'tags': {}} - for row in rows: - amount = row.get('amount', 0.0) - cat = row.get('category', 'unkategorisiert') - if cat not in sums['categories']: - sums['categories'][cat] = 0.0 - - sums['categories'][cat] += amount - - tags = row.get('tags', []) - if not tags: - tags = ['untagged'] - for tag in tags: - if tag not in sums['tags']: - sums['tags'][tag] = 0.0 - - sums['tags'][tag] += amount - - # Sort Sums - sums['categories'] = dict(sorted(sums['categories'].items(), - key=lambda item: item[1], - reverse=True)) - sums['tags'] = dict(sorted(sums['tags'].items(), - key=lambda item: item[1], - reverse=True)) - - return render_template('stats.html', sums=sums, IBAN=iban, + _, frontend_filters = parent.filter_to_condition(request.args) + + return render_template('stats.html', IBAN=iban, filters=frontend_filters) @current_app.route('/sw.js') @@ -890,18 +860,35 @@ def stream(iban_data, parsers): @current_app.route('/api/stats/', methods=['GET']) def statsIban(iban): """ - Liefert Statistiken zur IBAN zurück. + Liefert eine bestimmte Statistik zu einer IBAN und einem Filter zurück. Args (uri): iban, str: IBAN zu der die Statistiken angezeigt werden sollen. + startDate, str (query): Startdatum (Y-m-d) für die Anzeige der Einträge + endDate, str (query): Enddatum (Y-m-d) für die Anzeige der Einträge + category, str (query): Kategorie-Filter + tag, str (query): Tag-Filter, einzelner Eintrag oder kommagetrennte Liste + tag_mode, str (query): Vergleichsmodus für Tag-Filter (siehe Models.md) + amount_min, float (query): Betragsfilter (größer gleich amount_min) + amount_max, float (query): Betragsfilter (kleiner gleich amount_max) + type, str (query): Art der Statistik (default: top) Returns: - json: Statistiken zur IBAN - - min_date, str: Datum der ältesten Transaktion - - max_date, str: Datum der jüngsten Transaktion - - number_tx, int: Anzahl der Transaktionen + json: Statistiken zur IBAN als dict {type: dict-result} + - top: siehe db_handler.stats_top() """ if not parent.check_requested_iban(iban): return "", 404 - stats = parent.db_handler.min_max_collection(iban, 'date_tx') - return stats, 200 + condition, _ = parent.filter_to_condition(request.args) + stat_type = request.args.get('type', 'top') + + # Statistic methods + methods = { + 'top': parent.db_handler.stats_top + } + + # Table with Transactions + current_app.logger.debug(f"Using condition filter: {condition}") + return { + stat_type: methods.get(stat_type)(iban, condition) + }, 200 diff --git a/handler/BaseDb.py b/handler/BaseDb.py index 23e9aea..1173455 100644 --- a/handler/BaseDb.py +++ b/handler/BaseDb.py @@ -560,17 +560,106 @@ def check_collection_is_iban(self, collection: str): iban_regex = re.compile(r'[A-Z]{2}[0-9]{2}[ ]?([0-9]{4}[ ]?){4,7}[0-9]{1,4}') return bool(re.match(iban_regex, collection)) - def min_max_collection(self, collection: str, key: str): + def stats_top(self, collection: str, condition: dict|list[dict]): """ - Gibt das Minimum und Maximum sowie die Gesamtzahl an Datensätzen einer Collection zurück. + Berechnet die Top-Kategorien und Tags basierend unter + Berücksichtigung einer Filter-Condition. Args: - collection (str): Name der Collection. - key (str): Schlüssel, für den Minimum und Maximum ermittelt werden sollen. - Returns: - dict: - - min (any): Minimum Wert - - max (any): Maximum Wert - - count (int): Anzahl der Datensätze - """ - raise NotImplementedError() + collection (str): Name der Collection oder Gruppe, aus der selektiert werden soll. + condition (dict | list(dict)): Bedingung als Dictionary + Returns: dict + - cat_top_in, list[dict]: Liste der Kategorien mit den höchsten + positiven Werten + - cat_top_out, list[dict]: Liste der Kategorien mit den höchsten + negativen Werten + - tag_top_in, list[dict]: Liste der Tags mit den höchsten + positiven Werten + - tag_top_out, list[dict]: Liste der Tags mit den höchsten + negativen Werten + - categories, dict: + category, str: Name der Kategorie + count, int: Anzahl der Transaktionen mit dieser Kategorie + sum, int: Summe der Beträge der Transaktionen mit dieser Kategorie + abs_in, int: Summe der positiven Beträge der Transaktionen mit + dieser Kategorie + abs_out, int: Summe der negativen Beträge der Transaktionen mit + dieser Kategorie + - tags, dict: + tag, str: Name des Tags + count, int: Anzahl der Transaktionen mit diesem Tag + sum, int: Summe der Beträge der Transaktionen mit diesem Tag + abs_in, int: Summe der positiven Beträge der Transaktionen + mit diesem Tag + abs_out, int: Summe der negativen Beträge der Transaktionen + """ + result = { + 'cat_top_in': [], + 'cat_top_out': [], + 'tag_top_in': [], + 'tag_top_out': [], + 'categories': {}, + 'tags': {} + } + rows = self.select(collection, condition) + + # Calculate TOP categories and tags + for row in rows: + amount = row.get('amount', 0.0) + cat = row.get('category', 'unkategorisiert') + tags = row.get('tags', ['ungetagged']) + + # Tag and Categories sums + + # -- Cats + if cat not in result['categories']: + result['categories'][cat] = { + 'category': cat, + 'count': 0, + 'sum': 0.0, + 'abs_in': 0.0, + 'abs_out': 0.0 + } + + else: + result['categories'][cat]['count'] += 1 + result['categories'][cat]['sum'] += amount + if amount >= 0: + result['categories'][cat]['abs_in'] += abs(amount) + else: + result['categories'][cat]['abs_out'] += abs(amount) + + # -- Tags + for t in tags: + if t not in result['tags']: + result['tags'][t] = { + 'tag': t, + 'count': 0, + 'sum': 0.0, + 'abs_in': 0.0, + 'abs_out': 0.0 + } + + else: + result['tags'][t]['count'] += 1 + result['tags'][t]['sum'] += amount + if amount >= 0: + result['tags'][t]['abs_in'] += abs(amount) + else: + result['tags'][t]['abs_out'] += abs(amount) + + # Sort and limit results by absolute inflow and return only category keys + result['cat_top_in'] = [key for key, _ in sorted( + result['categories'].items(), key=lambda item: item[1]['abs_in'] + )] + result['cat_top_out'] = [key for key, _ in sorted( + result['categories'].items(), key=lambda item: item[1]['abs_out'], reverse=True + )] + result['tag_top_in'] = [key for key, _ in sorted( + result['tags'].items(), key=lambda item: item[1]['abs_in'] + )] + result['tag_top_out'] = [key for key, _ in sorted( + result['tags'].items(), key=lambda item: item[1]['abs_out'], reverse=True + )] + + return result diff --git a/settings/rule/00-default-categories.json b/settings/rule/00-default-categories.json index 86efd0b..e831af5 100644 --- a/settings/rule/00-default-categories.json +++ b/settings/rule/00-default-categories.json @@ -5,7 +5,7 @@ "category": "Lebensmittel", "filter": [{ "key": "tags", - "value": ["Supermarkt", "Drogerie", "Apotheke", "Bäcker"], + "value": ["Lebensmittel", "Drogerie", "Apotheke", "Bäcker"], "compare": "in" }] }, diff --git a/tests/input_generic2.csv b/tests/input_generic2.csv index 691e325..b6f2504 100644 --- a/tests/input_generic2.csv +++ b/tests/input_generic2.csv @@ -1,6 +1,7 @@ Buchungstag;Valuta;Art;Buchungstext;Betrag;Währung;Gegenkonto -01.01.2023;16.05.2023;Lastschrift;"Dummy Transaction for group tests";-11,63;EUR -03.01.2023;15.05.2023;Lastschrift;"Dummy Transaction for group tests";-99,58;EUR -04.01.2023;15.05.2023;Lastschrift;"Dummy Transaction for group tests";-71,35;EUR -05.01.2023;15.05.2023;Lastschrift;"Dummy Transaction for group tests";-221,98;EUR -02.01.2023;15.05.2023;Lastschrift;"Dummy Transaction for group tests";-118,94;EUR +01.01.2023;16.05.2023;Lastschrift;"Wucherpfennig sagt Danke 88//HANNOV 2023-01-01T08:59:42 KFN 9 VJ 7777 Kartenzahlung";-11,63;EUR;997788666 +04.01.2023;15.05.2023;Lastschrift;"DM FIL.2222 F:1111//Frankfurt/DE 2023-01-04T13:22:16 KFN 9 VJ 7777 Kartenzahlung";-71,35;EUR;997788666 +03.01.2023;15.05.2023;Lastschrift;"EDEKA, München//München/ 2023-01-03T14:39:49 KFN 9 VJ 7777 Kartenzahlung";-99,58;EUR;997788666 +02.01.2023;15.05.2023;Lastschrift;"MEIN GARTENCENTER//Berlin 2023-01-02T12:57:02 KFN 9 VJ 7777 Kartenzahlung";-118,94;EUR;997788666 +05.01.2023;15.05.2023;Lastschrift;"Stadt Halle 0000005112 OBJEKT 0001 ABGABEN LT. BESCHEID End-to-End-Ref.: 2023-01-00111-9090-0000005112 Mandatsref: M1111111 Gläubiger-ID: DE7000100000077777 SEPA-BASISLASTSCHRIFT wiederholend";-221,98;EUR;997788666 +06.01.2023;15.05.2023;Überweisung;"Ihr Gehalt";3000,00;EUR;997788666 diff --git a/tests/test_integ_more_routes.py b/tests/test_integ_more_routes.py index 5049fa9..533e936 100644 --- a/tests/test_integ_more_routes.py +++ b/tests/test_integ_more_routes.py @@ -46,6 +46,34 @@ def test_upload_file_route(test_app): "Angaben zum Upload wurden nicht gefunden" +def test_add_or_update_tags(test_app): + """Test adding and updating tag for a transaction""" + + with test_app.app_context(): + + iban = "DE89370400440532013000" + tid = "6884802db5e07ee68a68e2c64f9c0cdd" + + # Add first tag + r = test_app.host.set_manual_tag_and_cat(iban, tid, ['Bäckerei']) + assert r == {'updated': 1}, \ + "Es wurde keine Transaktion geändert (set_manual_tag_and_cat), first Tag" + + # Update tag (add one more) + r = test_app.host.set_manual_tag_and_cat(iban, tid, ['Markt'], overwrite=False) + assert r == {'updated': 1}, \ + "Es wurde keine Transaktion geändert (set_manual_tag_and_cat), add Tag" + + # Update tag (remove one) + r = test_app.host.set_manual_tag_and_cat(iban, tid, ['Bäckerei'], overwrite=True) + assert r == {'updated': 1}, \ + "Es wurde keine Transaktion geändert (set_manual_tag_and_cat), remove one Tag" + + # Update tag (remove all) + assert test_app.host.remove_tags(iban, tid) == {'updated': 1}, \ + "Es wurde keine Transaktion geändert (remove_tags)" + + def test_get_error_messages(test_app): """ Testet das Auslesen der Fehlermeldungen, diff --git a/tests/test_integ_more_rules.py b/tests/test_integ_more_rules.py deleted file mode 100644 index 74b7838..0000000 --- a/tests/test_integ_more_rules.py +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/python3 # pylint: disable=invalid-name -"""Testing more rules and specific tagging situations""" - -import io -import os -import sys - - -# Add Parent for importing from 'app.py' -parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -sys.path.append(parent_dir) - -from helper import get_testfile_contents - -EXAMPLE_CSV = os.path.join( - os.path.dirname(os.path.realpath(__file__)), - 'input_commerzbank.csv' -) - - -def test_upload_file_route(test_app): - """Upload Testdata for further testing""" - - with test_app.app_context(): - - with test_app.test_client() as client: - - # Clear Database - result = client.delete("/api/deleteDatabase/DE89370400440532013000") - assert result.status_code == 200, "Fehler beim Leeren der Datenbank" - - # Prepare File - content = get_testfile_contents(EXAMPLE_CSV, binary=True) - files = { - 'file-batch': (io.BytesIO(content), 'input_commerzbank.csv'), - 'bank': 'Commerzbank' - } - - # Post File - result = client.post( - "/api/upload/DE89370400440532013000", - data=files, content_type='multipart/form-data' - ) - - # Check Response - # (If other test run already, data is til present and duplicates won't be inserted) - assert result.status_code in (200, 201), \ - f"Die Seite hat den Upload nicht wie erwartet verarbeitet: {result.text}" - assert result.json.get('filename') == 'input_commerzbank.csv', \ - "Angaben zum Upload wurden nicht gefunden" - - -def test_add_or_update_tags(test_app): - """Test adding and updating tag for a transaction""" - - with test_app.app_context(): - - iban = "DE89370400440532013000" - tid = "6884802db5e07ee68a68e2c64f9c0cdd" - - # Add first tag - r = test_app.host.set_manual_tag_and_cat(iban, tid, ['Bäckerei']) - assert r == {'updated': 1}, \ - "Es wurde keine Transaktion geändert (set_manual_tag_and_cat), first Tag" - - # Update tag (add one more) - r = test_app.host.set_manual_tag_and_cat(iban, tid, ['Markt'], overwrite=False) - assert r == {'updated': 1}, \ - "Es wurde keine Transaktion geändert (set_manual_tag_and_cat), add Tag" - - # Update tag (remove one) - r = test_app.host.set_manual_tag_and_cat(iban, tid, ['Bäckerei'], overwrite=True) - assert r == {'updated': 1}, \ - "Es wurde keine Transaktion geändert (set_manual_tag_and_cat), remove one Tag" - - # Update tag (remove all) - assert test_app.host.remove_tags(iban, tid) == {'updated': 1}, \ - "Es wurde keine Transaktion geändert (remove_tags)" diff --git a/tests/test_unit_route_stats.py b/tests/test_unit_route_stats.py new file mode 100644 index 0000000..f8fb818 --- /dev/null +++ b/tests/test_unit_route_stats.py @@ -0,0 +1,157 @@ +#!/usr/bin/python3 # pylint: disable=invalid-name +"""Testmodul das abfragen der Route für die Statistiken""" + +import io +import os +import sys +import pprint + +# Add Parent for importing from Modules +parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.append(parent_dir) + +from helper import get_testfile_contents + + +EXAMPLE_CSV = os.path.join( + os.path.dirname(os.path.realpath(__file__)), + 'input_generic2.csv' +) + +def test_upload_file_route(test_app): + """Upload Testdata for further testing""" + + with test_app.app_context(): + + with test_app.test_client() as client: + iban = "DE89370400440532013000" + + # Clear Database + result = client.delete(f"/api/deleteDatabase/{iban}") + assert result.status_code == 200, "Fehler beim Leeren der Datenbank" + + # Prepare File + content = get_testfile_contents(EXAMPLE_CSV, binary=True) + files = { + 'file-batch': (io.BytesIO(content), 'input_generic2.csv'), + 'bank': 'Generic' + } + + # Post File + result = client.post( + f"/api/upload/{iban}", + data=files, content_type='multipart/form-data' + ) + + # Check Response + # (If other test run already, data is til present and duplicates won't be inserted) + assert result.status_code in (200, 201), \ + f"Die Seite hat den Upload nicht wie erwartet verarbeitet: {result.text}" + assert result.json.get('filename') == 'input_generic2.csv', \ + "Angaben zum Upload wurden nicht gefunden" + + # Taggen + result = client.put(f"/api/tag/{iban}", json={}) + assert result.status_code == 200, \ + "Die Seite hat die Tagging Anfrage nicht wie erwartet verarbeitet" + + tx_id ="5197f4774271512f252acfb3c4f63237" + r = test_app.host.set_manual_tag_and_cat( # pylint: disable=protected-access + iban, tx_id, tags= ['Gehalt'], category='Einkommen' + ) + assert r.get('updated') == 1, 'Es wurde kein Eintrag aktualisiert. ' + + tx_id ="6884802db5e07ee68a68e2c64f9c0cdd" + r = test_app.host.set_manual_tag_and_cat( # pylint: disable=protected-access + iban, tx_id, tags= ['Garten'], category='Hauskosten' + ) + assert r.get('updated') == 1, 'Es wurde kein Eintrag aktualisiert. ' + + tx_id ="fdd4649484137572ac642e2c0f34f9af" + r = test_app.host.set_manual_tag_and_cat( # pylint: disable=protected-access + iban, tx_id, tags= ['Drogerie'] + ) + assert r.get('updated') == 1, 'Es wurde kein Eintrag aktualisiert. ' + + # Kategorisieren + result = client.put(f"/api/cat/{iban}", json={}) + assert result.status_code == 200, \ + "Die Seite hat die Kategorisierung Anfrage nicht wie erwartet verarbeitet" + + +def test_route_stats(test_app): + """Test the route for the statistics""" + + with test_app.app_context(): + + with test_app.test_client() as client: + + # Get Stats + result = client.get("/api/stats/DE89370400440532013000?type=top") + + # Check Response + assert result.status_code == 200, \ + "Die Seite hat die Anfrage nicht wie erwartet verarbeitet" + + result = result.json + pprint.pprint(result) + assert 'top' in result, "Die erwarteten Daten wurden nicht gefunden" + top_result = result['top'] + + + + # Prüfe die Struktur für cat_top_in + assert 'cat_top_in' in top_result, "cat_top_in muss vorhanden sein" + assert isinstance(top_result['cat_top_in'], list), "cat_top_in muss eine Liste sein" + for item in top_result['cat_top_in']: + assert isinstance(item, str), "Jedes Element in cat_top_in muss ein String sein" + + # Prüfe die Struktur für cat_top_out + assert 'cat_top_out' in top_result, "cat_top_out muss vorhanden sein" + assert isinstance(top_result['cat_top_out'], list), "cat_top_out muss eine Liste sein" + for item in top_result['cat_top_out']: + assert isinstance(item, str), "Jedes Element in cat_top_out muss ein String sein" + + # Prüfe die Struktur für tag_top_in + assert 'tag_top_in' in top_result, "tag_top_in muss vorhanden sein" + assert isinstance(top_result['tag_top_in'], list), "tag_top_in muss eine Liste sein" + for item in top_result['tag_top_in']: + assert isinstance(item, str), "Jedes Element in tag_top_in muss ein String sein" + + # Prüfe die Struktur für tag_top_out + assert 'tag_top_out' in top_result, "tag_top_out muss vorhanden sein" + assert isinstance(top_result['tag_top_out'], list), "tag_top_out muss eine Liste sein" + for item in top_result['tag_top_out']: + assert isinstance(item, str), "Jedes Element in tag_top_out muss ein String sein" + + # Prüfe die Struktur für categories + assert 'categories' in top_result, "categories muss vorhanden sein" + assert isinstance(top_result['categories'], dict), "categories muss ein Dictionary sein" + for _, cat in top_result['categories'].items(): + assert isinstance(cat, dict), "Jedes Element in categories muss ein Dictionary sein" + assert 'category' in cat, "category muss in jedem categories-Dict vorhanden sein" + assert isinstance(cat['category'], str), "category muss ein String sein" + assert 'count' in cat, "count muss in jedem categories-Dict vorhanden sein" + assert isinstance(cat['count'], int), "count muss ein Integer sein" + assert 'sum' in cat, "sum muss in jedem categories-Dict vorhanden sein" + assert isinstance(cat['sum'], (int, float)), "sum muss ein Integer oder Float sein" + assert 'abs_in' in cat, "abs_in muss in jedem categories-Dict vorhanden sein" + assert isinstance(cat['abs_in'], (int, float)), "abs_in muss ein Integer oder Float sein" + assert 'abs_out' in cat, "abs_out muss in jedem categories-Dict vorhanden sein" + assert isinstance(cat['abs_out'], (int, float)), "abs_out muss ein Integer oder Float sein" + + # Prüfe die Struktur für tags + assert 'tags' in top_result, "tags muss vorhanden sein" + assert isinstance(top_result['tags'], dict), "tags muss ein Dictionary sein" + for _, tag in top_result['tags'].items(): + assert isinstance(tag, dict), "Jedes Element in tags muss ein Dictionary sein" + assert 'tag' in tag, "tag muss in jedem tags-Dict vorhanden sein" + assert isinstance(tag['tag'], str), "tag muss ein String sein" + assert 'count' in tag, "count muss in jedem tags-Dict vorhanden sein" + assert isinstance(tag['count'], int), "count muss ein Integer sein" + assert 'sum' in tag, "sum muss in jedem tags-Dict vorhanden sein" + assert isinstance(tag['sum'], (int, float)), "sum muss ein Integer oder Float sein" + assert 'abs_in' in tag, "abs_in muss in jedem tags-Dict vorhanden sein" + assert isinstance(tag['abs_in'], (int, float)), "abs_in muss ein Integer oder Float sein" + assert 'abs_out' in tag, "abs_out muss in jedem tags-Dict vorhanden sein" + assert isinstance(tag['abs_out'], (int, float)), "abs_out muss ein Integer oder Float sein"