major: upload and storage of recipes

PDF-Recipes are uploadable and some basic functions are already
implemented.
Also minor bugfixes.
This commit is contained in:
Lunaresk 2022-08-23 23:10:18 +02:00
parent b997e25a08
commit 764738b20d
20 changed files with 221 additions and 50 deletions

View File

@ -45,11 +45,13 @@ def create_app(config_class=Config):
from app.auth import bp as auth_bp from app.auth import bp as auth_bp
app.register_blueprint(auth_bp, url_prefix='/auth') app.register_blueprint(auth_bp, url_prefix='/auth')
from app.errors import bp as errors_bp from app.errors import bp as errors_bp
app.register_blueprint(errors_bp) app.register_blueprint(errors_bp, url_prefix='/error')
from app.main import bp as main_bp from app.main import bp as main_bp
app.register_blueprint(main_bp) app.register_blueprint(main_bp)
from app.api import bp as api_bp from app.api import bp as api_bp
app.register_blueprint(api_bp, url_prefix="/api") app.register_blueprint(api_bp, url_prefix="/api")
from app.receipts import bp as receipts_bp
app.register_blueprint(receipts_bp, url_prefix='/receipts')
return app return app

View File

@ -1,6 +1,6 @@
from app.models import User from app.models import User
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField from wtforms import BooleanField, PasswordField, StringField, SubmitField
from wtforms.validators import DataRequired, Email, EqualTo, ValidationError from wtforms.validators import DataRequired, Email, EqualTo, ValidationError
class LoginForm(FlaskForm): class LoginForm(FlaskForm):

View File

@ -8,7 +8,7 @@ from flask import flash, redirect, request, url_for
from flask_login import current_user, login_user, logout_user from flask_login import current_user, login_user, logout_user
from werkzeug.urls import url_parse from werkzeug.urls import url_parse
@bp.route(f'/register', methods=['GET', 'POST']) @bp.route('/register', methods=['GET', 'POST'])
def web_register(): def web_register():
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for('main.index')) return redirect(url_for('main.index'))
@ -22,7 +22,7 @@ def web_register():
return redirect(url_for('auth.web_login')) return redirect(url_for('auth.web_login'))
return render_template('auth/register.html', title='Register', form=form) return render_template('auth/register.html', title='Register', form=form)
@bp.route(f'/login', methods=['GET', 'POST']) @bp.route('/login', methods=['GET', 'POST'])
def web_login(): def web_login():
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for('main.index')) return redirect(url_for('main.index'))
@ -39,7 +39,7 @@ def web_login():
return redirect(next_page) return redirect(next_page)
return render_template('auth/login.html', title='Sign In', form=form) return render_template('auth/login.html', title='Sign In', form=form)
@bp.route(f'/logout') @bp.route('/logout')
def web_logout(): def web_logout():
logout_user() logout_user()
return redirect(url_for('main.index')) return redirect(url_for('main.index'))

View File

@ -2,6 +2,10 @@ from app import db
from app.errors import bp from app.errors import bp
from flask import render_template from flask import render_template
@bp.app_errorhandler(403)
def not_allowed_error(error):
return render_template('errors/403.html'), 403
@bp.app_errorhandler(404) @bp.app_errorhandler(404)
def not_found_error(error): def not_found_error(error):
return render_template('errors/404.html'), 404 return render_template('errors/404.html'), 404

View File

@ -12,7 +12,7 @@ class NewItemForm(FlaskForm):
amount_change = IntegerField("Amount", validators=[Optional()]) amount_change = IntegerField("Amount", validators=[Optional()])
category = SelectMultipleField("Categories", choices=[], validators=[Optional()]) category = SelectMultipleField("Categories", choices=[], validators=[Optional()])
brand = SelectField("Brand", choices=[], validators=[DataRequired()]) brand = SelectField("Brand", choices=[], validators=[DataRequired()])
submit = SubmitField("Submit") submit = SubmitField("Submit", render_kw={"class": "btn btn-primary mt-3"})
@classmethod @classmethod
def new(cls): def new(cls):

View File

@ -52,9 +52,12 @@ def get_report_from_user():
def token_authorization(): def token_authorization():
LOGGER.debug("Token Login") LOGGER.debug("Token Login")
if not request.json or 'login' not in request.json: if not request.json or 'login' not in request.json:
LOGGER.debug("JSON not delivered or 'login' not in JSON")
abort(400) abort(400)
if not LoginToken.query.filter_by(token=request.json['login']).first(): if not LoginToken.query.filter_by(token=request.json['login']).first():
LOGGER.debug(f"Token <{request.json['login']}> not recognized")
abort(403) abort(403)
LOGGER.debug("Token accepted")
return jsonify({}), 200 return jsonify({}), 200
@bp.route('/token_insert', methods=['POST']) @bp.route('/token_insert', methods=['POST'])
@ -94,7 +97,7 @@ def new_item():
new_item.AmountChange = [AmountChange(Item = new_item, date = date(2021, 12, 1), amount = form.amount_change.data)] new_item.AmountChange = [AmountChange(Item = new_item, date = date(2021, 12, 1), amount = form.amount_change.data)]
db.session.add(new_item) db.session.add(new_item)
db.session.commit() db.session.commit()
return redirect(url_for('index')) return redirect(url_for('main.index'))
return render_template('main/new_item.html', form=form) return render_template('main/new_item.html', form=form)
@bp.route('/overview/register_boughts', methods=['GET']) @bp.route('/overview/register_boughts', methods=['GET'])

View File

@ -58,9 +58,11 @@ class Establishment(db.Model):
class LoginToken(db.Model): class LoginToken(db.Model):
user = db.Column(db.ForeignKey('user.id'), primary_key=True, server_onupdate=db.FetchedValue()) user = db.Column(db.ForeignKey('user.id'), primary_key=True, server_onupdate=db.FetchedValue())
establishment = db.Column(db.ForeignKey('establishment.id'), primary_key=True, server_onupdate=db.FetchedValue()) establishment = db.Column(db.ForeignKey('establishment.id'), primary_key=True, server_onupdate=db.FetchedValue())
token = db.Column(db.String(15), nullable=True, unique=True) token = db.Column(db.String(15), nullable=False, unique=True)
paid = db.Column(db.BigInteger, nullable=False, server_default=str(0)) paid = db.Column(db.BigInteger, nullable=False, server_default=str(0))
Receipt = db.relationship("Receipt", backref='LoginToken', lazy='dynamic')
def __repr__(self) -> str: def __repr__(self) -> str:
return f"LoginToken {self.token}" return f"LoginToken {self.token}"
@ -125,6 +127,8 @@ class Receipt(db.Model):
date = db.Column(db.Date, nullable=False) date = db.Column(db.Date, nullable=False)
from_user = db.Column(db.ForeignKey("login_token.token"), server_onupdate=db.FetchedValue()) from_user = db.Column(db.ForeignKey("login_token.token"), server_onupdate=db.FetchedValue())
registered = db.Column(db.Boolean, nullable=False, server_default=str(False)) registered = db.Column(db.Boolean, nullable=False, server_default=str(False))
ItemReceipt = db.relationship("ItemReceipt", backref='Receipt', lazy='dynamic')
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<Receipt {self.id}>" return f"<Receipt {self.id}>"

5
app/receipts/__init__.py Normal file
View File

@ -0,0 +1,5 @@
from flask import Blueprint
bp = Blueprint('receipts', __name__)
from app.receipts import forms, routes

16
app/receipts/forms.py Normal file
View File

@ -0,0 +1,16 @@
from flask_wtf import FlaskForm
from flask_wtf.file import FileAllowed, FileField, FileRequired
from wtforms import BooleanField, SelectMultipleField, SubmitField, widgets
from wtforms.validators import Optional
class MultiCheckboxField(SelectMultipleField):
widget = widgets.ListWidget(prefix_label=False)
option_widget = widgets.CheckboxInput()
class UploadReceiptForm(FlaskForm):
pdfReceipt = FileField("PDF", validators=[FileRequired(), FileAllowed(["pdf"], "Invalid Format, must be .pdf")])
submit = SubmitField("Submit", render_kw={"class": "btn btn-primary mt-3"})
class CheckItemsForm(FlaskForm):
items = MultiCheckboxField("Items")
submit = SubmitField("Submit", render_kw={"class": "btn btn-primary mt-3"})

61
app/receipts/routes.py Normal file
View File

@ -0,0 +1,61 @@
from app import db, LOGGER
from app.receipts import bp
from app.receipts.forms import CheckItemsForm, UploadReceiptForm
from app.models import Receipt, LoginToken
from app.utils.routes_utils import render_custom_template as render_template
from flask import abort, request, url_for
from flask_login import current_user, login_required
from app.utils.pdf_receipt_parser import PDFReceipt
PDFDir = "./"
@bp.route('/upload_receipt', methods=['GET', 'POST'])
@login_required
def upload_receipt():
"""Upload of a receipt."""
if current_user.is_anonymous:
abort(403)
if "establishment" in request.args:
if LoginToken.query.filter_by(establishment=request.args['establishment'], user=current_user.id).first():
form = UploadReceiptForm()
LOGGER.debug(form.pdfReceipt.data)
if form.is_submitted():
LOGGER.debug("submitted")
if form.validate():
LOGGER.debug("valid")
else:
LOGGER.debug(form.errors)
if form.validate_on_submit():
receipt = PDFReceipt(form.pdfReceipt.data)
dbReceipt = Receipt(id = receipt.id, date = receipt.date,
from_user = LoginToken.query.filter_by(establishment=request.args['establishment'], user=current_user.id).first().token)
form.pdfReceipt.data.save(PDFDir + f"{str(receipt.date)}_{receipt.id}.pdf")
db.session.add(dbReceipt)
db.session.commit()
return receipt.text.replace("\n", "<br>")
return render_template("receipts/upload.html", form = form)
abort(403)
@bp.route('/confirm_receipt', methods=['GET', 'POST'])
@login_required
def confirm_receipt_items():
"""Check items from a receipt if they should be accounted for payment."""
if "receipt" in request.args:
receipt_details = Receipt.query.get(request.args['receipt'])
if current_user.is_anonymous and current_user.id == receipt_details.LoginToken.Establishment.owner:
receipt = PDFReceipt._getPDFReceiptFromFile(PDFDir + f"{receipt.date}_{receipt.id}.pdf")
form = CheckItemsForm()
# TODO: Precheck if items are already in database. If yes, check if item is present only once or multiple
# times and provide dropdown menu if necessary. If not, provide input field.
temp_choices = []
for item in receipt.items:
match item:
case {"itemname": itemname, "price": price}:
temp_choices.append((itemname.replace(" ", "_"), f"{itemname, price}"))
case {"itemname": itemname, "price": price, "amount": amount}:
temp_choices.append((itemname.replace(" ", "_"), f"{itemname}, {price} * {amount}"))
form.choices = temp_choices
if form.validate_on_submit():
pass # TODO
return render_template("receipts/confirm_items.html")
abort(403)

View File

@ -5,16 +5,10 @@
<h1>Sign In</h1> <h1>Sign In</h1>
<form action="" method="post" novalidate> <form action="" method="post" novalidate>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<p> {{ wtf.form_field(form.email, class=form.email.render_kw["class"] or "form-control mb-3") }}
{{ wtf.form_field(form.email) }} {{ wtf.form_field(form.password, class=form.password.render_kw["class"] or "form-control mb-3") }}
</p> {{ wtf.form_field(form.remember_me, class=form.remember_me.render_kw["class"] or "mb-3") }}
<p> {{ wtf.form_field(form.submit, class=form.submit.render_kw["class"] or "btn btn-primary mt-3") }}
{{ wtf.form_field(form.password) }}
</p>
<p>
{{ wtf.form_field(form.remember_me) }}
</p>
{{ wtf.form_field(form.submit, class=form.submit.render_kw["class"]) }}
</form> </form>
<p> <p>
Forgot Your Password? Forgot Your Password?

View File

@ -5,15 +5,9 @@
<h1>Register</h1> <h1>Register</h1>
<form action="" method="post"> <form action="" method="post">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<p> {{ wtf.form_field(form.email, class=form.email.render_kw["class"] or "form-control mb-3") }}
{{ wtf.form_field(form.email) }} {{ wtf.form_field(form.password, class=form.password.render_kw["class"] or "form-control mb-3") }}
</p> {{ wtf.form_field(form.password2, class=form.password2.render_kw["class"] or "form-control mb-3") }}
<p> {{ wtf.form_field(form.submit, class=form.submit.render_kw["class"] or "btn btn-primary mt-3") }}
{{ wtf.form_field(form.password) }}
</p>
<p>
{{ wtf.form_field(form.password2) }}
</p>
{{ wtf.form_field(form.submit, class=form.submit.render_kw["class"]) }}
</form> </form>
{% endblock %} {% endblock %}

View File

@ -45,9 +45,18 @@
<div class="collapse" id="dashboard-collapse"> <div class="collapse" id="dashboard-collapse">
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small"> <ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small">
{% if establishments %} {% if establishments %}
<li><a href="#" class="link-dark rounded">Allgemein</a></li> <!-- <li><a href="#" class="link-dark rounded">Allgemein</a></li> -->
{% for establishment in establishments %} {% for establishment in establishments %}
<li><a href="{{ url_for('main.get_report_from_user', establishment=establishment.id) }}" class="link-dark rounded">{{ establishment.name }}</a></li> <li>
<button class="btn btn-toggle align-items-center rounded collapsed" data-bs-toggle="collapse" data-bs-target="#est_{{ establishment.id }}" aria-expanded="false">
{{ establishment.name }}
</button>
<div class="collapse" id="est_{{ establishment.id }}">
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small">
<li><a href="{{ url_for('main.get_report_from_user', establishment=establishment.id) }}" class="link-dark rounded">Übersicht</a></li>
</ul>
</div>
</li>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</ul> </ul>

View File

@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block app_content %}
<h1>You are not allowed to do this!</h1>
<h4>how dare you</h4>
<p><a href="{{ url_for('main.index') }}">Back</a></p>
{% endblock %}

View File

@ -1,24 +1,17 @@
{% extends "base.html" %} {% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block app_content %} {% block app_content %}
<form action="" method="post"> <form action="" method="post">
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
<div class="row">{{form.id.label}}</div> {{ wtf.form_field(form.id, class=form.id.render_kw["class"] or "form-control mb-3") }}
<div class="row">{{form.id()}}</div> {{ wtf.form_field(form.name, class=form.name.render_kw["class"] or "form-control mb-3") }}
<div class="row">{{form.name.label}}</div> {{ wtf.form_field(form.description, class=form.description.render_kw["class"] or "form-control mb-3") }}
<div class="row">{{form.name()}}</div> {{ wtf.form_field(form.date, class=form.date.render_kw["class"] or "form-control mb-3") }}
<div class="row">{{form.description.label}}</div> {{ wtf.form_field(form.price_change, class=form.price_change.render_kw["class"] or "form-control mb-3") }}
<div class="row">{{form.description()}}</div> {{ wtf.form_field(form.amount_change, class=form.amount_change.render_kw["class"] or "form-control mb-3") }}
<div class="row">{{form.date.label}}</div> {{ wtf.form_field(form.category, class=form.category.render_kw["class"] or "form-control mb-3") }}
<div class="row">{{form.date()}}</div> {{ wtf.form_field(form.brand, class=form.brand.render_kw["class"] or "form-control mb-3") }}
<div class="row">{{form.price_change.label}}</div> {{ wtf.form_field(form.submit, class=form.submit.render_kw["class"] or "btn btn-primary mt-3") }}
<div class="row">{{form.price_change()}}</div>
<div class="row">{{form.amount_change.label}}</div>
<div class="row">{{form.amount_change()}}</div>
<div class="row">{{form.category.label}}</div>
<div class="row">{{form.category()}}</div>
<div class="row">{{form.brand.label}}</div>
<div class="row">{{form.brand()}}</div>
<div class="row">{{form.submit()}}</div>
</form> </form>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,9 @@
{% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block app_content %}
<form action="" method="post" novalidate enctype="multipart/form-data">
{{ form.hidden_tag() }}
{{ wtf.form_field(form.submit, class=form.submit.render_kw["class"] or "btn btn-primary mt-3") }}
</form>
{% endblock %}

View File

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block app_content %}
<form action="" method="post" novalidate enctype="multipart/form-data">
{{ form.hidden_tag() }}
{{ wtf.form_field(form.pdfReceipt, class=form.pdfReceipt.render_kw["class"] or "form-control") }}
{{ wtf.form_field(form.submit, class=form.submit.render_kw["class"] or "btn btn-primary mt-3") }}
</form>
{% endblock %}

View File

@ -0,0 +1,54 @@
from datetime import datetime
import fitz
from re import search
class PDFReceipt:
"""Class to use a PDF-Receipt as an object.
Arguments:
strPDFFile -- The path to the PDF-File as a string.
parser -- A keyword in lowercase to tell how the receipt is formated.
Currently supported: 'edeka'
"""
def __init__(self, bPDFFile, parser: str = "edeka") -> None:
self.text = PDFReceipt._getTextFromPDF(bPDFFile)
self.id, self.date, self.items = PDFReceipt._getInfosFromText(self.text, parser)
def _getTextFromPDF(file):
with fitz.open("pdf", file) as doc:
text = ""
for page in doc:
text += page.get_text()
return text.strip()
def _getItemsTextFromText(text, start="", end=""):
return text[text.index(start)+len(start):text.index(end)].strip()
def _convertItemsTextToDict(text):
temp = text.split("\n")
resultsArr = []
i = 0
while i < len(temp):
if search("(\d+) x", temp[i]):
resultsArr.append({"itemname": temp[i+2], "price": temp[i+1], "amount": temp[i][:-2]})
i += 4
else:
resultsArr.append({"itemname": temp[i], "price": temp[i+1][:-2]})
i += 2
return resultsArr
def _getInfosFromText(text: str, parser: str = "edeka"):
if parser.lower() == "edeka":
items = PDFReceipt._convertItemsTextToDict(PDFReceipt._getItemsTextFromText(text, "EUR", "----------"))
strDate = text.split("\n")[-1].split(" ")[0]
date = datetime.strptime(strDate, "%d.%m.%y").date()
strReceiptNumber = text.split("\n")[-1].split(" ")[-1]
try:
intReceiptNumber = int(strReceiptNumber)
except:
raise ValueError("Receipt Number not an integer.")
return (intReceiptNumber, date, items)
def getPDFReceiptFromFile(strPDFFile: str, parser: str = "edeka"):
with open(strPDFFile) as doc:
return PDFReceipt(doc, parser)

View File

@ -6,7 +6,7 @@ load_dotenv(os.path.join(basedir, '.env'))
class Config(object): class Config(object):
SECRET_KEY = os.environ.get('SECRET_KEY') or "MY_5€cr37_K€Y" SECRET_KEY = os.environ.get('SECRET_KEY') or "s0m37h!n6-obfu5c471ng"
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', '').replace( SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', '').replace(
'postgres://', 'postgresql://') or \ 'postgres://', 'postgresql://') or \
(f"postgresql://{os.environ.get('DATABASE_USER', 'scan2kasse')}:{os.environ.get('DATABASE_PASS', 'asdf1337')}" (f"postgresql://{os.environ.get('DATABASE_USER', 'scan2kasse')}:{os.environ.get('DATABASE_PASS', 'asdf1337')}"

View File

@ -2,19 +2,25 @@
keys=root, main keys=root, main
[handlers] [handlers]
keys=console, file keys=console, file, void
[formatters] [formatters]
keys=stdout keys=stdout
[logger_root] [logger_root]
level = DEBUG handlers = void
level = CRITICAL
[logger_main] [logger_main]
handlers = console, file handlers = console, file
level = DEBUG level = DEBUG
qualname = main qualname = main
[handler_void]
class = logging.StreamHandler
level = CRITICAL
formatter = stdout
[handler_console] [handler_console]
class = logging.StreamHandler class = logging.StreamHandler
level = DEBUG level = DEBUG