diff --git a/Dockerfile b/Dockerfile
index 4dc0bd0..31199e9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -21,4 +21,4 @@ RUN ln -sf /dev/stdout /var/log/apache2/access.log && \
EXPOSE 80
-ENTRYPOINT ["/app/docker/entrypoint.sh"]
+ENTRYPOINT ["apachectl", "-D", "FOREGROUND"]
diff --git a/README.md b/README.md
index f1690ce..4299d7f 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,9 @@ docker compose build
docker compose down && docker compose up -d
```
+**Ändere `AUTH_PASSWORD` in der `docker-compose.yaml` !**
+
+
### Standalone non-Docker Setup
```
@@ -36,6 +39,8 @@ pip install -r requirements.txt
.venv/bin/python3.12 app/server.py
```
+**Ändere das Login Passwort in der `app/config.py` !**
+
### Start
- Importiere Kontoumsätze über CSV Listen oder PDF Kontoauszüge deiner Bank ([unterstützte Banken](#unterstützte-banken))
diff --git a/app/config.py b/app/config.py
index 54a9ae3..74ba86c 100644
--- a/app/config.py
+++ b/app/config.py
@@ -1,12 +1,19 @@
#!/usr/bin/python3
"""App Settings zum Zeitpunkt der Initalisierung von PynanceParser"""
+import os
+
# Logging (will also log in webserver logs if used via wsgi)
LOG_ACCESS_FILE = '/tmp/pynance_access.log'
LOG_ERROR_FILE = '/tmp/pynance_error.log'
# Options:
-DATABASE_BACKEND = 'tiny' # or 'mongo'
+
+# - Login Password (overwrite to not use the system env variable)
+PASSWORD = os.getenv('AUTH_PASSWORD', 'change_this_password')
+
+# - Database Backend ('tiny' or 'mongo')
+DATABASE_BACKEND = 'tiny'
# For tiny: Path to the Folder (/path/to)
# For mongo: MongoDB URI
diff --git a/app/routes.py b/app/routes.py
index cf99dfa..81ccae9 100644
--- a/app/routes.py
+++ b/app/routes.py
@@ -4,7 +4,8 @@
import os
from datetime import datetime
from flask import request, current_app, render_template, redirect, \
- make_response, send_from_directory
+ make_response, send_from_directory, session
+import secrets
class Routes:
@@ -37,6 +38,59 @@ def version_string():
'version': current_app.config.get('VERSION', 'unknown')
}
+ @current_app.before_request
+ def require_login():
+ """
+ Before Request Handler, der sicherstellt, dass der User eingeloggt ist.
+ Falls nicht, wird dieser immer zur Login Seite umeleitet-
+ """
+ # Allow PyTest Client
+ if current_app.config.get('TESTING', False):
+ return
+
+ # Allow access to login route
+ if request.endpoint == "login":
+ return
+
+ # Allow access to CSS files
+ if request.endpoint == "static" and request.path.endswith(".css"):
+ return
+
+ # Allow access to JS files
+ if request.endpoint == "static" and request.path.endswith(".js"):
+ return
+
+ # Block everything else unless logged in
+ if not session.get("logged_in"):
+ return redirect('/login')
+
+ @current_app.route("/login", methods=["GET", "POST"])
+ def login():
+ """
+ Login Seite, die ohne gültiges Cookie immer aufgerufen wird.
+ Args (form):
+ password, str: Passwort für den Login
+ Returns:
+ html: Login Formular
+ """
+ error = None
+
+ if request.method == "POST":
+ password = request.form.get("password", "")
+ if secrets.compare_digest(password, current_app.config['PASSWORD']):
+ session["logged_in"] = True
+ return redirect('/')
+
+ error = "Invalid password"
+
+ return render_template('login.html', error=error)
+
+ @current_app.route("/logout")
+ def logout():
+ """Logout Seite, welche das Cookie löscht und zur Loin Seite weiterleitet."""
+ session.clear()
+ return redirect('/login')
+
@current_app.route('/', methods=['GET'])
def welcome() -> str:
"""
@@ -215,16 +269,6 @@ def show_stats(iban) -> str:
return render_template('stats.html', sums=sums, IBAN=iban,
filters=frontend_filters)
- @current_app.route('/logout', methods=['GET'])
- def logout():
- """
- Loggt den User aus der Session aus und leitet zur Startseite weiter.
-
- Returns:
- redirect: Weiterleitung zur Startseite
- """
- return redirect('/')
-
@current_app.route('/sw.js')
def sw():
response = make_response(
diff --git a/app/server.py b/app/server.py
index 3483387..b364551 100755
--- a/app/server.py
+++ b/app/server.py
@@ -39,6 +39,7 @@ def create_app(config_path: str) -> Flask:
template_folder=os.path.join(parent_dir, 'app', 'templates'),
static_folder=os.path.join(parent_dir, 'app', 'static')
)
+ app.secret_key = os.urandom(24).hex()
# Global Config
app.config.from_pyfile(config_path)
diff --git a/app/static/css/style.css b/app/static/css/style.css
index f429d14..5881f53 100644
--- a/app/static/css/style.css
+++ b/app/static/css/style.css
@@ -15,8 +15,11 @@ main {
flex: 1;
}
-.container.hero {
- margin-top: 10%;
+.container.hero {margin-top: 10%;}
+ main.container.hero {margin-top: 5%;}
+
+.margin-top{
+ margin-top: 2em;
}
/* Pico Tooltip Feature with newlines*/
@@ -38,6 +41,10 @@ main {
@media (max-width: 1023px) {.hide-m {display:none !important;}}
/* Color classes */
+.error {
+ color: var(--pico-color-red-600);
+ font-weight: bold;
+}
.delete {
color: white;
border-color: var(--pico-color-red-600);
diff --git a/app/templates/iban.html b/app/templates/iban.html
index 6dc3c89..fdac2fd 100644
--- a/app/templates/iban.html
+++ b/app/templates/iban.html
@@ -26,7 +26,7 @@
- 🚪 Logout
+ 📚 Konten
diff --git a/app/templates/index.html b/app/templates/index.html
index 413b17e..b885618 100644
--- a/app/templates/index.html
+++ b/app/templates/index.html
@@ -5,7 +5,7 @@
@@ -55,6 +55,7 @@ Pynance Parser
Das Konto wird bei einem Import automatisch erstellt.
+ Logout
diff --git a/app/templates/login.html b/app/templates/login.html
new file mode 100644
index 0000000..f868953
--- /dev/null
+++ b/app/templates/login.html
@@ -0,0 +1,37 @@
+{% extends 'layout.html' %}
+
+{% block content %}
+
+
+
+
+
+
+
+ {% if error %}
+
+ {% endif %}
+
+
+
+
+{% endblock %}
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 6fac8ef..510e43c 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -7,7 +7,6 @@ services:
volumes:
- ./settings:/app/settings
environment:
- - AUTH_USER=username
- AUTH_PASSWORD=yourpasswordhere
mongo:
diff --git a/docker/apache2.conf b/docker/apache2.conf
index c425473..031933a 100644
--- a/docker/apache2.conf
+++ b/docker/apache2.conf
@@ -6,10 +6,9 @@
WSGIScriptAlias / /app/app/server.py
- AuthType Basic
- AuthName "PynanceParser Basic Auth"
- AuthUserFile /etc/apache2/.htpasswd
- Require valid-user
+ Options Indexes FollowSymLinks
+ AllowOverride None
+ Require all granted
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
deleted file mode 100755
index 7ceed2e..0000000
--- a/docker/entrypoint.sh
+++ /dev/null
@@ -1,7 +0,0 @@
-#!/bin/bash
-
-# Generate the .htpasswd file
-htpasswd -cb /etc/apache2/.htpasswd "$AUTH_USER" "$AUTH_PASSWORD"
-
-# Start Apache in the foreground
-exec apachectl -D FOREGROUND
diff --git a/reader/Comdirect.py b/reader/Comdirect.py
index 7811ce6..26ab0f6 100644
--- a/reader/Comdirect.py
+++ b/reader/Comdirect.py
@@ -42,12 +42,8 @@ def from_csv(self, filepath):
continue
amount = float(row['Umsatz in EUR'].replace(',', '.'))
- date_tx = datetime.datetime.strptime(
- date_tx, date_format
- ).replace(tzinfo=datetime.timezone.utc).timestamp()
- valuta = datetime.datetime.strptime(
- row['Wertstellung (Valuta)'], date_format
- ).replace(tzinfo=datetime.timezone.utc).timestamp()
+ date_tx = self._parse_from_strftime(date_tx, date_format)
+ valuta = self._parse_from_strftime(row['Wertstellung (Valuta)'], date_format)
text_tx = row['Buchungstext']
match = rx.match(text_tx)
@@ -86,8 +82,9 @@ def from_pdf(self, filepath):
filepath,
pages="2-end",
flavor="stream",
- row_tol=10,
- columns=["115,187,305,500"]*16
+ row_tol=12,
+ columns=["115,187,305,500"],
+ table_areas=["0,830,590,0"]
)
#TODO: Hack-araound: https://github.com/atlanhq/camelot/issues/357#issuecomment-520986016
@@ -122,6 +119,7 @@ def from_pdf(self, filepath):
for i, row in enumerated_table:
if re_datecheck.match(row[0]) is None:
+ print("skip row", row)
continue # Skip Header and unvalid Rows
amount = float(row[4].replace('.', '').replace(',', '.'))
diff --git a/reader/Commerzbank.py b/reader/Commerzbank.py
index 769e811..72a1e2b 100644
--- a/reader/Commerzbank.py
+++ b/reader/Commerzbank.py
@@ -107,24 +107,29 @@ def from_pdf(self, filepath):
date_tx = 0
date_tx_year = "1970" # Default Year if not found yet
enumerated_table = enumerate(self.all_rows[start_index:end_index])
+
for i, row in enumerated_table:
if row[0].startswith('Buchungsdatum: '):
# All following rows have this 'date_tx'
date_tx_year = row[0][-4:]
- date_tx = datetime.datetime.strptime(
+ date_tx = self._parse_from_strftime(
row[0][-10:], "%d.%m.%Y"
- ).replace(tzinfo=datetime.timezone.utc).timestamp()
+ )
continue # Skip Header Rows
+ if len(row[1]) < 5:
+ # No valid date in this row, skip
+ # This is needed because the bank itself does not honor
+ # their own layout and breaking boundaries to neighbour values.
+ continue
+
# negativer Betrag in Spalte "Lasten" oder positiv "zu Gunsten"
amount = f"-{row[2][:-1]}" if row[2] else row[3]
line = {
'date_tx': date_tx,
- 'valuta': datetime.datetime.strptime(
- f"{row[1]}.{date_tx_year}", "%d.%m.%Y"
- ).replace(tzinfo=datetime.timezone.utc).timestamp(),
+ 'valuta': self._parse_from_strftime(f"{row[1]}.{date_tx_year}", "%d.%m.%Y"),
'art': "",
'text_tx': row[0],
'amount': float(amount.replace('.', '').replace(',', '.')),
diff --git a/reader/Generic.py b/reader/Generic.py
index 8b3603b..9b05502 100644
--- a/reader/Generic.py
+++ b/reader/Generic.py
@@ -3,6 +3,7 @@
import datetime
import csv
+import re
class Reader:
@@ -95,3 +96,43 @@ def from_http(self, url):
ausgelesenen Kontoumsätzen entspricht.
"""
raise NotImplementedError()
+
+ def _parse_from_strftime(self, date_string, date_format):
+ """
+ Hilfsmethode um ein Datum aus einem String mit einem Format in einen UTC-Timestamp
+ umzuwandeln.
+
+ Args:
+ date_string (str): Datum als String
+ date_format (str): Formatstring wie von `datetime.strptime` verwendet
+
+ Returns:
+ int: UTC-Timestamp des übergebenen Datums
+ """
+ try:
+ return datetime.datetime.strptime(
+ date_string, date_format
+ ).replace(tzinfo=datetime.timezone.utc).timestamp()
+
+ except ValueError as e:
+ if "day is out of range for month" in str(e):
+ # Handle invalid dates like 31.11.2023 -> 30.11.2023
+ split_char = re.search(r'[^\d]', date_string)
+ if not split_char:
+ raise e # No valid split character found
+
+ # Replace just the day part
+ split_char = split_char.group(0)
+ day_index = date_format.split(split_char).index('%d')
+ if day_index == -1:
+ raise e # No day part found in format
+
+ date_string_list = date_string.split(split_char)
+ date_string_list[day_index] = int(date_string_list[day_index]) - 1
+ date_string = split_char.join(map(str, date_string_list))
+
+ return self._parse_from_strftime(
+ date_string, date_format
+ )
+
+ raise e # Re-raise other ValueErrors
diff --git a/reader/Volksbank_Mittelhessen.py b/reader/Volksbank_Mittelhessen.py
index d95c186..2964c50 100644
--- a/reader/Volksbank_Mittelhessen.py
+++ b/reader/Volksbank_Mittelhessen.py
@@ -82,12 +82,8 @@ def from_pdf(self, filepath):
pages="all", # End -1
flavor="stream",
table_areas=["60,629,573,51"],
- columns=["75,112,440,526"],
- split_text=True,
- #layout_kwargs={ # übernommen von Commerzbank, da ähnliches Layout
- # "char_margin": 2,
- # "word_margin": 0.5,
- #},
+ columns=["75,115,455"],
+ split_text=True
)
# Tabellen aller Seiten zusammenfügen
@@ -107,11 +103,11 @@ def from_pdf(self, filepath):
if row[2].replace(' ', '').lower().startswith('alterkontostand'):
# Last row before transactions
start_index = self.all_rows.index(row) + 1
- date_tx_year = row[2][-4:] # Jahr für die Transaktionen merken
if row[2].replace(' ', '').lower().startswith('neuerkontostand'):
# First row after transactions + final line
end_index = self.all_rows.index(row) - 1
+ date_tx_year = row[2][-4:] # Jahr für die Transaktionen merken
break
if date_tx_year is None or re.match(r'^\d{4}$', date_tx_year) is None:
@@ -129,16 +125,13 @@ def from_pdf(self, filepath):
continue # Skip Header and unvalid Rows
# Positives 'Haben' oder negatives 'Soll'
- amount = f'-{row[3]}' if row[3] else row[4]
- amount = amount[:-2].replace('.', '').replace(',', '.')
+ amount_prefix = '' if row[3][-1] == 'H' else '-'
+ amount = f"{amount_prefix}{re.sub(r'H|S', '', row[3]).strip()}"
+ amount = amount.replace('.', '').replace(',', '.')
line = {
- 'date_tx': datetime.datetime.strptime(
- f"{row[0]}{date_tx_year}", "%d.%m.%Y"
- ).replace(tzinfo=datetime.timezone.utc).timestamp(),
- 'valuta': datetime.datetime.strptime(
- f"{row[1]}{date_tx_year}", "%d.%m.%Y"
- ).replace(tzinfo=datetime.timezone.utc).timestamp(),
+ 'date_tx': self._parse_from_strftime(f"{row[0]}{date_tx_year}", "%d.%m.%Y"),
+ 'valuta': self._parse_from_strftime(f"{row[1]}{date_tx_year}", "%d.%m.%Y"),
'art': row[2],
'text_tx': "",
'amount': float(amount),
diff --git a/tests/config.py b/tests/config.py
index 43a980c..8e9a37a 100644
--- a/tests/config.py
+++ b/tests/config.py
@@ -1,13 +1,19 @@
#!/usr/bin/python3
"""App Settings zum Zeitpunkt der Initalisierung von PynanceParser (Testinstanz)"""
+import os
LOG_ACCESS_FILE = '/tmp/pynance_access.log'
LOG_ERROR_FILE = '/tmp/pynance_error.log'
# Options:
-DATABASE_BACKEND = 'tiny'
+
+# - Login Password (overwrite to not use the system env variable)
+PASSWORD = os.getenv('AUTH_PASSWORD', 'change_this_password')
+
+# - Database Backend ('tiny' or 'mongo')
#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/)
diff --git a/tests/conftest.py b/tests/conftest.py
index 361bb1d..90822ae 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -29,6 +29,7 @@ def test_app():
# App Context
app = create_app(config_path)
+ app.config['TESTING'] = True
with app.app_context():
yield app
diff --git a/tests/test_unit_reader_Comdirect.py b/tests/test_unit_reader_Comdirect.py
index 5e19257..0cb157d 100644
--- a/tests/test_unit_reader_Comdirect.py
+++ b/tests/test_unit_reader_Comdirect.py
@@ -27,15 +27,28 @@ def test_read_from_csv(test_app):
check_transaktion_list(transaction_list)
-def test_read_from_pdf(test_app):
+# Look for test files and create a tuple list
+test_folder = os.path.join('/tmp', 'Comdirect')
+test_files = []
+if not os.path.isdir(test_folder):
+ test_files = [()]
+else:
+ for file in os.listdir(test_folder):
+ test_files.append(
+ (os.path.join(test_folder, file))
+ )
+
+# Using every test file in its own test
+@pytest.mark.parametrize("full_path", test_files)
+def test_read_from_pdf(test_app, full_path):
"""Testet das Einlesen einer PDF Datei mit Kontoumsätzen"""
- test_file_pdf = os.path.join('/tmp', 'comdirect.pdf')
- if not os.path.isfile(test_file_pdf):
- # Test file not provided (sensitive data is not part of git repo)
- pytest.skip("Testfile /tmp/comdirect.pdf not found....skipping")
+ if not full_path:
+ # Test files not provided (sensitive data is not part of git repo)
+ pytest.skip("Testfile not provided....skipping")
with test_app.app_context():
- transaction_list = Comdirect().from_pdf(test_file_pdf)
+ transaction_list = Comdirect().from_pdf(full_path)
+ assert transaction_list, "No transactions found in PDF file"
# Check Reader Ergebnisse
check_transaktion_list(transaction_list)
diff --git a/tests/test_unit_reader_Commerzbank.py b/tests/test_unit_reader_Commerzbank.py
index afc369f..6663496 100644
--- a/tests/test_unit_reader_Commerzbank.py
+++ b/tests/test_unit_reader_Commerzbank.py
@@ -26,15 +26,28 @@ def test_read_from_csv(test_app):
check_transaktion_list(transaction_list)
-def test_read_from_pdf(test_app):
+# Look for test files and create a tuple list
+test_folder = os.path.join('/tmp', 'Commerzbank')
+test_files = []
+if not os.path.isdir(test_folder):
+ test_files = [()]
+else:
+ for file in os.listdir(test_folder):
+ test_files.append(
+ (os.path.join(test_folder, file))
+ )
+
+# Using every test file in its own test
+@pytest.mark.parametrize("full_path", test_files)
+def test_read_from_pdf(test_app, full_path):
"""Testet das Einlesen einer PDF Datei mit Kontoumsätzen"""
- test_file_pdf = os.path.join('/tmp', 'commerzbank.pdf')
- if not os.path.isfile(test_file_pdf):
- # Test file not provided (sensitive data is not part of git repo)
- pytest.skip("Testfile /tmp/commerzbank.pdf not found....skipping")
+ if not full_path:
+ # Test files not provided (sensitive data is not part of git repo)
+ pytest.skip("Testfile not provided....skipping")
with test_app.app_context():
- transaction_list = Commerzbank().from_pdf(test_file_pdf)
+ transaction_list = Commerzbank().from_pdf(full_path)
+ assert transaction_list, "No transactions found in PDF file"
# Check Reader Ergebnisse
check_transaktion_list(transaction_list)
diff --git a/tests/test_unit_reader_Generic.py b/tests/test_unit_reader_Generic.py
index 9b036c0..36ee396 100644
--- a/tests/test_unit_reader_Generic.py
+++ b/tests/test_unit_reader_Generic.py
@@ -36,3 +36,29 @@ def test_read_from_pdf():
def test_read_from_http():
"""Testet das Einlesen Kontoumsätzen aus einer Online-Quelle"""
return None
+
+def test_strftime_parser(test_app):
+ """Testet den strftime Parser des Generic Readers"""
+ with test_app.app_context():
+ reader = Generic()
+
+ # Teste verschiedene Datumsformate
+ date_formats = [
+ ("%d.%m.%Y", "25.12.2023", 1703462400.0),
+ ("%Y-%m-%d", "2023-12-25", 1703462400.0),
+ ("%m/%d/%Y", "12/25/2023", 1703462400.0),
+ ]
+
+ for fmt, date_str, expected_timestamp in date_formats:
+ ts = reader._parse_from_strftime(date_str, fmt) # pylint: disable=protected-access
+ assert ts == expected_timestamp, f"Failed for format {fmt}"
+
+ # Teste automatische Korrektur (31.11. / 30.02. etc.)
+ date_formats = [
+ ("%d.%m.%Y", "31.11.2023", 1701302400.0), # Korrigiert zu 30.11.2023
+ ("%d.%m.%Y", "30.02.2024", 1709164800.0), # Korrigiert zu 28.02.2024
+ ]
+
+ for fmt, date_str, expected_timestamp in date_formats:
+ ts = reader._parse_from_strftime(date_str, fmt) # pylint: disable=protected-access
+ assert ts == expected_timestamp, f"Failed for format {fmt}"
diff --git a/tests/test_unit_reader_Volksbank-Mittelhessen.py b/tests/test_unit_reader_Volksbank-Mittelhessen.py
index dbf293a..c06e952 100644
--- a/tests/test_unit_reader_Volksbank-Mittelhessen.py
+++ b/tests/test_unit_reader_Volksbank-Mittelhessen.py
@@ -27,15 +27,28 @@ def test_read_from_csv(test_app):
check_transaktion_list(transaction_list)
-def test_read_from_pdf(test_app):
+# Look for test files and create a tuple list
+test_folder = os.path.join('/tmp', 'Volksbank_Mittelhessen')
+test_files = []
+if not os.path.isdir(test_folder):
+ test_files = [()]
+else:
+ for file in os.listdir(test_folder):
+ test_files.append(
+ (os.path.join(test_folder, file))
+ )
+
+# Using every test file in its own test
+@pytest.mark.parametrize("full_path", test_files)
+def test_read_from_pdf(test_app, full_path):
"""Testet das Einlesen einer PDF Datei mit Kontoumsätzen"""
- test_file_pdf = os.path.join('/tmp', 'volksbank-mittelhessen.pdf')
- if not os.path.isfile(test_file_pdf):
- # Test file not provided (sensitive data is not part of git repo)
- pytest.skip("Testfile /tmp/volksbank-mittelhessen.pdf not found....skipping")
+ if not full_path:
+ # Test files not provided (sensitive data is not part of git repo)
+ pytest.skip("Testfile not provided....skipping")
with test_app.app_context():
- transaction_list = Volksbank_Mittelhessen().from_pdf(test_file_pdf)
+ transaction_list = Volksbank_Mittelhessen().from_pdf(full_path)
+ assert transaction_list, "No transactions found in PDF file"
# Check Reader Ergebnisse
check_transaktion_list(transaction_list)