diff --git a/app/routes.py b/app/routes.py index d21d393e..d1e11e07 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/server.py b/app/server.py index 348de9b4..7f3bcae3 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/app/static/css/grid.css b/app/static/css/grid.css index c1697534..8738de78 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, diff --git a/app/static/js/index.js b/app/static/js/index.js index 2687e5a2..d6dba069 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 e77dd8e6..fe8b88e0 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. +

+

+ +

+ +
diff --git a/handler/MongoDb.py b/handler/MongoDb.py index 11485412..56813dd7 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/conftest.py b/tests/conftest.py index 90822aeb..7593fe71 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 d2b71131..00000000 --- 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 6792deeb..9d450805 100644 --- a/tests/test_integ_basics.py +++ b/tests/test_integ_basics.py @@ -732,60 +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" - - # 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" - - # 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 00000000..965c630e --- /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" +