major: refactor, renaming, huge changes

Everything kinda changed. Huge refactoring.
Website now has sidebar instead of top navigation.
Database got huge changes.
More forms!
This commit is contained in:
Lunaresk 2022-02-20 02:04:16 +01:00
parent f713564238
commit ffd007a200
18 changed files with 649 additions and 311 deletions

View File

@ -28,5 +28,6 @@ app.config.from_file("configs/config.yaml", safe_load)
db = SQLAlchemy(app)
migrate = Migrate(app, db, render_as_batch=True)
login = LoginManager(app)
login.login_view = 'web_login'
from app import views, models
from app import routes, models

View File

@ -1,7 +1,7 @@
from app.models import Brand, Category
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField, SelectMultipleField, DateField, IntegerField, SelectField, FloatField
from wtforms.validators import DataRequired
from wtforms.validators import DataRequired, Optional
class LoginForm(FlaskForm):
username = StringField('Username', validators=[DataRequired()])
@ -15,8 +15,8 @@ class NewItemForm(FlaskForm):
description = StringField("Description", validators=[DataRequired()])
date = DateField("Insert Date", validators=[DataRequired()])
price_change = FloatField("Price", validators=[DataRequired()])
amount_change = IntegerField("Amount")
category = SelectMultipleField("Categories", choices=[(c.id, c.name) for c in Category.query.order_by("name").all()], validators=[DataRequired()])
amount_change = IntegerField("Amount", validators=[Optional()])
category = SelectMultipleField("Categories", choices=[(c.id, c.name) for c in Category.query.order_by("name").all()], validators=[Optional()])
brand = SelectField("Brand", choices=[(b.id, b.name) for b in Brand.query.order_by("name").all()], validators=[DataRequired()])
submit = SubmitField("Submit")
@ -26,4 +26,11 @@ class NewCategoryForm(FlaskForm):
class NewBrandForm(FlaskForm):
name = StringField("Name", validators=[DataRequired()])
submit = SubmitField("Submit")
class NeueWGForm(FlaskForm):
wg_name = StringField("Name", validators=[DataRequired()])
submit = SubmitField("Submit")
class WGBeitretenForm(FlaskForm):
submit = SubmitField("Submit")

View File

@ -1,7 +1,5 @@
from app import db, login
from flask_login import UserMixin
from sqlalchemy.sql import text
from sqlalchemy_utils.view import create_view
from werkzeug.security import generate_password_hash, check_password_hash
item_category = db.Table("item_category",
@ -10,11 +8,13 @@ item_category = db.Table("item_category",
)
class User(UserMixin, db.Model):
id = db.Column(db.String(10), primary_key=True)
name = db.Column(db.String(64), nullable=False)
id = db.Column(db.BigInteger, primary_key=True)
email = db.Column(db.String(64), nullable=False, unique=True)
username = db.Column(db.String(64), nullable=False, unique=True)
password_hash = db.Column(db.String(128), nullable=False)
Bought = db.relationship("Bought", backref='User', lazy='dynamic')
# Bought = db.relationship("Bought", backref='User', lazy='dynamic')
LoginToken = db.relationship("LoginToken", backref='User', lazy='dynamic')
def set_password(self, password):
self.password_hash = generate_password_hash(password)
@ -23,11 +23,24 @@ class User(UserMixin, db.Model):
return check_password_hash(self.password_hash, password)
def __repr__(self) -> str:
return f"<User {self.id} ({self.name})>"
return f"<User {self.id} ({self.username})>"
class Establishment(db.Model):
id = db.Column(db.BigInteger, primary_key=True)
name = db.Column(db.String(64), nullable=False)
LoginToken = db.relationship("LoginToken", backref='Establishment', lazy='dynamic')
def __repr__(self) -> str:
return f"<Establishment {self.id} ({self.name})>"
class LoginToken(db.Model):
user = db.Column(db.ForeignKey('user.id'), primary_key=True)
token = db.Column(db.String(32), nullable=False)
establishment = db.Column(db.ForeignKey('establishment.id'), primary_key=True)
token = db.Column(db.String(15), nullable=True, unique=True)
def __repr__(self) -> str:
return f"LoginToken {self.token}"
class Brand(db.Model):
id = db.Column(db.Integer, primary_key=True)
@ -38,7 +51,7 @@ class Brand(db.Model):
class Category(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(32), nullable=False)
name = db.Column(db.String(32), nullable=False, unique=True)
Item = db.relationship("Item", secondary=item_category, lazy="dynamic", back_populates="Category")
@ -60,12 +73,12 @@ class Item(db.Model):
return f"<Item {self.id} ({self.name})>"
class Bought(db.Model):
user = db.Column(db.ForeignKey('user.id'), primary_key=True)
token = db.Column(db.ForeignKey('login_token.token'), primary_key=True)
item = db.Column(db.ForeignKey('item.id'), primary_key=True)
date = db.Column(db.Date, primary_key=True)
amount = db.Column(db.SmallInteger, nullable=False)
# registered = db.Column(db.Boolean, nullable=False, default=False)
# paid = db.Column(db.SmallInteger, nullable=False, default=0)
registered = db.Column(db.Boolean, nullable=False, default=False)
paid = db.Column(db.SmallInteger, nullable=False, default=0)
def __repr__(self) -> str:
return f"<Bought Object>"
@ -103,30 +116,6 @@ class ItemReceipt(db.Model):
def __repr__(self) -> str:
return f"<ItemReceipt {self.receipt}: {self.item}>"
def query_price_per_amount_view():
p = db.aliased(PriceChange, name="p")
a = db.aliased(AmountChange, name="a")
date = db.func.greatest(p.date, a.date).label("date")
price = (db.func.ceil(p.price.cast(db.Float)/db.func.coalesce(a.amount, 1))/100).label("price")
select = db.select(p.item.label("item"), date, price)
select = select.distinct(p.item, date)
select = select.join(a, p.item==a.item, isouter=True)
select = select.order_by(p.item, db.desc(db.func.greatest(p.date, a.date)))
return select
price_per_amount = create_view("price_per_amount", query_price_per_amount_view(), db.metadata)
def query_bought_with_prices_view():
b = db.aliased(Bought, name="b")
ppa = price_per_amount.alias("ppa")
select = db.select(b.user.label("user"), b.date.label("date"), b.item.label("item"), b.amount.label("amount"), ppa.c.price.label("price"))
select = select.distinct(b.user, b.date, b.item)
select = select.join(ppa, b.item==ppa.c.item)
select = select.where(ppa.c.date<=b.date)
select = select.order_by(db.desc(b.user), db.desc(b.date), db.desc(b.item), db.desc(ppa.c.date))
return select
bought_with_prices = create_view("bought_with_prices", query_bought_with_prices_view(), db.metadata)
@login.user_loader
def load_user(id):

116
app/routes.py Normal file
View File

@ -0,0 +1,116 @@
from app import app, db, LOGGER
from app.forms import NewItemForm, LoginForm
from app.models import LoginToken, User, Item, Brand, Category, PriceChange, AmountChange
from app.utils import view_utils, database_utils, routes_utils
from datetime import date
from flask import abort, flash, redirect, request, url_for
from flask.json import jsonify
from flask_login import current_user, login_required, login_user, logout_user
from werkzeug.urls import url_parse
APPNAME = "scan2kasse"
render_template = routes_utils.render_custom_template
@app.route('/')
def index():
return "<h1>Hello, World!</h>", 200
@app.route('/test')
def test():
if request.args:
LOGGER.debug(request.args['testing'])
form = NewItemForm()
return render_template("test.html", form=form)
@app.route(f'/{APPNAME}/token_authorization')
def token_authorization():
LOGGER.debug("Token Login")
if not request.json or 'login' not in request.json:
abort(400)
if not LoginToken.query.filter_by(token=request.json['login']).first():
abort(403)
return jsonify({}), 200
@app.route(f'/{APPNAME}/token_insert', methods=['POST'])
def insert():
match request.json:
case {'user': user, 'items': items, 'date': date}:
failed = database_utils.insert_bought_items(user, items, date)
case {'user': user, 'items': items}:
failed = database_utils.insert_bought_items(user, items)
case _:
abort(400)
if failed:
return jsonify(failed), 400
return jsonify({'inserted': True}), 201
@app.route(f'/{APPNAME}/login', methods=['GET', 'POST'])
def web_login():
if current_user.is_authenticated:
return redirect(url_for('index'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data).first()
if user is None or not user.check_password(form.password.data):
flash('Invalid username or password')
return redirect(url_for('web_login'))
login_user(user, remember=form.remember_me.data)
next_page = request.args.get('next')
if not next_page or url_parse(next_page).netloc != '':
next_page = url_for('index')
return redirect(next_page)
return render_template('login.html', title='Sign In', form=form)
@app.route(f'/{APPNAME}/newitem', methods=['GET', 'POST'])
@login_required
def new_item():
if current_user.is_anonymous:
abort(403)
form=NewItemForm()
if form.is_submitted():
LOGGER.debug("submitted")
if form.validate():
LOGGER.debug("valid")
LOGGER.debug(form.errors)
if form.validate_on_submit():
LOGGER.debug("valid form")
brand = Brand.query.get(form.brand.data)
new_item = Item(id = form.id.data, name = form.name.data, brand = brand.id, description = form.description.data)
# if form.category.data:
# category = Category.query.get(id = form.category.data)
# new_item.Category = category
new_item.PriceChange = [PriceChange(Item = new_item, date = date(2021, 12, 1), price = form.price_change.data)]
if 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.commit()
return redirect(url_for('index'))
return render_template('admin/new_item.html', form=form)
@app.route(f'/{APPNAME}/overview', methods=['GET'])
def get_report_from_user():
if 'month' in request.args:
try:
month = int(request.args['month'])
except Exception as e:
LOGGER.exception("")
abort(400)
else:
if (month > 12 or month < 1):
abort(400)
LOGGER.info("Getting results.")
results = database_utils.get_report(kwargs = request.args)
LOGGER.debug(f"Results received: {results}")
if results:
result_list = view_utils.group_results(results)
else:
result_list = []
if request.content_type == "application/json":
return jsonify(result_list)
else:
return render_template("overview.html", results=result_list)

89
app/static/sidebars.css Normal file
View File

@ -0,0 +1,89 @@
body {
min-height: 100vh;
min-height: -webkit-fill-available;
}
html {
height: -webkit-fill-available;
}
main {
display: flex;
flex-wrap: nowrap;
height: 100vh;
height: -webkit-fill-available;
max-height: 100vh;
overflow-x: auto;
overflow-y: hidden;
}
.b-example-divider {
flex-shrink: 0;
width: 1.5rem;
height: 100vh;
background-color: rgba(0, 0, 0, .1);
border: solid rgba(0, 0, 0, .15);
border-width: 1px 0;
box-shadow: inset 0 .5em 1.5em rgba(0, 0, 0, .1), inset 0 .125em .5em rgba(0, 0, 0, .15);
}
.bi {
vertical-align: -.125em;
pointer-events: none;
fill: currentColor;
}
.dropdown-toggle { outline: 0; }
.nav-flush .nav-link {
border-radius: 0;
}
.btn-toggle {
display: inline-flex;
align-items: center;
padding: .25rem .5rem;
font-weight: 600;
color: rgba(0, 0, 0, .65);
background-color: transparent;
border: 0;
}
.btn-toggle:hover,
.btn-toggle:focus {
color: rgba(0, 0, 0, .85);
background-color: #d2f4ea;
}
.btn-toggle::before {
width: 1.25em;
line-height: 0;
content: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='rgba%280,0,0,.5%29' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M5 14l6-6-6-6'/%3e%3c/svg%3e");
transition: transform .35s ease;
transform-origin: .5em 50%;
}
.btn-toggle[aria-expanded="true"] {
color: rgba(0, 0, 0, .85);
}
.btn-toggle[aria-expanded="true"]::before {
transform: rotate(90deg);
}
.btn-toggle-nav a {
display: inline-flex;
padding: .1875rem .5rem;
margin-top: .125rem;
margin-left: 1.25rem;
text-decoration: none;
}
.btn-toggle-nav a:hover,
.btn-toggle-nav a:focus {
background-color: #d2f4ea;
}
.scrollarea {
overflow-y: auto;
}
.fw-semibold { font-weight: 600; }
.lh-tight { line-height: 1.25; }

View File

@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block 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>
</form>
{% endblock %}

View File

@ -4,40 +4,90 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href={{ url_for('static', filename="sidebars.css")}}>
{% if title %}
<title>{{ title }}</title>
{% else %}
<title>Scan2Kasse</title>
{% endif %}
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="#">Navbar</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">Home</a>
<main>
<div class="flex-shrink-0 p-3 bg-white" style="width: 280px;">
<a href="/" class="d-flex align-items-center pb-3 mb-3 link-dark text-decoration-none border-bottom">
<svg class="bi me-2" width="30" height="24"><use xlink:href="#bootstrap"></use></svg>
<span class="fs-5 fw-semibold">Scan2Kasse</span>
</a>
<ul class="list-unstyled ps-0">
<li class="mb-1">
<button class="btn btn-toggle align-items-center rounded collapsed" data-bs-toggle="collapse" data-bs-target="#home-collapse" aria-expanded="true">
Home
</button>
<div class="collapse show" id="home-collapse">
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small">
<li><a href="#" class="link-dark rounded">Übersicht</a></li>
<li><a href="#" class="link-dark rounded">Updates</a></li>
<li><a href="#" class="link-dark rounded">Reports</a></li>
</ul>
</div>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Overview
</a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdown">
<li><a class="dropdown-item" href="#">Monthly</a></li>
<li><a class="dropdown-item" href="#">Yearly</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#">Full Overview</a></li>
</ul>
<li class="mb-1">
<button class="btn btn-toggle align-items-center rounded collapsed" data-bs-toggle="collapse" data-bs-target="#dashboard-collapse" aria-expanded="false">
Dashboard
</button>
<div class="collapse" id="dashboard-collapse">
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small">
<li><a href="#" class="link-dark rounded">Overview</a></li>
<li><a href="#" class="link-dark rounded">Weekly</a></li>
<li><a href="#" class="link-dark rounded">Monthly</a></li>
<li><a href="#" class="link-dark rounded">Annually</a></li>
</ul>
</div>
</li>
</ul>
<!-- <li class="mb-1">
<button class="btn btn-toggle align-items-center rounded collapsed" data-bs-toggle="collapse" data-bs-target="#orders-collapse" aria-expanded="false">
Orders
</button>
<div class="collapse" id="orders-collapse">
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small">
<li><a href="#" class="link-dark rounded">New</a></li>
<li><a href="#" class="link-dark rounded">Processed</a></li>
<li><a href="#" class="link-dark rounded">Shipped</a></li>
<li><a href="#" class="link-dark rounded">Returned</a></li>
</ul>
</div>
</li> -->
<li class="border-top my-3"></li>
<li class="mb-1">
<button class="btn btn-toggle align-items-center rounded collapsed" data-bs-toggle="collapse" data-bs-target="#account-collapse" aria-expanded="false">
Account
</button>
<div class="collapse" id="account-collapse" style="">
<ul class="btn-toggle-nav list-unstyled fw-normal pb-1 small">
<li><a href="#" class="link-dark rounded">New...</a></li>
<li><a href="#" class="link-dark rounded">Profile</a></li>
<li><a href="#" class="link-dark rounded">Settings</a></li>
<li><a href="#" class="link-dark rounded">Sign out</a></li>
</ul>
</div>
</li>
</ul>
</div>
{% with messages = get_flashed_messages() %}
{% if messages %}
<ul>
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
<div class="container">
<div class="row-md-3">
{% block content %}{% endblock %}
</div>
</div>
</nav>
<div class="container">
<div class="row-md-3">
{% block content %}{% endblock %}
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
</body>
</html>

24
app/templates/login.html Normal file
View File

@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block content %}
<h1>Sign In</h1>
<form action="" method="post" novalidate>
{{ form.hidden_tag() }}
<p>
{{ form.username.label }}<br>
{{ form.username(size=32) }}
{% for error in form.username.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>
{{ form.password.label }}<br>
{{ form.password(size=32) }}
{% for error in form.password.errors %}
<span style="color: red;">[{{ error }}]</span>
{% endfor %}
</p>
<p>{{ form.remember_me() }} {{ form.remember_me.label }}</p>
<p>{{ form.submit() }}</p>
</form>
{% endblock %}

View File

@ -1,27 +1,31 @@
{% extends "base.html" %}
{% block content %}
{% for user, dates in results.items() %}
<div class="row">
<h3>{{ user }}: {{ dates.sum }} €</h3>
{% for date, items in dates.items() %}
{% if date != "sum" %}
<div class="row">
<div class="col-sm-1"></div>
<div class="col">
<h4>{{ date }}</h4>
{% for item, values in items.items() %}
<div class="row">
<div class="col-sm-1"></div>
<div class="col">
{{ values.0 }}x {{ item }} je {{ values.1 }}
</div>
{% for user in results %}
<div class="card">
<a class="btn btn-primary" data-bs-toggle="collapse" href="#b{{ user.id }}" role="button" aria-expanded="false" aria-controls="b{{ user.id }}">
<div class="card-header">
<h3>{{ user.username }}: {{ user.sum/100 }} €</h3>
</div>
</a>
<div class="collapse" id="b{{ user.id }}">
{% for item_infos in user.item_infos %}
<div class="card-body">
<div class="col-sm-1"></div>
<div class="col">
<h4>{{ item_infos.date }}</h4>
{% for item in item_infos.item_list %}
<div class="row">
<div class="col-sm-1"></div>
<div class="col">
{{ item.amount }}x {{ item.name }} je {{ item.price/100 }} €
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endfor %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% endblock %}

View File

@ -1,17 +1,19 @@
from calendar import month
from app import db, LOGGER
from app.models import Bought, bought_with_prices
from app.models import Bought, Item, LoginToken, User
from app.utils.view_utils import bought_with_prices as bwp
from copy import deepcopy
from datetime import date as dtdate, timedelta
from psycopg2 import errors
from random import choice as rndchoice
from sqlalchemy import text
from sqlalchemy.dialects.postgresql import insert
from string import ascii_letters, digits
def insert_bought_items(user: str, items: dict, date: str = None):
def insert_bought_items(token: str, items: dict, date: str = None):
if not date:
date = dtdate.today()
for item, amount in deepcopy(items).items():
query_insert = insert(Bought).values(user=user, item=int(item), date=date, amount=int(amount))
query_insert = insert(Bought).values(token=token, item=int(item), date=date, amount=int(amount))
query_insert = query_insert.on_conflict_do_update("bought_pkey", set_=dict(amount=text(f'bought.amount + {amount}')))
try:
db.session.execute(query_insert)
@ -20,20 +22,28 @@ def insert_bought_items(user: str, items: dict, date: str = None):
db.session.rollback()
except Exception as e:
db.session.rollback()
LOGGER.exception()
LOGGER.exception("")
else:
del(items[item])
return {'user':user, 'date': date, 'items': items} if items else {}
return {'user':token, 'date': date, 'items': items} if items else {}
def get_report(**kwargs):
query_select = bought_with_prices.select()
query_select = db.session.query(bwp.c.token, User.username, bwp.c.date, bwp.c.item, Item.name, bwp.c.amount, bwp.c.price)
query_select = query_select.select_from(bwp).join(LoginToken, LoginToken.token==bwp.c.token).join(User, LoginToken.user==User.id).join(Item, Item.id==bwp.c.item)
if "user" in kwargs:
query_select = query_select.where(bought_with_prices.c.user == kwargs['user'])
query_select = query_select.where(bwp.c.token == kwargs['user'])
match kwargs:
case {"month": month}:
year = kwargs["year"] if "year" in kwargs else dtdate.today().year
query_select = query_select.where(bought_with_prices.c.date.between(dtdate(int(year), int(month), 1), dtdate(int(year), int(month)+1, 1)-timedelta(days=1)))
query_select = query_select.where(bwp.c.date.between(dtdate(int(year), int(month), 1), dtdate(int(year), int(month)+1, 1)-timedelta(days=1)))
case {"year": year}:
query_select = query_select.where(bought_with_prices.c.date.between(dtdate(int(year), 1, 1), dtdate(int(year), 12, 31)))
results = db.session.execute(query_select)
return tuple(results)
query_select = query_select.where(bwp.c.date.between(dtdate(int(year), 1, 1), dtdate(int(year), 12, 31)))
query_select = query_select.order_by(bwp.c.token, bwp.c.date, bwp.c.item)
results = query_select.all()
return tuple(results)
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():
return new_token
return generate_token()

18
app/utils/routes_utils.py Normal file
View File

@ -0,0 +1,18 @@
from datetime import date
from flask import render_template
from flask_login import current_user
def get_base_infos():
infos = {}
infos['now'] = date.today()
infos['current_user'] = current_user
if current_user.is_authenticated:
tokens = current_user.LoginToken.all()
establishments = [logintoken.Establishment for logintoken in tokens]
if establishments:
infos['establishments'] = establishments
return infos
def render_custom_template(*args, **kwargs):
return render_template(*args, **kwargs, **get_base_infos())

View File

@ -1,18 +1,55 @@
from app import LOGGER
from app import db, LOGGER
from app.models import AmountChange, Bought, PriceChange
from datetime import date
from flask import render_template
from flask_login import current_user
from sqlalchemy_utils import create_view
def group_results(results: tuple) -> dict:
result_dict = {}
def group_results(results: tuple) -> list:
result_list = []
LOGGER.debug("Grouping...")
for result in results:
if result[0] not in result_dict:
result_dict[result[0]] = {"sum": 0}
if str(result[1]) not in result_dict[result[0]]:
result_dict[result[0]][str(result[1])] = {}
result_dict[result[0]][str(result[1])][result[2]] = (
result[3], result[4])
price = int(result[3]) * int(float(result[4].split(" ")[0].replace(",", "."))*100)
result_dict[result[0]]["sum"] += price
for key in result_dict.keys():
result_dict[key]["sum"] /= 100
try:
result_user_index = [result[0] == result_item['id'] for result_item in result_list].index(True)
except ValueError as e:
result_list.append({"id": result[0], "name": result[1], "sum": 0, "item_infos": []})
result_user_index = -1
result_user = result_list[result_user_index]
try:
result_date_index = [result[2] == result_list_date['date'] for result_list_date in result_user["item_infos"]].index(True)
except ValueError as e:
result_user["item_infos"].append({'date': result[2], 'item_list': []})
result_date_index = -1
result_date = result_user['item_infos'][result_date_index]
result_date['item_list'].append({'id': result[3], 'name': result[4], 'amount': result[5], 'price': result[6]})
for result_user in result_list:
for result_date in result_user['item_infos']:
for result_item in result_date['item_list']:
result_user['sum'] += result_item['amount'] * result_item['price']
LOGGER.debug("Grouped.")
return result_dict
return result_list
def selectable_price_per_amount_view():
p = db.aliased(PriceChange, name="p")
a = db.aliased(AmountChange, name="a")
date = db.func.greatest(p.date, a.date).label("date")
price = db.func.ceil(p.price.cast(db.Float)/db.func.coalesce(a.amount, 1)).label("price")
select = db.select(p.item.label("item"), date, price)
select = select.distinct(p.item, date)
select = select.join(a, p.item==a.item, isouter=True)
select = select.order_by(p.item, db.desc(db.func.greatest(p.date, a.date)))
return select
price_per_amount = create_view("price_per_amount", selectable_price_per_amount_view(), db.metadata)
def selectable_bought_with_prices_view():
b = db.aliased(Bought, name="b")
ppa = price_per_amount.alias("ppa")
select = db.select(b.token.label("token"), b.date.label("date"), b.item.label("item"), b.amount.label("amount"), ppa.c.price.label("price"))
select = select.distinct(b.token, b.date, b.item)
select = select.join(ppa, b.item==ppa.c.item)
select = select.where(ppa.c.date<=b.date)
select = select.order_by(db.desc(b.token), db.desc(b.date), db.desc(b.item), db.desc(ppa.c.date))
return select
bought_with_prices = create_view("bought_with_prices", selectable_bought_with_prices_view(), db.metadata)

View File

@ -1,66 +0,0 @@
from app import app, LOGGER
#from app.forms import NewItemForm
from app.models import User
from flask import abort, request, render_template
from flask.json import jsonify
from app.utils import view_utils, database_utils
APPNAME = "scan2kasse"
@app.route('/')
def index():
return "<h1>Hello, World!</h>", 200
@app.route('/test')
def test():
if request.args:
LOGGER.debug(request.args['testing'])
#form = NewItemForm()
#return render_template("test.html", form=form)
@app.route(f'/{APPNAME}/login')
def login():
if not request.json or 'login' not in request.json:
abort(400)
if not User.query.get(request.json['login']):
abort(403)
return jsonify({}), 200
@app.route(f'/{APPNAME}/insert', methods=['POST'])
def insert():
match request.json:
case {'user': user, 'items': items, 'date': date}:
failed = database_utils.insert_bought_items(user, items, date)
case {'user': user, 'items': items}:
failed = database_utils.insert_bought_items(user, items)
case _:
abort(400)
if failed:
return jsonify(failed), 400
return jsonify({'inserted': True}), 201
@app.route(f'/{APPNAME}/overview', methods=['GET'])
def get_report_from_user():
user, month, year = [None]*3
if request.args:
args = request.args
if 'month' in args:
month = int(args['month'])
if 'year' in args:
year = int(args['year'])
if month and (month > 12 or month < 1):
abort(400)
LOGGER.info("Getting results.")
results = database_utils.get_report(user=user, year=year, month=month)
LOGGER.debug(f"Results received: {results}")
if results:
result_dict = view_utils.group_results(results)
else:
result_dict = {}
if request.content_type == "application/json":
return jsonify(result_dict)
else:
return render_template("overview.html", results=result_dict)

View File

@ -0,0 +1,39 @@
"""create views
Revision ID: 3d5fb9e86939
Revises: dbf88acb76bc
Create Date: 2022-02-20 01:58:37.444542
"""
from alembic import op
import sqlalchemy as sa
from app import db
from app.utils.view_utils import selectable_price_per_amount_view, selectable_bought_with_prices_view
from sqlalchemy_utils import create_view
# revision identifiers, used by Alembic.
revision = '3d5fb9e86939'
down_revision = 'dbf88acb76bc'
branch_labels = None
depends_on = None
def upgrade():
metadata = sa.MetaData()
create_view('price_per_amount',
selectable_price_per_amount_view(),
metadata
)
create_view('bought_with_prices',
selectable_bought_with_prices_view(),
metadata
)
metadata.create_all(db.engine)
def downgrade():
with db.engine.connect() as con:
con.execute("DROP VIEW bought_with_prices;")
con.execute("DROP VIEW price_per_amount;")

View File

@ -1,88 +0,0 @@
"""full structure
Revision ID: 60e8f49dee49
Revises:
Create Date: 2022-02-03 19:24:24.715589
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '60e8f49dee49'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('brand',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=32), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('category',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=32), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('user',
sa.Column('id', sa.String(length=10), nullable=False),
sa.Column('name', sa.String(length=64), nullable=True),
sa.Column('password_hash', sa.String(length=128), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('item',
sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('name', sa.String(length=64), nullable=True),
sa.Column('brand', sa.Integer(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['brand'], ['brand.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('amount_change',
sa.Column('item', sa.BigInteger(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('amount', sa.SmallInteger(), nullable=True),
sa.ForeignKeyConstraint(['item'], ['item.id'], ),
sa.PrimaryKeyConstraint('item', 'date')
)
op.create_table('bought',
sa.Column('user', sa.String(length=10), nullable=False),
sa.Column('item', sa.BigInteger(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('amount', sa.SmallInteger(), nullable=True),
sa.ForeignKeyConstraint(['item'], ['item.id'], ),
sa.ForeignKeyConstraint(['user'], ['user.id'], ),
sa.PrimaryKeyConstraint('user', 'item', 'date')
)
op.create_table('item_category',
sa.Column('item', sa.BigInteger(), nullable=False),
sa.Column('category', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['category'], ['category.id'], ),
sa.ForeignKeyConstraint(['item'], ['item.id'], ),
sa.PrimaryKeyConstraint('item', 'category')
)
op.create_table('price_change',
sa.Column('item', sa.BigInteger(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('price', sa.SmallInteger(), nullable=True),
sa.ForeignKeyConstraint(['item'], ['item.id'], ),
sa.PrimaryKeyConstraint('item', 'date')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('price_change')
op.drop_table('item_category')
op.drop_table('bought')
op.drop_table('amount_change')
op.drop_table('item')
op.drop_table('user')
op.drop_table('category')
op.drop_table('brand')
# ### end Alembic commands ###

View File

@ -0,0 +1,127 @@
"""full structure
Revision ID: dbf88acb76bc
Revises:
Create Date: 2022-02-20 01:58:13.735541
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'dbf88acb76bc'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('brand',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=32), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('category',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=32), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('establishment',
sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('name', sa.String(length=64), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('receipt',
sa.Column('id', sa.Numeric(precision=22, scale=0), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('registered', sa.Boolean(), nullable=False),
sa.Column('paid', sa.SmallInteger(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('user',
sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('email', sa.String(length=64), nullable=False),
sa.Column('username', sa.String(length=64), nullable=False),
sa.Column('password_hash', sa.String(length=128), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email'),
sa.UniqueConstraint('username')
)
op.create_table('item',
sa.Column('id', sa.BigInteger(), nullable=False),
sa.Column('name', sa.String(length=64), nullable=False),
sa.Column('brand', sa.Integer(), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.ForeignKeyConstraint(['brand'], ['brand.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('login_token',
sa.Column('user', sa.BigInteger(), nullable=False),
sa.Column('establishment', sa.BigInteger(), nullable=False),
sa.Column('token', sa.String(length=15), nullable=True),
sa.ForeignKeyConstraint(['establishment'], ['establishment.id'], ),
sa.ForeignKeyConstraint(['user'], ['user.id'], ),
sa.PrimaryKeyConstraint('user', 'establishment'),
sa.UniqueConstraint('token')
)
op.create_table('amount_change',
sa.Column('item', sa.BigInteger(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('amount', sa.SmallInteger(), nullable=False),
sa.ForeignKeyConstraint(['item'], ['item.id'], ),
sa.PrimaryKeyConstraint('item', 'date')
)
op.create_table('bought',
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.Column('registered', sa.Boolean(), nullable=False),
sa.Column('paid', sa.SmallInteger(), nullable=False),
sa.ForeignKeyConstraint(['item'], ['item.id'], ),
sa.ForeignKeyConstraint(['token'], ['login_token.token'], ),
sa.PrimaryKeyConstraint('token', 'item', 'date')
)
op.create_table('item_category',
sa.Column('item', sa.BigInteger(), nullable=False),
sa.Column('category', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['category'], ['category.id'], ),
sa.ForeignKeyConstraint(['item'], ['item.id'], ),
sa.PrimaryKeyConstraint('item', 'category')
)
op.create_table('item_receipt',
sa.Column('receipt', sa.Numeric(precision=22, scale=0), nullable=False),
sa.Column('item', sa.BigInteger(), nullable=False),
sa.Column('amount', sa.SmallInteger(), nullable=False),
sa.ForeignKeyConstraint(['item'], ['item.id'], ),
sa.ForeignKeyConstraint(['receipt'], ['receipt.id'], ),
sa.PrimaryKeyConstraint('receipt', 'item')
)
op.create_table('price_change',
sa.Column('item', sa.BigInteger(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('price', sa.SmallInteger(), nullable=False),
sa.ForeignKeyConstraint(['item'], ['item.id'], ),
sa.PrimaryKeyConstraint('item', 'date')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('price_change')
op.drop_table('item_receipt')
op.drop_table('item_category')
op.drop_table('bought')
op.drop_table('amount_change')
op.drop_table('login_token')
op.drop_table('item')
op.drop_table('user')
op.drop_table('receipt')
op.drop_table('establishment')
op.drop_table('category')
op.drop_table('brand')
# ### end Alembic commands ###

View File

@ -1,44 +0,0 @@
"""add views
Revision ID: f79ee0125ba6
Revises: 60e8f49dee49
Create Date: 2022-02-07 13:29:26.663482
"""
import sqlalchemy as sa
from app import db
from app.models import query_price_per_amount_view, query_bought_with_prices_view
from alembic import op
from sqlalchemy_utils import create_view
# revision identifiers, used by Alembic.
revision = 'f79ee0125ba6'
down_revision = '60e8f49dee49'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
metadata = sa.MetaData()
create_view('price_per_amount',
query_price_per_amount_view(),
metadata
)
create_view('bought_with_prices',
query_bought_with_prices_view(),
metadata
)
metadata.create_all(db.engine)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with db.engine.connect() as con:
con.execute("DROP VIEW bought_with_prices;")
con.execute("DROP VIEW price_per_amount;")
#op.drop_table('bought_with_prices')
#op.drop_table('price_per_amount')
# ### end Alembic commands ###

3
run.py
View File

@ -4,7 +4,8 @@ from gevent.pywsgi import WSGIServer
@app.shell_context_processor
def make_shell_context():
return {'db': db, 'User': User, 'Bought': Bought, 'Item': Item}
return {'db': db, 'User': User, 'Bought': Bought, 'Item': Item,
"LoginToken": LoginToken, "Establishment": Establishment, "Receipt": Receipt}
if __name__ == '__main__':
http_server = WSGIServer(('', 5000), app)