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
app.register_blueprint(auth_bp, url_prefix='/auth')
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
app.register_blueprint(main_bp)
from app.api import bp as api_bp
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

View File

@ -1,6 +1,6 @@
from app.models import User
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
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 werkzeug.urls import url_parse
@bp.route(f'/register', methods=['GET', 'POST'])
@bp.route('/register', methods=['GET', 'POST'])
def web_register():
if current_user.is_authenticated:
return redirect(url_for('main.index'))
@ -22,7 +22,7 @@ def web_register():
return redirect(url_for('auth.web_login'))
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():
if current_user.is_authenticated:
return redirect(url_for('main.index'))
@ -39,7 +39,7 @@ def web_login():
return redirect(next_page)
return render_template('auth/login.html', title='Sign In', form=form)
@bp.route(f'/logout')
@bp.route('/logout')
def web_logout():
logout_user()
return redirect(url_for('main.index'))

View File

@ -2,6 +2,10 @@ from app import db
from app.errors import bp
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)
def not_found_error(error):
return render_template('errors/404.html'), 404

View File

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

View File

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

View File

@ -58,9 +58,11 @@ class Establishment(db.Model):
class LoginToken(db.Model):
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())
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))
Receipt = db.relationship("Receipt", backref='LoginToken', lazy='dynamic')
def __repr__(self) -> str:
return f"LoginToken {self.token}"
@ -125,6 +127,8 @@ class Receipt(db.Model):
date = db.Column(db.Date, nullable=False)
from_user = db.Column(db.ForeignKey("login_token.token"), server_onupdate=db.FetchedValue())
registered = db.Column(db.Boolean, nullable=False, server_default=str(False))
ItemReceipt = db.relationship("ItemReceipt", backref='Receipt', lazy='dynamic')
def __repr__(self) -> str:
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>
<form action="" method="post" novalidate>
{{ form.hidden_tag() }}
<p>
{{ wtf.form_field(form.email) }}
</p>
<p>
{{ 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"]) }}
{{ wtf.form_field(form.email, class=form.email.render_kw["class"] or "form-control mb-3") }}
{{ wtf.form_field(form.password, class=form.password.render_kw["class"] or "form-control mb-3") }}
{{ wtf.form_field(form.remember_me, class=form.remember_me.render_kw["class"] or "mb-3") }}
{{ wtf.form_field(form.submit, class=form.submit.render_kw["class"] or "btn btn-primary mt-3") }}
</form>
<p>
Forgot Your Password?

View File

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

View File

@ -45,9 +45,18 @@
<div class="collapse" id="dashboard-collapse">
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small">
{% 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 %}
<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 %}
{% endif %}
</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" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block app_content %}
<form action="" method="post">
{{ form.hidden_tag() }}
<div class="row">{{form.id.label}}</div>
<div class="row">{{form.id()}}</div>
<div class="row">{{form.name.label}}</div>
<div class="row">{{form.name()}}</div>
<div class="row">{{form.description.label}}</div>
<div class="row">{{form.description()}}</div>
<div class="row">{{form.date.label}}</div>
<div class="row">{{form.date()}}</div>
<div class="row">{{form.price_change.label}}</div>
<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>
{{ wtf.form_field(form.id, class=form.id.render_kw["class"] or "form-control mb-3") }}
{{ wtf.form_field(form.name, class=form.name.render_kw["class"] or "form-control mb-3") }}
{{ wtf.form_field(form.description, class=form.description.render_kw["class"] or "form-control mb-3") }}
{{ wtf.form_field(form.date, class=form.date.render_kw["class"] or "form-control mb-3") }}
{{ wtf.form_field(form.price_change, class=form.price_change.render_kw["class"] or "form-control mb-3") }}
{{ wtf.form_field(form.amount_change, class=form.amount_change.render_kw["class"] or "form-control mb-3") }}
{{ wtf.form_field(form.category, class=form.category.render_kw["class"] or "form-control mb-3") }}
{{ wtf.form_field(form.brand, class=form.brand.render_kw["class"] or "form-control mb-3") }}
{{ wtf.form_field(form.submit, class=form.submit.render_kw["class"] or "btn btn-primary mt-3") }}
</form>
{% 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):
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(
'postgres://', 'postgresql://') or \
(f"postgresql://{os.environ.get('DATABASE_USER', 'scan2kasse')}:{os.environ.get('DATABASE_PASS', 'asdf1337')}"

View File

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