Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand All @@ -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))
Expand Down
9 changes: 8 additions & 1 deletion app/config.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
66 changes: 55 additions & 11 deletions app/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions app/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 9 additions & 2 deletions app/static/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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*/
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion app/templates/iban.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ <h3>
&#128202; <a href="/{{IBAN}}/stats?{{ request.query_string.decode('utf-8')|safe }}" title="Statistiken">Statistik</a>
</li>
<li>
&#128682; <a href="/logout" title="Logout">Logout</a>
&#128218; <a href="/" title="Konten">Konten</a>
</li>
</ul>
</nav>
Expand Down
3 changes: 2 additions & 1 deletion app/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<header>
<hgroup class="container hero">
<h1>Pynance Parser</h1>
<p>Manage Bankaccounts like a Boss !</p>
<p>Manage Bankaccounts like a Boss !</p>
</hgroup>
</header>

Expand Down Expand Up @@ -55,6 +55,7 @@ <h1>Pynance Parser</h1>

<section>
<p>Das Konto wird bei einem Import automatisch erstellt.</p>
<p><a href="/logout" role="button" class="secondary margin-top">Logout</a></p>
</section>

</main>
Expand Down
37 changes: 37 additions & 0 deletions app/templates/login.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{% extends 'layout.html' %}

{% block content %}

<header>
<hgroup class="container hero">
<h1>Pynance Parser</h1>
<p>Manage Bankaccounts like a Boss !</p>
</hgroup>
</header>

<main class="container hero">

<section class="grid m-6-6 margin">

<form method="post" action="/login">
<div role="group">
<input type="password" name="password" value=""
placeholder="Passwort"
{% if error %}aria-invalid="true"{%endif%}
>
<input type="submit" value="🔒" role="button">
</div>
</form>

</section>

{% if error %}
<section>
<p class="error">{{ error }}</p>
</section>
{% endif %}

</main>


{% endblock %}
1 change: 0 additions & 1 deletion docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ services:
volumes:
- ./settings:/app/settings
environment:
- AUTH_USER=username
- AUTH_PASSWORD=yourpasswordhere

mongo:
Expand Down
7 changes: 3 additions & 4 deletions docker/apache2.conf
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@
WSGIScriptAlias / /app/app/server.py

<Directory /app>
AuthType Basic
AuthName "PynanceParser Basic Auth"
AuthUserFile /etc/apache2/.htpasswd
Require valid-user
Options Indexes FollowSymLinks
AllowOverride None
Require all granted
</Directory>

</VirtualHost>
7 changes: 0 additions & 7 deletions docker/entrypoint.sh

This file was deleted.

14 changes: 6 additions & 8 deletions reader/Comdirect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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(',', '.'))
Expand Down
15 changes: 10 additions & 5 deletions reader/Commerzbank.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(',', '.')),
Expand Down
41 changes: 41 additions & 0 deletions reader/Generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import datetime
import csv
import re


class Reader:
Expand Down Expand Up @@ -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
Loading