diff --git a/app/routes.py b/app/routes.py index d21d393e..6a16b658 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 @@ -256,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') @@ -498,8 +473,41 @@ 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): """ @@ -852,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/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/functions.js b/app/static/js/functions.js index 5e2ddce2..fd0bba59 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/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/app/templates/macros.html b/app/templates/macros.html index f6e9fdb6..00b7b06c 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 e6d7d816..fe5d4594 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 4b7f078a..11734559 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 @@ -559,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/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/settings/rule/00-default-categories.json b/settings/rule/00-default-categories.json index 86efd0bd..e831af50 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/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/input_generic2.csv b/tests/input_generic2.csv index 691e3254..b6f25042 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/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" + diff --git a/tests/test_integ_more_routes.py b/tests/test_integ_more_routes.py index 5049fa94..533e9367 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 74b78385..00000000 --- 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 00000000..f8fb818b --- /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" diff --git a/tests/test_unit_sorting.py b/tests/test_unit_sorting.py index b2e9a3c4..4bd26576 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."