Compare commits

..

15 Commits

Author SHA1 Message Date
f24880c09a fix: raise user password char length (#18)
All checks were successful
Create Release / release (push) Successful in 28s
Create Release / docker (push) Successful in 8m55s
Co-authored-by: Lunaresk <lingsonb@gmail.com>
Reviewed-on: #18
2025-06-03 21:39:00 +02:00
3622cbca05 fix: workflow login
All checks were successful
Create Release / release (push) Successful in 21s
Create Release / docker (push) Successful in 8m43s
2025-04-09 01:29:54 +02:00
637a5f7ad5 fix: stylesheet
Some checks failed
Create Release / release (push) Successful in 2m1s
Create Release / docker (push) Failing after 8m22s
2025-04-09 00:50:15 +02:00
15ba3c060a Workflow Action for Docker Repo (#2)
Reviewed-on: #2
2024-11-03 17:57:37 +00:00
bde9a50767 fix: update dockerfile 2024-11-03 15:47:29 +01:00
60e221146a Actions Workflow Fix 2024-11-03 10:51:25 +01:00
d34f2adb2f fix: correct OCR language 2024-11-02 23:46:02 +01:00
a6f229e7fb minor: set folder for receipts 2024-08-26 19:43:47 +02:00
5a453a140e major: integrate ocr to pdf reader 2024-08-25 22:19:53 +02:00
0f6f76a66b fix: money can now be entered in decimal 2024-08-18 18:39:56 +02:00
14b7e1f84c bug: model fix 2024-06-16 17:25:43 +02:00
560016cc18 minor: add table as save for unknown items 2024-06-16 16:21:57 +02:00
b57dbba5d6 minor: display payments on overview
Payments are now displayed at the bottom of the lists.
Also, receipts can now be uploaded without necessarily
providing an image of the original.
2024-02-18 17:37:06 +01:00
7ae57ae3a4 major: add calculation for payments 2024-02-09 23:12:23 +01:00
04a13635dc minor: add button for upload receipt 2023-11-27 22:13:50 +01:00
75 changed files with 564 additions and 123 deletions

View File

@ -4,19 +4,47 @@ on:
tags:
- '*'
jobs:
build:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Archive Server
uses: thedoctor0/zip-release@master
- uses: https://gitea.com/actions/checkout@master
- name: Zip Artifacts
uses: https://github.com/thedoctor0/zip-release@master
with:
type: 'zip'
filename: 'server.zip'
exclusions: '*.git*'
- name: Release Archive
uses: ncipollo/release-action@v1
uses: https://gitea.com/actions/gitea-release-action@v1
with:
allowUpdates: true
artifacts: "server.zip"
token: ${{ secrets.GITHUB_TOKEN }}
server_url: https://gitea.wpgcommunity.net
files: 'server.zip'
docker:
runs-on: ubuntu-latest
steps:
- uses: https://gitea.com/actions/checkout@master
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ vars.DOCKER_REPO }}/costhive
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
config-inline: |
[registry."${{ vars.DOCKER_REPO }}"]
http = true
insecure = true
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ vars.DOCKER_REPO }}
username: ${{ vars.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASS }}
- name: Build and push
uses: https://github.com/docker/build-push-action@v6
with:
push: true
context: .
tags: ${{ steps.meta.outputs.tags }}

4
.gitignore vendored
View File

@ -353,4 +353,6 @@ tests*
*.ps1
*.pdf
*.backup
Dockerfile.*
Dockerfile.*
docker-compose.*
*debug.py

View File

@ -1,5 +1,5 @@
FROM python@sha256:21c9f0b22213295a13bd678c5b45aa587ff6cb01cd99b6cf0e6928f4c777006b
# python:3.11.4-slim-bullseye (arm/v7)
FROM python@sha256:c66cf219ac0083a9af2ff90e16530f16cd503c59eb7909feb3b8f3524dc1a87e
# python:3.12.2-slim-bullseye (amd64)
RUN useradd costhive
WORKDIR /home/costhive
@ -16,7 +16,7 @@ RUN python -m venv venv; \
COPY backend backend
ENV FLASK_APP run.py
ENV FLASK_APP=run.py
RUN chmod +x boot.sh; \
chown -R costhive:costhive .

View File

@ -4,6 +4,7 @@ from dotenv import load_dotenv
basedir = os.path.abspath(os.path.dirname(__file__))
load_dotenv(os.path.join(basedir, '.env'))
os.environ["TESSDATA_PREFIX"] = os.path.join(basedir, 'tessdata')
class Config(object):
SECRET_KEY = os.environ.get('SECRET_KEY') or "s0m37h!n6-obfu5c471ng"
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', '').replace(
@ -19,3 +20,4 @@ class Config(object):
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
ADMINS = ['postmaster@wpgcommunity.net']
POSTS_PER_PAGE = 15
RECEIPT_FOLDER = f"{basedir}/../PDFReceipts"

Binary file not shown.

View File

@ -0,0 +1 @@
tessedit_create_alto 1

View File

@ -0,0 +1,7 @@
tessedit_ambigs_training 1
load_freq_dawg 0
load_punc_dawg 0
load_system_dawg 0
load_number_dawg 0
ambigs_debug_level 3
load_fixed_length_dawgs 0

View File

@ -0,0 +1 @@
tessedit_zero_rejection T

View File

@ -0,0 +1,5 @@
load_bigram_dawg True
tessedit_enable_bigram_correction True
tessedit_bigram_debug 3
save_raw_choices True
save_alt_choices True

View File

@ -0,0 +1,12 @@
disable_character_fragments T
file_type .bl
textord_fast_pitch_test T
tessedit_zero_rejection T
tessedit_minimal_rejection F
tessedit_write_rep_codes F
edges_children_fix F
edges_childarea 0.65
edges_boxarea 0.9
tessedit_resegment_from_boxes T
tessedit_train_from_boxes T
textord_no_rejects T

View File

@ -0,0 +1,13 @@
file_type .bl
#tessedit_use_nn F
textord_fast_pitch_test T
tessedit_zero_rejection T
tessedit_minimal_rejection F
tessedit_write_rep_codes F
edges_children_fix F
edges_childarea 0.65
edges_boxarea 0.9
tessedit_resegment_from_boxes T
tessedit_train_from_boxes T
#textord_repeat_extraction F
textord_no_rejects T

View File

@ -0,0 +1 @@
tessedit_char_whitelist 0123456789-.

View File

@ -0,0 +1 @@
tessedit_write_images T

View File

@ -0,0 +1,2 @@
tessedit_create_hocr 1
hocr_font_info 0

View File

@ -0,0 +1,2 @@
interactive_display_mode T
tessedit_display_outwords T

View File

@ -0,0 +1,4 @@
textord_skewsmooth_offset 8
textord_skewsmooth_offset2 8
textord_merge_desc 0.5
textord_no_rejects 1

View File

@ -0,0 +1,2 @@
tessedit_resegment_from_line_boxes 1
tessedit_make_boxes_from_boxes 1

View File

@ -0,0 +1 @@
debug_file tesseract.log

View File

@ -0,0 +1,11 @@
file_type .bl
textord_fast_pitch_test T
tessedit_zero_rejection T
tessedit_minimal_rejection F
tessedit_write_rep_codes F
edges_children_fix F
edges_childarea 0.65
edges_boxarea 0.9
tessedit_train_line_recognizer T
textord_no_rejects T
tessedit_init_config_only T

View File

@ -0,0 +1 @@
tessedit_create_lstmbox 1

View File

@ -0,0 +1,4 @@
stopper_debug_level 1
classify_debug_level 1
segsearch_debug_level 1
language_model_debug_level 3

View File

@ -0,0 +1 @@
tessedit_create_boxfile 1

View File

@ -0,0 +1 @@
tessedit_create_pdf 1

View File

@ -0,0 +1 @@
debug_file /dev/null

View File

@ -0,0 +1,2 @@
tessedit_resegment_from_boxes 1
tessedit_make_boxes_from_boxes 1

View File

@ -0,0 +1,12 @@
textord_show_blobs 0
textord_debug_tabfind 3
textord_tabfind_show_partitions 1
textord_tabfind_show_initial_partitions 1
textord_tabfind_show_columns 1
textord_tabfind_show_blocks 1
textord_tabfind_show_initialtabs 1
textord_tabfind_show_finaltabs 1
textord_tabfind_show_strokewidths 1
textord_tabfind_show_vlines 0
textord_tabfind_show_images 1
tessedit_dump_pageseg_images 0

View File

@ -0,0 +1 @@
tessedit_create_tsv 1

View File

@ -0,0 +1,3 @@
# This config file should be used with other config files which create renderers.
# usage example: tesseract eurotext.tif eurotext txt hocr pdf
tessedit_create_txt 1

View File

@ -0,0 +1,2 @@
tessedit_write_unlv 1
unlv_tilde_crunching T

View File

@ -0,0 +1 @@
tessedit_create_wordstrbox 1

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,2 @@
1-\d\d\d-GOOG-411
www.\n\\\*.com

View File

@ -0,0 +1,5 @@
the
quick
brown
fox
jumped

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1 @@
# No content needed as all defaults are correct.

View File

@ -0,0 +1,2 @@
chop_enable 0
wordrec_enable_assoc 0

View File

@ -0,0 +1,7 @@
#################################################
# Adaptive Matcher Using PreAdapted Templates
#################################################
classify_enable_adaptive_debugger 1
matcher_debug_flags 6
matcher_debug_level 1

View File

@ -0,0 +1,12 @@
#################################################
# Adaptive Matcher Using PreAdapted Templates
#################################################
classify_enable_adaptive_debugger 1
matcher_debug_flags 6
matcher_debug_level 1
wordrec_display_splits 0
wordrec_display_all_blobs 1
wordrec_display_segmentations 2
classify_debug_level 1

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,9 @@
#################################################
# Adaptive Matcher Using PreAdapted Templates
#################################################
wordrec_display_splits 0
wordrec_display_all_blobs 1
wordrec_display_segmentations 2
classify_debug_level 1
stopper_debug_level 1

View File

@ -0,0 +1,35 @@
"""new table for bought entries with unknown items 2
Revision ID: 2a64d3b9235a
Revises: 015f4256bb4c
Create Date: 2024-06-02 13:19:59.901053
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2a64d3b9235a'
down_revision = '015f4256bb4c'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('bought_with_unknown_item',
sa.Column('token', sa.String(length=15), nullable=False),
sa.Column('item', sa.BigInteger(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('amount', sa.SmallInteger(), nullable=False),
sa.ForeignKeyConstraint(['token'], ['login_token.token'], ),
sa.PrimaryKeyConstraint('token', 'item', 'date')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('bought_with_unknown_item')
# ### end Alembic commands ###

View File

@ -0,0 +1,38 @@
"""raise password char length
Revision ID: 782a2409df41
Revises: 926395732c3e
Create Date: 2025-06-03 21:01:23.169897
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '782a2409df41'
down_revision = '926395732c3e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.alter_column('password_hash',
existing_type=sa.VARCHAR(length=128),
type_=sa.String(length=255),
existing_nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.alter_column('password_hash',
existing_type=sa.String(length=255),
type_=sa.VARCHAR(length=128),
existing_nullable=False)
# ### end Alembic commands ###

View File

@ -0,0 +1,38 @@
"""raise bonid digits
Revision ID: 926395732c3e
Revises: 2a64d3b9235a
Create Date: 2024-08-24 10:33:39.109944
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '926395732c3e'
down_revision = '2a64d3b9235a'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('receipt', schema=None) as batch_op:
batch_op.alter_column('bonid',
existing_type=sa.NUMERIC(precision=24, scale=0),
type_=sa.Numeric(precision=28, scale=0),
existing_nullable=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('receipt', schema=None) as batch_op:
batch_op.alter_column('bonid',
existing_type=sa.Numeric(precision=28, scale=0),
type_=sa.NUMERIC(precision=24, scale=0),
existing_nullable=True)
# ### end Alembic commands ###

View File

@ -3,6 +3,7 @@ from .receipt_item import ReceiptItem
from .receipt import Receipt
from .login_token import LoginToken
from .bought import Bought
from .bought_with_unknown_item import BoughtWithUnknownItem
from .establishment import Establishment
from .establishment_candidate import EstablishmentCandidate
from .user import User

View File

@ -0,0 +1,14 @@
from src import db
from .bought import Bought
class BoughtWithUnknownItem(db.Model):
token = db.Column(db.ForeignKey('login_token.token'),
primary_key=True, server_onupdate=db.FetchedValue())
item = db.Column(db.BigInteger, primary_key=True,
server_onupdate=db.FetchedValue())
date = db.Column(db.Date, primary_key=True)
amount = db.Column(db.SmallInteger, nullable=False)
def __repr__(self) -> str:
return f"<Bought Object>"

View File

@ -4,7 +4,7 @@ from src import db
class Payment(db.Model):
id = db.Column(db.BigInteger, primary_key=True, autoincrement=True)
token = db.Column(db.ForeignKey('login_token.token'),
primary_key=True, server_onupdate=db.FetchedValue())
server_onupdate=db.FetchedValue(), nullable=False)
date = db.Column(db.Date, nullable=False, server_default=db.func.now())
amount = db.Column(db.BigInteger, nullable=False, server_default=str(0))

View File

@ -4,7 +4,7 @@ from src import db
class Receipt(db.Model):
id = db.Column(db.BigInteger, primary_key=True, autoincrement=True)
date = db.Column(db.Date, nullable=False)
bonid = db.Column(db.Numeric(precision=24, scale=0), unique=True)
bonid = db.Column(db.Numeric(precision=28, scale=0), unique=True)
from_user = db.Column(db.ForeignKey("login_token.token"),
server_onupdate=db.FetchedValue())
registered = db.Column(db.Boolean, nullable=False,

View File

@ -9,7 +9,7 @@ from werkzeug.security import generate_password_hash, check_password_hash
class User(UserMixin, db.Model):
id = db.Column(db.BigInteger, primary_key=True, autoincrement=True)
email = db.Column(db.String(255), nullable=False, unique=True)
password_hash = db.Column(db.String(128), nullable=False)
password_hash = db.Column(db.String(255), nullable=False)
LoginToken = db.relationship("LoginToken", backref='User', lazy='dynamic')
Bought = db.relationship("Bought", secondary="login_token",

View File

@ -34,10 +34,12 @@ login = LoginManager()
login.login_view = 'auth.web_login'
ma = Marshmallow()
mail = Mail()
migrate = Migrate()
migrate = Migrate(transaction_per_migration=True)
def create_app(config_class=Config):
if not exists(config_class.RECEIPT_FOLDER):
makedirs(config_class.RECEIPT_FOLDER)
app = Flask(__name__, template_folder="../web/templates", static_folder="../web/static")
app.config.from_object(config_class)
bootstrap.init_app(app)

View File

@ -2,7 +2,7 @@ from flask import abort, request
from flask.json import jsonify
from flask_login import current_user, login_required
from . import bp
from .utils import get_report, group_results, sum_entries
from .utils import calculate_payments, get_report, group_results, sum_entries
from src import LOGGER
from models import Establishment, LoginToken
from src.utils.routes_utils import render_custom_template as render_template
@ -25,7 +25,7 @@ def get_report_from_user(establishment_id):
LOGGER.info("Getting results.")
results = get_report(**request.args, **{"establishment": establishment_id})
LOGGER.debug(f"Results received.")
LOGGER.debug(str(results))
# LOGGER.debug(str(results))
if results:
result_list = group_results(results)
tokens = establishment.LoginToken.all()
@ -33,10 +33,12 @@ def get_report_from_user(establishment_id):
for token in tokens:
token_dates.extend(token.LoginTokenDates.all())
sum_entries(result_list, token_dates)
calculate_payments(result_list)
else:
result_list = []
# LOGGER.debug(result_list)
if request.content_type == "application/json":
LOGGER.debug("Returning JSON")
return jsonify(result_list)
else:
return render_template("establishment/overview/overview.html", results=result_list, establishment=Establishment.query.get(int(establishment_id)))

View File

@ -1,7 +1,7 @@
from src import LOGGER
from datetime import date as dtdate, timedelta
from flask_login import current_user
from models import Establishment, Item, LoginToken, User, Receipt, ReceiptItem
from models import Establishment, Item, LoginToken, User, Payment, Receipt, ReceiptItem
from src import db, LOGGER
from src.utils.view_utils import bought_with_prices as bwp
@ -14,6 +14,12 @@ def group_results(results: tuple) -> list:
sum: <sum of itemamounts*itemprices>,
membership_dates: [
(datetime.date(entry_date), datetime.date(exit_date))
],
payments: [
{
date: datetime.date(payment date),
amount: <payment (ct)>
}
]
item_infos: [
{
@ -55,15 +61,14 @@ def group_results(results: tuple) -> list:
LOGGER.debug("Grouped.")
return result_list
def sum_entries(grouped_result_list, login_token_dates):
LOGGER.debug("Preparing dict")
dict_people_modifier = {}
def get_token_modifier_on_date(login_token_dates):
# dict_people_modifier:
# {datetime.date(x,y,z): {
# 'add': <list of usertokens>,
# 'remove: <list of usertokens>
# }
# }
dict_people_modifier = {}
for tokendate in login_token_dates:
if not tokendate.activation_date in dict_people_modifier:
dict_people_modifier[tokendate.activation_date] = {}
@ -72,11 +77,13 @@ def sum_entries(grouped_result_list, login_token_dates):
dict_people_modifier[tokendate.activation_date]["add"].append(tokendate.token)
if tokendate.deactivation_date != None:
if not tokendate.deactivation_date in dict_people_modifier:
if not tokendate.deactivation_date in dict_people_modifier:
dict_people_modifier[tokendate.deactivation_date] = {}
if not "remove" in dict_people_modifier[tokendate.deactivation_date]:
dict_people_modifier[tokendate.deactivation_date]['remove'] = []
dict_people_modifier[tokendate.deactivation_date]["remove"].append(tokendate.token)
dict_people_modifier[tokendate.deactivation_date] = {}
if not "remove" in dict_people_modifier[tokendate.deactivation_date]:
dict_people_modifier[tokendate.deactivation_date]['remove'] = []
dict_people_modifier[tokendate.deactivation_date]["remove"].append(tokendate.token)
return dict_people_modifier
def generate_better_list(dict_people_modifier):
list_people_amount_per_date = [{"date": key, "people": value} for key, value in dict_people_modifier.items()]
list_people_amount_per_date.sort(key=lambda x: x.get('date'))
list_people_per_date = []
@ -95,25 +102,34 @@ def sum_entries(grouped_result_list, login_token_dates):
list_people_per_date[-1]['people'].remove(person)
except ValueError as e:
LOGGER.debug(f'{person} not in list.')
LOGGER.debug("This is line 91")
return list_people_per_date
def sum_entries(grouped_result_list, login_token_dates):
dict_people_modifier = get_token_modifier_on_date(login_token_dates)
LOGGER.debug(dict_people_modifier)
LOGGER.debug("Preparing dict")
list_people_per_date = generate_better_list(dict_people_modifier)
LOGGER.debug("This is line 106")
for result_user in grouped_result_list:
relevant_date_index = 0
if result_user.get('id'):
for result_date in result_user['item_infos']:
# TODO get relevant date index
for i in range(relevant_date_index + 1, len(list_people_per_date)):
if list_people_per_date[i].get('date') > result_date.get('date'):
LOGGER.debug(f"{list_people_per_date[i].get('date')} > {result_date.get('date')}")
relevant_date_index = i-1
break
if i == len(list_people_per_date)-1:
if list_people_per_date[i].get('date') < result_date.get('date'):
relevant_date_index=i
# LOGGER.debug(f"Relevant Date: {list_people_per_date[relevant_date_index].get('date')}, Index: {relevant_date_index}")
# LOGGER.debug(f"Result Date: {result_date.get('date')}")
for result_item in result_date['item_list']:
result_user['sum'] += result_item['amount'] * result_item['price']
list_people_per_date[relevant_date_index]['sum'] += result_item['amount'] * result_item['price']
for result_date in result_user['item_infos']:
# TODO get relevant date index
if not result_date.get('date'):
result_date['item_list'] = []
continue
for i in range(relevant_date_index + 1, len(list_people_per_date)):
if list_people_per_date[i].get('date') > result_date.get('date'):
# LOGGER.debug(f"{list_people_per_date[i].get('date')} > {result_date.get('date')}")
relevant_date_index = i-1
break
if i == len(list_people_per_date)-1:
if list_people_per_date[i].get('date') <= result_date.get('date'):
relevant_date_index=i
# LOGGER.debug(f"Relevant Date: {list_people_per_date[relevant_date_index].get('date')}, Index: {relevant_date_index}")
# LOGGER.debug(f"Result Date: {result_date.get('date')}")
for result_item in result_date['item_list']:
result_user['sum'] += result_item['amount'] * result_item['price']
list_people_per_date[relevant_date_index]['sum'] += result_item['amount'] * result_item['price']
LOGGER.debug(list_people_per_date)
for entry_people_per_date in list_people_per_date:
for result_user in grouped_result_list:
@ -121,10 +137,21 @@ def sum_entries(grouped_result_list, login_token_dates):
LOGGER.debug(f"Reducing sum of {result_user.get('id')} by {entry_people_per_date.get('sum')/len(entry_people_per_date.get('people'))}")
result_user['sum'] -= entry_people_per_date.get('sum')/len(entry_people_per_date.get('people'))
def calculate_payments(grouped_result_list):
LOGGER.debug("Calculating Payments")
for result_user in grouped_result_list:
payments:list[Payment] = Payment.query.filter_by(token=result_user.get('id')).order_by(Payment.date).all()
if payments:
LOGGER.debug(f"Payments found for user {result_user.get('id')}")
result_user['payments'] = [{"date": x.date, "amount": x.amount} for x in payments]
paymentsum = sum([x.amount for x in payments])
LOGGER.debug(f"Adding payments of a total of {paymentsum} to {result_user.get('id')}")
result_user['sum'] -= sum([x.amount for x in payments])
def get_report(**kwargs):
query_select_boughts = db.session.query(
bwp.c.token, User.email, bwp.c.date, bwp.c.item, Item.name, bwp.c.amount, bwp.c.price)
query_select_boughts = query_select_boughts.select_from(User).join(LoginToken, LoginToken.user == User.id).join(
LoginToken.token, User.email, bwp.c.date, bwp.c.item, Item.name, bwp.c.amount, bwp.c.price)
query_select_boughts = query_select_boughts.select_from(LoginToken).join(User, LoginToken.user == User.id).join(
bwp, LoginToken.token == bwp.c.token, isouter = True).join(Item, Item.id == bwp.c.item, isouter = True)
query_select_receipts = db.session.query(
Receipt.from_user, User.email, Receipt.date, ReceiptItem.item, ReceiptItem.name, ReceiptItem.amount, -ReceiptItem.price)
@ -161,7 +188,7 @@ def get_report(**kwargs):
query_select_boughts = query_select_boughts.filter(bwp.c.date.between(
dtdate(int(year), 1, 1), dtdate(int(year), 12, 31)))
query_select = query_select_boughts.union(query_select_receipts)
query_select = query_select.order_by(bwp.c.token, bwp.c.date, bwp.c.item)
query_select = query_select.order_by(LoginToken.token, bwp.c.date, bwp.c.item)
LOGGER.debug(str(query_select))
results = query_select.all()
return tuple(results)

View File

@ -1,12 +1,12 @@
from models import LoginToken
from flask_wtf import FlaskForm
from wtforms import DateField, FloatField, IntegerField, SelectField, SelectMultipleField, StringField, SubmitField
from wtforms.validators import DataRequired, Optional
from wtforms import DateField, DecimalField, SelectField, SubmitField
from wtforms.validators import DataRequired
class NewPaymentForm(FlaskForm):
token = SelectField("User", validators=[DataRequired()], render_kw={"class": "form-control"})
date = DateField("Date", validators=[DataRequired()], render_kw={"class": "form-control"})
amount = IntegerField("Amount (in ct)", validators=[DataRequired()], render_kw={"class": "form-control"})
amount = DecimalField("Amount (€)", validators=[DataRequired()], render_kw={"class": "form-control"})
submit = SubmitField("Submit", render_kw={"class": "btn btn-primary mt-3"})
@classmethod

View File

@ -17,7 +17,7 @@ def insert_payment(establishment_id: int):
if form.validate_on_submit():
new_payment = Payment(token = form.token.data,
date = form.date.data,
amount = form.amount.data)
amount = int(form.amount.data*100))
db.session.add(new_payment)
db.session.commit()
return redirect(url_for("main.index"))

View File

@ -10,5 +10,4 @@ def show_item(item: int):
itemschema = ItemSchema().dump(itemobj)
itemschema['PriceChange'].sort(key=lambda d: d['date'], reverse=True)
itemschema['AmountChange'].sort(key=lambda d: d['date'], reverse=True)
print(itemschema)
return render_template('item/details/show_item.html', item = itemschema)

View File

@ -73,6 +73,4 @@ class CheckItemsForm(FlaskForm):
item['itemname'], item['price'], item['amount'] if 'amount' in item else 1, 0))
check_items = CheckItems(check_items_entry)
form = cls(obj=check_items)
print(f"{form.items.entries}")
return form

View File

@ -1,5 +1,5 @@
from datetime import date
from flask import abort, request, url_for
from flask import abort, current_app, request, url_for
from flask_login import current_user, login_required
from . import bp
from .forms import CheckCustomItemsEntryForm, CheckItemsEntryForm, CheckItemsForm, get_choices
@ -9,16 +9,16 @@ from models import AmountChange, Item, LoginToken, PriceChange, Receipt, Receipt
from src.utils.modules.receipt_parser.pdf_receipt_parser import PDFReceipt
from src.utils.routes_utils import render_custom_template as render_template
PDFDir = "./"
@bp.route('/<int:receipt_id>', methods=['GET', 'POST'])
@login_required
def confirm_receipt_items(receipt_id: int):
"""Check items from a receipt if they should be accounted for payment.
Get those items from the receipt PDF itself."""
PDFDir: str = current_app.config["RECEIPT_FOLDER"]
receipt_details: Receipt = Receipt.query.get(receipt_id)
if current_user.is_authenticated and current_user.id == receipt_details.LoginToken.Establishment.owner:
receipt: PDFReceipt = PDFReceipt.getPDFReceiptFromFile(PDFDir + f"{receipt_details.id}.pdf")
receipt: PDFReceipt = PDFReceipt.getPDFReceiptFromFile(PDFDir + f"/{receipt_details.id}.pdf")
form: CheckItemsForm = CheckItemsForm.new(receipt.items)
_template = CheckCustomItemsEntryForm(prefix="custom_items-_-")
# TODO: Precheck if items are already in database. If yes, check if item is present only once or multiple

View File

@ -1,4 +1,5 @@
from datetime import date
from decimal import Decimal
from models import AmountChange, Item, PriceChange, Receipt, ReceiptItem
from src import db, LOGGER
@ -35,7 +36,7 @@ def insert_existing_item(formitemdict: dict[str: str], receipt_date: date = None
db.session.commit()
def insert_item_to_receipt(receipt: Receipt, item_dict: dict[str: str], item_index:int=0):
receipt.ReceiptItem.append(ReceiptItem(item=item_index, name=item_dict.get('itemname'), amount=item_dict.get('amount'), price=int(str(item_dict.get('price')).replace(',','').replace('.', ''))))
receipt.ReceiptItem.append(ReceiptItem(item=item_index, name=item_dict.get('itemname'), amount=item_dict.get('amount'), price=int(item_dict.get('price')*100)))
db.session.add(receipt)
db.session.commit()

View File

@ -1,7 +1,16 @@
from flask_wtf import FlaskForm
from flask_wtf.file import FileAllowed, FileField, FileRequired
from wtforms import SubmitField
from flask_wtf.file import FileAllowed, FileField
from wtforms import DateField, SelectField, SubmitField
from models import Establishment
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"})
user = SelectField("User", choices=[], render_kw={"class": "form-control"})
date = DateField("Insert Date", render_kw={"class": "form-control"})
pdfReceipt = FileField("PDF", validators=[FileAllowed(["pdf"], "Invalid Format, must be .pdf")])
submit = SubmitField("Submit", render_kw={"class": "btn btn-primary mt-3"})
@classmethod
def new(cls, establishment):
form = cls()
form.user.choices = [(None, "")]+[(t.token, t.User.email) for t in Establishment.query.get(establishment).LoginToken.order_by("user").all()]
return form

View File

@ -1,4 +1,4 @@
from flask import abort, redirect, request, url_for
from flask import abort, current_app, redirect, request, url_for
from flask_login import current_user, login_required
from os import rename
from werkzeug.utils import secure_filename
@ -10,27 +10,37 @@ from models.login_token import LoginToken
from src.utils.modules.receipt_parser.pdf_receipt_parser import PDFReceipt
from src.utils.routes_utils import render_custom_template as render_template
PDFDir = "./"
@bp.route('/<int:establishment>', methods=['GET', 'POST'])
@login_required
def upload_receipt(establishment: int):
"""Upload of a receipt."""
PDFDir: str = current_app.config["RECEIPT_FOLDER"]
if current_user.is_anonymous:
abort(403)
if LoginToken.query.filter_by(establishment=establishment, user=current_user.id).first():
form = UploadReceiptForm()
form = UploadReceiptForm.new(establishment)
LOGGER.debug(form.pdfReceipt.data)
if form.validate_on_submit():
pdfReceipt = form.pdfReceipt.data
pdfReceipt.save(f"{PDFDir}/temp.pdf")
with open(f"{PDFDir}/temp.pdf") as doc:
receipt = PDFReceipt(doc)
dbReceipt = Receipt(bonid = receipt.id, date = receipt.date,
from_user = LoginToken.query.filter_by(establishment=establishment, user=current_user.id).first().token)
receipt_date = form.date.data
bonid = None
if form.user.data:
from_user = form.user.data
else:
from_user = LoginToken.query.filter_by(establishment=establishment, user=current_user.id).first_or_404().token
if pdfReceipt:
pdfReceipt.save(f"{PDFDir}/temp.pdf")
with open(f"{PDFDir}/temp.pdf") as doc:
receipt = PDFReceipt(doc)
bonid = receipt.id
if receipt.date:
receipt_date = receipt.date
dbReceipt = Receipt(date = receipt_date, from_user = from_user, bonid = bonid)
db.session.add(dbReceipt)
db.session.commit()
rename(f"{PDFDir}/temp.pdf", f"{PDFDir}{secure_filename(f'{dbReceipt.id}.pdf')}")
LOGGER.debug(receipt.text)
if pdfReceipt:
rename(f"{PDFDir}/temp.pdf", f"{PDFDir}/{secure_filename(f'{dbReceipt.id}.pdf')}")
LOGGER.debug(receipt.words)
return redirect(url_for("receipts.check_items.confirm_receipt_items", receipt_id = dbReceipt.id))
else:
LOGGER.debug(form.errors)

View File

@ -8,7 +8,7 @@ from sqlalchemy.dialects.postgresql import insert
from string import ascii_letters, digits
from .view_utils import bought_with_prices as bwp
from src import db, LOGGER
from models import Bought, Establishment, Item, LoginToken, User
from models import Bought, BoughtWithUnknownItem, Establishment, Item, LoginToken, User
def insert_bought_items(token: str, dates: list[dict[str: any]]):
@ -19,21 +19,35 @@ def insert_bought_items(token: str, dates: list[dict[str: any]]):
item['item_id']), date=date['date'], amount=int(item["amount"]))
query_insert = query_insert.on_conflict_do_update(
"bought_pkey", set_=dict(amount=text(f'bought.amount + {int(item["amount"])}')))
try:
db.session.execute(query_insert)
db.session.commit()
except errors.ForeignKeyViolation as e:
db.session.rollback()
except Exception as e:
db.session.rollback()
LOGGER.exception("")
else:
if(_query_successful(query_insert)):
item_index = dates[date_index]['items'].index(item)
del (dates[date_index]['items'][item_index])
else:
query_insert = insert(BoughtWithUnknownItem).values(token=token, item=int(
item['item_id']), date=date['date'], amount=int(item["amount"]))
query_insert = query_insert.on_conflict_do_update(
"bought_with_unknown_item_pkey", set_=dict(amount=text(f'bought_with_unknown_item.amount + {int(item["amount"])}')))
if(_query_successful(query_insert)):
item_index = dates[date_index]['items'].index(item)
del (dates[date_index]['items'][item_index])
if len(dates[date_index]['items']) == 0:
del (dates[date_index])
return {'token': token, 'dates': dates} if dates else {}
def _query_successful(query):
try:
db.session.execute(query)
db.session.commit()
except errors.ForeignKeyViolation as e:
db.session.rollback()
return False
except Exception as e:
db.session.rollback()
LOGGER.exception("")
return False
else:
return True
def generate_token(length=15, allowed_chars=ascii_letters + digits):
new_token = "".join((rndchoice(allowed_chars) for i in range(length)))
if not LoginToken.query.filter_by(token=new_token).first():

View File

@ -0,0 +1,20 @@
from datetime import datetime
def getDictFromWords(words: list[tuple]):
results = {"items": []}
results['date'] = datetime.strptime(words[-6][4], "%d.%m.%y").date()
results['bonid'] = words[-1][4]
currentline = 0
skipwords = 14
for i, word in enumerate(words[skipwords:]):
if currentline != word[5]:
results['items'].append({"itemname": word[4]})
currentline = word[5]
elif word[6] == 0:
results['items'][-1]["itemname"] += " " + word[4]
if word[6] == 1 and word[7] == 1:
results['items'][-1]["price"] = word[4].split("*")[0]
if "----" in word[4]:
del(results['items'][-1])
break
return results

View File

@ -0,0 +1,27 @@
from datetime import datetime
def getDictFromWords(words: list[tuple]):
results = {"items": []}
results['bonid'] = words[-1][4]
currentline = 0
skipwords = 9
for i, word in enumerate(words[skipwords:]):
if currentline != word[5]:
results['items'].append({"itemname": word[4]})
currentline = word[5]
elif word[6] == 0:
results['items'][-1]["itemname"] += " " + word[4]
if word[6] == 1 and word[7] == 0:
if word[4].lower() == "x":
results['items'][-1]["amount"] = words[i+skipwords+1][4]
else:
results['items'][-1]["price"] = word[4]
elif word[6] == 2:
results['items'][-1]["price"] = word[4]
if word[4].lower() == "gesamt":
del(results['items'][-1])
break
for i, word in enumerate(words[::-1]):
if word[4].lower() == "datum:":
results['date'] = datetime.strptime(words[::-1][i-1][4], "%d.%m.%Y").date()
return results

View File

@ -1,5 +1,7 @@
import fitz
from datetime import datetime, date
from datetime import date
from .edeka.edeka_parser import getDictFromWords as edekaparser
from .kaufland.kaufland_parser import getDictFromWords as kauflandparser
from re import search
class PDFReceipt:
@ -10,51 +12,47 @@ class PDFReceipt:
parser -- A keyword in lowercase to tell how the receipt is formated.
Currently supported: 'edeka'
"""
def __init__(self, bPDFFile, parser: str = "edeka") -> None:
def __init__(self, strPDFFile) -> None:
try:
self.text = PDFReceipt._getTextFromPDF(bPDFFile)
self.id, self.date, self.items = PDFReceipt._getInfosFromText(self.text, parser)
self.words = PDFReceipt._getWordsFromPDF(strPDFFile)
storename = PDFReceipt._getStoreName(self.words)
self.id, self.date, self.items = PDFReceipt._getInfosFromText(self.words, store = storename)
except:
self.text = "PDF konnte nicht geladen werden."
self.words = "PDF konnte nicht geladen werden."
self.date = date.today()
self.id = None
self.items = []
def _getTextFromPDF(file):
def _getWordsFromPDF(file):
with fitz.open(file, filetype="pdf") as doc:
text = ""
words = []
for page in doc:
text += page.get_text()
return text.strip()
words.extend(page.get_text("words", textpage=page.get_textpage_ocr(language = 'deu'), sort=True))
return words
def _getStoreName(words: list[tuple]) -> str:
for word in words:
if word[4].lower() in ("edeka", "kaufland"):
return word[4].lower()
return "unknown"
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, start="EUR", end="----------"))
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.")
def _getInfosFromText(words: str, store: str = "edeka"):
if store == "edeka":
result = edekaparser(words)
elif store == "kaufland":
result = kauflandparser(words)
items = result.get("items")
date = result.get("date")
strReceiptNumber = result.get("bonid")
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)
def getPDFReceiptFromFile(strPDFFile: str):
try:
with open(strPDFFile) as doc:
return PDFReceipt(doc)
except FileNotFoundError as e:
return PDFReceipt(None)

View File

@ -1,6 +1,7 @@
from datetime import date
from flask import render_template
from flask_login import current_user
from src import LOGGER
def get_base_infos():
@ -15,4 +16,5 @@ def get_base_infos():
return infos
def render_custom_template(*args, **kwargs):
LOGGER.debug("Rendering template")
return render_template(*args, **kwargs, **get_base_infos())

View File

@ -7,6 +7,10 @@
onclick="window.location.href='{{ url_for('establishment.payment.insert_payment', establishment_id=establishment.id) }}'">
Zahlung hinzufügen
</button>
<button type="button" class="btn btn-outline-dark px-2" data-bs-toggle="button" autocomplete="off"
onclick="window.location.href='{{ url_for('receipts.upload.upload_receipt', establishment=establishment.id) }}'">
Quittung hinzufügen
</button>
{% endif %}
{% endif %}
{% for user in results %}
@ -34,6 +38,23 @@
</div>
</div>
{% endfor %}
{% if user.payments %}
<h3>Einzahlungen</h3>
{% for payments in user.payments %}
<div class="card-body">
<div class="col-sm-1"></div>
<div class="col">
<h4>{{ payments.date }}</h4>
<div class="row">
<div class="col-sm-1"></div>
<div class="col">
{{ payments.amount/100 }} €
</div>
</div>
</div>
</div>
{% endfor %}
{% endif %}
</div>
{% endif %}
</div>

View File

@ -1,10 +1,13 @@
{% extends "base.html" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% from 'utils/form/_render_field.html' import render_field %}
{% 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") }}
{{ render_field(form.hidden_tag) }}
{{ render_field(form.user) }}
{{ render_field(form.date) }}
{{ render_field(form.pdfReceipt) }}
{{ render_field(form.submit) }}
</form>
{% endblock %}

View File

@ -1,12 +1,18 @@
{% from 'utils/form/_render_radio_button.html' import render_field as render_radio %}
{% from 'utils/form/_render_generic_field.html' import render_field as render_generic %}
{% from 'utils/form/_render_hidden_field.html' import render_field as render_hidden %}
{% from 'utils/form/_render_submit_field.html' import render_field as render_submit %}
{% from 'utils/form/_render_file_field.html' import render_field as render_file %}
{% macro render_field(field) %}
{% if field.__class__.__name__ == "RadioField" %}
{{ render_radio(field) }}
{% elif field.__class__.__name__ == "HiddenField" %}
{{ render_hidden(field) }}
{% elif field.__class__.__name__ == "SubmitField" %}
{{ render_submit(field) }}
{% elif field.__class__.__name__ == "FileField" %}
{{ render_file(field) }}
{% else %}
{{ render_generic(field)}}
{% endif %}

View File

@ -0,0 +1,7 @@
{% macro render_field(field) %}
<div class="form-group">
File upload<br>
{{ field(**kwargs)|safe }}
{% include "utils/form/_render_field_errors.html" %}
</div>
{% endmacro %}

View File

@ -0,0 +1,6 @@
{% macro render_field(field) %}
<div class="form-group">
{{ field(**kwargs)|safe }}
{% include "utils/form/_render_field_errors.html" %}
</div>
{% endmacro %}