diff --git a/app/__init__.py b/app/__init__.py index c68fb02..757a0c9 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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 diff --git a/app/auth/forms.py b/app/auth/forms.py index 8dfb21b..919ef6d 100644 --- a/app/auth/forms.py +++ b/app/auth/forms.py @@ -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): diff --git a/app/auth/routes.py b/app/auth/routes.py index f2c810e..8645509 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -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')) diff --git a/app/errors/handlers.py b/app/errors/handlers.py index d2d6935..7b71316 100644 --- a/app/errors/handlers.py +++ b/app/errors/handlers.py @@ -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 diff --git a/app/main/forms.py b/app/main/forms.py index b67420e..9616faa 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -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): diff --git a/app/main/routes.py b/app/main/routes.py index be4a70d..659c97d 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -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']) diff --git a/app/models.py b/app/models.py index 937b453..32032f4 100644 --- a/app/models.py +++ b/app/models.py @@ -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"" diff --git a/app/receipts/__init__.py b/app/receipts/__init__.py new file mode 100644 index 0000000..a215a0f --- /dev/null +++ b/app/receipts/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('receipts', __name__) + +from app.receipts import forms, routes \ No newline at end of file diff --git a/app/receipts/forms.py b/app/receipts/forms.py new file mode 100644 index 0000000..8b01006 --- /dev/null +++ b/app/receipts/forms.py @@ -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"}) \ No newline at end of file diff --git a/app/receipts/routes.py b/app/receipts/routes.py new file mode 100644 index 0000000..4b96f0b --- /dev/null +++ b/app/receipts/routes.py @@ -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", "
") + 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) \ No newline at end of file diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index e6322ef..ec37533 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -5,16 +5,10 @@

Sign In

{{ form.hidden_tag() }} -

- {{ wtf.form_field(form.email) }} -

-

- {{ wtf.form_field(form.password) }} -

-

- {{ wtf.form_field(form.remember_me) }} -

- {{ 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") }}

Forgot Your Password? diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html index 672a0d5..0e5d76e 100644 --- a/app/templates/auth/register.html +++ b/app/templates/auth/register.html @@ -5,15 +5,9 @@

Register

{{ form.hidden_tag() }} -

- {{ wtf.form_field(form.email) }} -

-

- {{ wtf.form_field(form.password) }} -

-

- {{ wtf.form_field(form.password2) }} -

- {{ 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") }}
{% endblock %} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index b112dc6..ec74f91 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -45,9 +45,18 @@
diff --git a/app/templates/errors/403.html b/app/templates/errors/403.html new file mode 100644 index 0000000..0e63ad0 --- /dev/null +++ b/app/templates/errors/403.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} + +{% block app_content %} +

You are not allowed to do this!

+

how dare you

+

Back

+{% endblock %} \ No newline at end of file diff --git a/app/templates/main/new_item.html b/app/templates/main/new_item.html index 5f4c53f..c5153a2 100644 --- a/app/templates/main/new_item.html +++ b/app/templates/main/new_item.html @@ -1,24 +1,17 @@ {% extends "base.html" %} +{% import 'bootstrap/wtf.html' as wtf %} {% block app_content %}
{{ form.hidden_tag() }} -
{{form.id.label}}
-
{{form.id()}}
-
{{form.name.label}}
-
{{form.name()}}
-
{{form.description.label}}
-
{{form.description()}}
-
{{form.date.label}}
-
{{form.date()}}
-
{{form.price_change.label}}
-
{{form.price_change()}}
-
{{form.amount_change.label}}
-
{{form.amount_change()}}
-
{{form.category.label}}
-
{{form.category()}}
-
{{form.brand.label}}
-
{{form.brand()}}
-
{{form.submit()}}
+ {{ 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") }}
{% endblock %} \ No newline at end of file diff --git a/app/templates/receipts/confirm_items.html b/app/templates/receipts/confirm_items.html new file mode 100644 index 0000000..572c967 --- /dev/null +++ b/app/templates/receipts/confirm_items.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block app_content %} +
+ {{ form.hidden_tag() }} + {{ wtf.form_field(form.submit, class=form.submit.render_kw["class"] or "btn btn-primary mt-3") }} +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/receipts/upload.html b/app/templates/receipts/upload.html new file mode 100644 index 0000000..20f8762 --- /dev/null +++ b/app/templates/receipts/upload.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block app_content %} +
+ {{ 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") }} +
+{% endblock %} \ No newline at end of file diff --git a/app/utils/pdf_receipt_parser.py b/app/utils/pdf_receipt_parser.py new file mode 100644 index 0000000..9537205 --- /dev/null +++ b/app/utils/pdf_receipt_parser.py @@ -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) \ No newline at end of file diff --git a/configs/config.py b/configs/config.py index e47ad8e..9c6ae3b 100644 --- a/configs/config.py +++ b/configs/config.py @@ -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')}" diff --git a/configs/log.conf b/configs/log.conf index 443074c..fbe730f 100644 --- a/configs/log.conf +++ b/configs/log.conf @@ -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