Initial commit - existing project files

This commit is contained in:
W13R 2022-03-16 12:11:30 +01:00
commit c49798a9ea
82 changed files with 4304 additions and 0 deletions

14
.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
/config/*
/static/admin
/application/**/migrations/*
/archive/*
/logs/*
/temp
/tmp
__pycache__
.vscode
*.pem
!/config/config.sample.sh
!/config/Caddyfile
!/config/tls/
!.gitkeep

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Julian Müller (W13R)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

42
README.md Normal file
View file

@ -0,0 +1,42 @@
# Drinks Manager
Can't keep track of the number of drinks your guests drink?
Now you have a web interface that *really tries* to make things less complicated- for
you and your guests.
This (exaggeration intended) most incredible piece of software is written in Python,
HTML, CSS, JS, Bash and uses Django and PostgreSQL.
You have to bring your own PostgreSQL Database though.
## Setup, Installation, Updating and Dependencies
see [Setup](docs/Setup.md)
## Configuration
see [Configuration](docs/Configuration.md)
## Usage
After setup, run ```./run.sh help``` to see a help text.
Start the production server with ```./run.sh server```. You can ignore the error message about the "lifespan error".
For more commands, see [Commands](docs/Commands.md).
## Versions
You can find the latest releases [here](https://gitlab.com/W13R/drinks-manager/-/releases). For Installation/Updating, you should consider using git, though (for more information see [Setup](docs/Setup.md)).
The releases are versioned after the following scheme:
`MAJOR`.`MINOR`
- `MAJOR`: will include changes
-> may be incompatible with the previous version
- `MINOR`: will only include bugfixes and smaller changes
-> may not be incompatible with the previous version

View file

138
application/app/admin.py Normal file
View file

@ -0,0 +1,138 @@
#
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.views.decorators.cache import never_cache
from .models import User
from .models import Drink
from .models import Order
from .models import Global
from .models import RegisterTransaction as Register
from .forms import CustomUserChangeForm
from .forms import CustomDrinkForm
from .forms import CustomGlobalForm
from .forms import CustomRegisterTransactionForm
# Admin Site
class CustomAdminSite(admin.AdminSite):
site_header = "Drinks Administration"
site_title = "Drinks Administration"
@never_cache
def index(self, request, extra_context=None):
return super().index(request, extra_context={
"registerBalance": "{:10.2f}".format(
Global.objects.get(name="register_balance").value_float
),
"admin_info": Global.objects.get(name="admin_info").value_string,
**(extra_context or {})
})
adminSite = CustomAdminSite()
# Register your models here.
class CustomUserAdmin(UserAdmin):
model = User
form = CustomUserChangeForm
fieldsets_ = list((*UserAdmin.fieldsets,))
fieldsets_.insert(1, (
"Balance",
{"fields": ("balance", "allow_order_with_negative_balance")},
)
)
fieldsets = tuple(fieldsets_)
list_display = ["username", "balance", "is_active", "allow_order_with_negative_balance"]
def get_actions(self, request): # remove the "delete_selected" action because it breaks some functionality
actions = super().get_actions(request)
if 'delete_selected' in actions:
del actions['delete_selected']
return actions
adminSite.register(User, CustomUserAdmin)
class CustomDrinkAdmin(admin.ModelAdmin):
model = Drink
form = CustomDrinkForm
list_display = ["product_name", "content_litres", "price", "available", "binary_availability", "deleted"]
adminSite.register(Drink, CustomDrinkAdmin)
class CustomRegisterAdmin(admin.ModelAdmin):
model = Register
form = CustomRegisterTransactionForm
site_title = "Register"
list_display = ["datetime", "transaction_sum", "user", "comment"]
actions = ["delete_selected_new"]
def get_actions(self, request): # remove the "delete_selected" action because it breaks some functionality
actions = super().get_actions(request)
if 'delete_selected' in actions:
del actions['delete_selected']
return actions
def delete_selected_new(self, request, queryset):
#print(queryset)
for supply in queryset:
#print(order)
supply.delete()
if queryset.count() < 2:
self.message_user(request, f"Revoked {queryset.count()} supply.")
else:
self.message_user(request, f"Revoked {queryset.count()} supplies.")
delete_selected_new.short_description = "Revoke selected transactions"
adminSite.register(Register, CustomRegisterAdmin)
class CustomOrderAdmin(admin.ModelAdmin):
model = Order
list_display = ["product_name", "amount", "price_sum", "user", "datetime"]
actions = ["delete_selected_new"]
def get_actions(self, request): # remove the "delete_selected" action because it breaks some functionality
actions = super().get_actions(request)
if 'delete_selected' in actions:
del actions['delete_selected']
return actions
def delete_selected_new(self, request, queryset):
#print(queryset)
for order in queryset:
#print(order)
order.delete()
self.message_user(request, f"Revoked {queryset.count()} order(s).")
delete_selected_new.short_description = "Revoke selected orders"
adminSite.register(Order, CustomOrderAdmin)
class CustomGlobalAdmin(admin.ModelAdmin):
model = Global
form = CustomGlobalForm
list_display = ["name", "value_float", "value_string"]
def get_actions(self, request): # remove the "delete_selected" action because it breaks some functionality
actions = super().get_actions(request)
if 'delete_selected' in actions:
del actions['delete_selected']
return actions
adminSite.register(Global, CustomGlobalAdmin)

7
application/app/apps.py Normal file
View file

@ -0,0 +1,7 @@
from django.apps import AppConfig
from django.contrib.admin.apps import AdminConfig
class DAppConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'app'

View file

@ -0,0 +1,16 @@
from django.conf import settings
from .models import Global
def app_version(request):
try:
global_message = Global.objects.get(pk="global_message").value_string
except Global.DoesNotExist:
global_message = ""
return {
"app_version": settings.APP_VERSION,
"currency_suffix": settings.CURRENCY_SUFFIX,
"global_message": global_message
}

47
application/app/forms.py Normal file
View file

@ -0,0 +1,47 @@
from django import forms
from django.conf import settings
from django.contrib.auth.forms import UserChangeForm
from .models import User
from .models import Drink
from .models import RegisterTransaction
from .models import Global
class CustomUserChangeForm(UserChangeForm):
balance = forms.DecimalField(max_digits=8, decimal_places=2, initial=0.00, label=f"Balance {settings.CURRENCY_SUFFIX}")
class Meta:
model = User
fields = ("username", "balance")
class CustomDrinkForm(forms.ModelForm):
product_name = forms.CharField(max_length=64, label="Product Name")
content_litres = forms.DecimalField(max_digits=6, decimal_places=3, initial=0.5, label="Content (l)")
price = forms.DecimalField(max_digits=6, decimal_places=2, label=f"Price {settings.CURRENCY_SUFFIX}")
class Meta:
model = Drink
fields = ("product_name", "content_litres", "price", "binary_availability", "available", "deleted")
class CustomRegisterTransactionForm(forms.ModelForm):
class Meta:
model = RegisterTransaction
fields = ("transaction_sum", "datetime", "is_user_deposit", "comment", "user")
class CustomGlobalForm(forms.ModelForm):
comment = forms.CharField(widget=forms.Textarea, required=False)
value_float = forms.FloatField(initial=0.00)
value_string = forms.CharField(widget=forms.Textarea, required=False)
class Meta:
model = Global
fields = ("name", "comment", "value_float", "value_string")

160
application/app/models.py Normal file
View file

@ -0,0 +1,160 @@
from django.db import models
from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django_currentuser.db.models import CurrentUserField
from django.forms import ValidationError
from django.utils import timezone
# helper
def make_register_transaction(transaction_sum:float):
regbalance = Global.objects.get(name="register_balance")
regbalance.value_float += float(round(float(transaction_sum), 2))
regbalance.save()
# Custom user model
class User(AbstractUser):
balance = models.DecimalField(max_digits=8, decimal_places=2, default=0.00)
allow_order_with_negative_balance = models.BooleanField(default=False)
def delete(self, *args, **kwargs):
self.balance = 0
self.is_active = False
self.username = f"<deleted user #{self.pk}>"
self.last_name = ""
self.first_name = ""
self.email = ""
super().save()
#
class Drink(models.Model):
product_name = models.CharField(max_length=64)
content_litres = models.DecimalField(max_digits=6, decimal_places=3, default=0.5)
price = models.DecimalField(max_digits=6, decimal_places=2, default=0.00)
available = models.PositiveIntegerField(default=0)
deleted = models.BooleanField(default=False)
# when the following field is true:
# available > 0 -> there is a indefinetly amount of drinks left
# available < 1 -> there are no drinks left
binary_availability = models.BooleanField(default=False)
def delete(self, *args, **kwargs):
self.deleted = True
super().save()
def __str__(self): return f"{self.product_name} ({str(self.content_litres).rstrip('0')}l) - {self.price}{settings.CURRENCY_SUFFIX}"
class RegisterTransaction(models.Model):
class Meta:
verbose_name = "register transaction"
verbose_name_plural = "register"
transaction_sum = models.DecimalField(max_digits=6, decimal_places=2, default=0.00)
# the following original_transaction_sum is needed when need to be
# updated, but the old value needs to be known (field is hidden)
old_transaction_sum = models.DecimalField(max_digits=6, decimal_places=2, default=0.00)
datetime = models.DateTimeField(default=timezone.now)
is_user_deposit = models.BooleanField(default=False)
comment = models.TextField(default=" ")
user = CurrentUserField()
def save(self, *args, **kwargs):
if self._state.adding:
make_register_transaction(self.transaction_sum)
if self.is_user_deposit == True: # update user balance
self.user.balance += self.transaction_sum
self.user.save()
self.old_transaction_sum = self.transaction_sum
super().save(*args, **kwargs)
else:
# update register transaction
sum_diff = self.transaction_sum - self.old_transaction_sum
make_register_transaction(sum_diff)
# update user balance
if self.is_user_deposit == True:
ub_sum_diff = self.transaction_sum - self.old_transaction_sum
self.user.balance += ub_sum_diff
self.user.save()
self.old_transaction_sum = self.transaction_sum
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
make_register_transaction(-self.transaction_sum)
# update user deposit
if self.is_user_deposit:
self.user.balance -= self.transaction_sum
self.user.save()
super().delete(*args, kwargs)
def __str__(self): return f"{self.transaction_sum}{settings.CURRENCY_SUFFIX} by {self.user}"
class Order(models.Model):
drink = models.ForeignKey(
"Drink",
on_delete=models.SET_NULL,
null=True,
limit_choices_to=models.Q(available__gt=0) # Query only those drinks with a availability greater than (gt) 0
)
user = CurrentUserField()
datetime = models.DateTimeField(default=timezone.now)
amount = models.PositiveIntegerField(default=1, editable=False)
# the following fields will be set automatically
# won't use foreign key, because the values of the foreign objects may change over time.
product_name = models.CharField(max_length=64, editable=False)
price_sum = models.DecimalField(max_digits=6, decimal_places=2, default=0, editable=False)
content_litres = models.DecimalField(max_digits=6, decimal_places=3, default=0, editable=False)
# TODO: Add more comments on how and why the save & delete functions are implemented
# address this in a refactoring issue.
def save(self, *args, **kwargs):
drink = Drink.objects.get(pk=self.drink.pk)
if self._state.adding and drink.available > 0:
if not drink.binary_availability:
drink.available -= self.amount
drink.save()
self.product_name = drink.product_name
self.price_sum = drink.price * self.amount
self.content_litres = drink.content_litres
self.user.balance -= self.price_sum
self.user.save()
super().save(*args, **kwargs)
else:
raise ValidationError("This entry can't be changed.")
def delete(self, *args, **kwargs):
self.user.balance += self.price_sum
self.user.save()
drink = Drink.objects.get(pk=self.drink.pk)
if not drink.binary_availability:
drink.available += self.amount
drink.save()
super().delete(*args, **kwargs)
def __str__(self): return f"{self.drink.product_name} ({str(self.drink.content_litres).rstrip('0')}l) x {self.amount} - {self.price_sum}{settings.CURRENCY_SUFFIX}"
class Global(models.Model):
# this contains global values that are generated/calculated by code
# e.g. the current balance of the register, ...
name = models.CharField(max_length=42, unique=True, primary_key=True)
comment = models.TextField()
value_float = models.FloatField(default=0.00)
value_string = models.TextField()
def __str__(self): return self.name

View file

@ -0,0 +1,137 @@
#from datetime import datetime
from django.conf import settings
from django.db import connection
def _select_from_db(sql_select:str):
result = None
with connection.cursor() as cursor:
cursor.execute(sql_select)
result = cursor.fetchall()
return result
def select_history(user, language_code="en") -> list:
# select order history and deposits
user_id = user.pk
result = _select_from_db(f"""
select
concat(
product_name, ' (',
content_litres::real, -- converting to real removes trailing zeros
'l) x ', amount, ' - ', price_sum, '{settings.CURRENCY_SUFFIX}') as "text",
datetime
from app_order
where user_id = {user_id}
union
select
concat('Deposit: +', transaction_sum, '{settings.CURRENCY_SUFFIX}') as "text",
datetime
from app_userdeposits_view
where user_id = {user_id}
order by datetime desc
fetch first 30 rows only;
""")
result = [list(row) for row in result]
if language_code == "de": # reformat for german translation
for row in result:
row[0] = row[0].replace(".", ",")
return result
def select_yopml12m(user) -> list:
# number of orders per month (last 12 months)
# only for the specified user
user_id = user.pk
result = _select_from_db(f"""
-- select the count of the orders per month (last 12 days)
select
to_char(date_trunc('month', datetime), 'YYYY-MM') as "month",
sum(amount) as "count"
from app_order
where user_id = {user_id}
and date_trunc('month', datetime) > date_trunc('month', now() - '12 months'::interval)
group by "month"
order by "month" desc;
""")
return [list(row) for row in result]
def select_aopml12m() -> list:
# number of orders per month (last 12 months)
result = _select_from_db(f"""
-- select the count of the orders per month (last 12 days)
select
to_char(date_trunc('month', datetime), 'YYYY-MM') as "month",
sum(amount) as "count"
from app_order
where date_trunc('month', datetime) > date_trunc('month', now() - '12 months'::interval)
group by "month"
order by "month" desc;
""")
return [list(row) for row in result]
def select_yopwd(user) -> list:
# number of orders per weekday (all time)
# only for the specified user
user_id = user.pk
result = _select_from_db(f"""
-- select the count of the orders per weekday (all time)
select
to_char(datetime, 'Day') as "day",
sum(amount) as "count"
from app_order
where user_id = {user_id}
group by "day"
order by "count" desc;
""")
return [list(row) for row in result]
return []
def select_aopwd() -> list:
# number of orders per weekday (all time)
result = _select_from_db(f"""
-- select the count of the orders per weekday (all time)
select
to_char(datetime, 'Day') as "day",
sum(amount) as "count"
from app_order
group by "day"
order by "count" desc;
""")
return [list(row) for row in result]
return []
def select_noyopd(user) -> list:
# number of orders per drink (all time)
# only for specified user
user_id = user.pk
result = _select_from_db(f"""
select
d.product_name as "label",
sum(o.amount) as "data"
from app_drink d
join app_order o on (d.id = o.drink_id)
where o.user_id = {user_id}
group by d.product_name
order by "data" desc;
""")
return [list(row) for row in result]
def select_noaopd() -> list:
# number of orders per drink (all time)
result = _select_from_db(f"""
select
d.product_name as "label",
sum(o.amount) as "data"
from app_drink d
join app_order o on (d.id = o.drink_id)
group by d.product_name
order by "data" desc;
""")
return [list(row) for row in result]

View file

@ -0,0 +1,18 @@
{% extends "admin/base.html" %}
{% block title %}
{% if subtitle %}
{{ subtitle }} |
{% endif %}
{{ title }} | {{ site_title|default:_('Django site admin') }}
{% endblock %}
{% block extrahead %}
<link rel="shortcut icon" href="/static/favicon.png" sizes="480x480" />
{% endblock %}
{% block branding %}
<h1 id="site-name"><a href="{% url 'admin:index' %}">{{ site_header|default:_('Django administration') }}</a></h1>
{% endblock %}
{% block nav-global %}{% endblock %}

View file

@ -0,0 +1,19 @@
{% extends "admin/index.html" %}
{% block sidebar %}
{{ block.super }}
<div>
<div>
<p>Current Register Balance: {{ registerBalance }}{{ currency_suffix }}</p>
{% if global_message != "" %}
<p>Global Message: {{ global_message }}</p>
{% endif %}
{% if admin_info != "" %}
<p>Admin Info: {{ admin_info }}</p>
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,59 @@
<!DOCTYPE html>
{% load i18n %}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=0.95">
<link rel="stylesheet" href="/static/css/main.css">
<link rel="shortcut icon" href="/static/favicon.png" sizes="480x480" />
<title>{% block title %}{% endblock %}</title>
{% block headAdditional %}{% endblock %}
</head>
<body>
<div class="baseLayout">
{% include "globalMessage.html" %}
{% if user.is_authenticated %}
<div class="userPanel">
{% include "userPanel.html" %}
</div>
{% endif %}
<main>
{% if user.is_authenticated or "accounts/login/" in request.path or "accounts/logout/" in request.path %}
<h1>{% block heading %}{% endblock %}</h1>
<div class="content">
{% block content %}{% endblock %}
</div>
{% else %}
<div class="centeringFlex">
{% translate "An error occured. Please log out and log in again." %}
<br>
<a href="/accounts/logout">log out</a>
</div>
{% endif %}
</main>
{% include "footer.html" %}
</div>
<script src="/static/js/main.js"></script>
</body>
</html>

View file

@ -0,0 +1,39 @@
{% extends "baseLayout.html" %}
{% load i18n %}
{% block title %}
{% translate "Drinks - Deposit" %}
{% endblock %}
{% block headAdditional %}
<link rel="stylesheet" href="/static/css/deposit.css">
{% endblock %}
{% block heading %}
{% translate "Deposit" %}
{% endblock %}
{% block content %}
<form id="depositForm">
{% csrf_token %}
<div class="row">
<div class="column">{% translate "Amount" %} {{ currency_suffix }}:</div>
<div class="column"><input type="number" name="depositAmount" id="depositAmount" max="9999.99" min="1.00"
step="0.01" autofocus></div>
</div>
<div id="statusInfo"></div>
<div class="horizontalButtonList">
<a href="/" class="button">{% translate "cancel" %}</a>
<input type="submit" id="depositSubmitBtn" class="button" value='{% translate "confirm" %}'>
</div>
</form>
<script src="/static/js/deposit.js"></script>
{% endblock %}

View file

@ -0,0 +1,6 @@
{% load i18n %}
<div class="footer">
<div>Version {{ app_version }}</div>
<div>Copyright (C) 2021, <a href="https://gitlab.com/W13R">Julian Müller (W13R)</a></div>
</div>

View file

@ -0,0 +1,5 @@
{% if global_message != "" %}
<div class="globalMessage">
<div>{{ global_message }}</div>
</div>
{% endif %}

View file

@ -0,0 +1,36 @@
{% extends "baseLayout.html" %}
{% load i18n %}
{% block title %}
{% translate "Drinks - History" %}
{% endblock %}
{% block headAdditional %}
<link rel="stylesheet" href="/static/css/history.css">
{% endblock %}
{% block heading %}
{% translate "History" %}
{% endblock %}
{% block content %}
{% if history %}
<table class="history">
<tr>
<th>{% translate "last 30 actions" %}</th>
<th></th>
</tr>
{% for h in history %}
<tr>
<td>{{ h.0 }}</td>
<td class="historyDate">{{ h.1 }}</td>
</tr>
{% endfor %}
</table>
{% else %}
{% translate "No history." %}
{% endif %}
{% endblock %}

View file

@ -0,0 +1,47 @@
{% extends "baseLayout.html" %}
{% load i18n %}
{% block title %}
{% translate "Drinks - Home" %}
{% endblock %}
{% block headAdditional %}
<link rel="stylesheet" href="/static/css/index.css">
{% endblock %}
{% block heading %}
{% translate "Available Drinks" %}
{% endblock %}
{% block content %}
{% if available_drinks %}
<ul class="availableDrinksList">
{% for drink in available_drinks %}
{% if drink.binary_availability %}
<li>
<a class="button" href="/order/{{ drink.id }}">
<span>{{ drink }}</span>
<span>{% translate "available" %}</span>
</a>
</li>
{% else %}
<li>
<a class="button" href="/order/{{ drink.id }}">
<span>{{ drink }}</span>
<span>{{ drink.available }} {% translate "available" %}</span>
</a>
</li>
{% endif %}
{% endfor %}
</ul>
{% else %}
{% translate "No drinks available." %}
{% endif %}
{% endblock %}

View file

@ -0,0 +1,99 @@
{% extends "baseLayout.html" %}
{% load i18n %}
{% block title %}
{% translate "Drinks - Order" %}
{% endblock %}
{% block headAdditional %}
<link rel="stylesheet" href="/static/css/order.css">
<link rel="stylesheet" href="/static/css/customNumberInput.css">
{% endblock %}
{% block heading %}
{% translate "Order" %}
{% endblock %}
{% block content %}
{% if drink and drink.available > 0 and not drink.deleted %}
{% if user.balance > 0 or user.allow_order_with_negative_balance %}
<form id="orderForm">
{% csrf_token %}
<div class="row">
<div class="column">{% translate "Drink" %}:</div>
<div class="column">{{ drink.product_name }}</div>
</div>
<div class="row">
<div class="column">{% translate "Price per Item" %} ({{ currency_suffix }}):</div>
<div class="column" id="pricePerDrink" data-drink-price="{{ drink.price }}">{{ drink.price }}</div>
</div>
{% if not drink.binary_availability %}
<div class="row">
<div class="column">{% translate "Available" %}:</div>
<div class="column">{{ drink.available }}</div>
</div>
{% endif %}
<div class="row">
<div class="column">{% translate "Count" %}:</div>
<div class="column">
<span class="customNumberInput">
<button type="button" class="customNumberInput-minus" id="numberOfDrinksBtnA">-</button>
{% if drink.binary_availability %}
<input type="number" class="customNumberInputField" name="numberOfDrinks" id="numberOfDrinks"
min="1" max="100" value="1">
{% else %}
<input type="number" class="customNumberInputField" name="numberOfDrinks" id="numberOfDrinks"
max="{{ drink.available }}" min="1" max="100" value="1">
{% endif %}
<button type="button" class="customNumberInput-plus" id="numberOfDrinksBtnB">+</button>
</span>
</div>
</div>
<div class="row">
<div class="column">{% translate "Sum" %} ({{ currency_suffix }}):</div>
<div class="column" id="orderCalculatedSum">{{ drink.price }}</div>
</div>
<div id="statusInfo"></div>
<input type="hidden" name="drinkID" id="drinkID" value="{{ drink.id }}">
<div class="horizontalButtonList">
<a href="/" class="button">{% translate "cancel" %}</a>
<input type="submit" id="orderSubmitBtn" class="button" value='{% translate "order" %}'>
</div>
</form>
<script src="/static/js/order.js"></script>
<script src="/static/js/customNumberInput.js"></script>
{% else %}
<div class="centeringFlex">
<p>{% translate "You can't order this, because you have a negative balance." %}</p>
<a href="/">{% translate "back" %}</a>
</div>
{% endif %}
{% else %}
<div class="centeringFlex">
<p>{% translate "This drink is not available." %}</p>
<a href="/">{% translate "back" %}</a>
</div>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,24 @@
{% extends "baseLayout.html" %}
{% load i18n %}
{% block title %}
{% translate "Drinks - Logged Out" %}
{% endblock %}
{% block headAdditional %}
<link rel="stylesheet" href="/static/css/login.css">
{% endblock %}
{% block content %}
<div class="centeringFlex">
{% translate "Logged out! You will be redirected shortly." %}
<br><br>
<a href="/">{% translate "Click here if automatic redirection does not work." %}</a>
</div>
<script src="/static/js/logged_out.js"></script>
{% endblock %}

View file

@ -0,0 +1,91 @@
{% extends "baseLayout.html" %}
{% load i18n %}
{% block title %}
{% translate "Drinks - Login" %}
{% endblock %}
{% block headAdditional %}
<link rel="stylesheet" href="/static/css/login.css">
{% endblock %}
{% block content %}
{% if error_message %}
<p class="errorText">{{ error_message }}</p>
{% endif %}
<div class="passwordOverlayContainer nodisplay" id="passwordOverlayContainer">
<div class="passwordOverlay">
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
<h1>{% translate "Log in" %}</h1>
<input type="text" name="username" autofocus="" autocapitalize="none" autocomplete="username" maxlength="150" required="" id="id_username">
<input type="password" name="password" autocomplete="current-password" required="" id="id_password" placeholder='{% translate "Password/PIN" %}'>
<div class="pinpad">
<table>
<tr>
<td><button type="button" class="pinpadBtn" data-btn="1">1</button></td>
<td><button type="button" class="pinpadBtn" data-btn="2">2</button></td>
<td><button type="button" class="pinpadBtn" data-btn="3">3</button></td>
</tr>
<tr>
<td><button type="button" class="pinpadBtn" data-btn="4">4</button></td>
<td><button type="button" class="pinpadBtn" data-btn="5">5</button></td>
<td><button type="button" class="pinpadBtn" data-btn="6">6</button></td>
</tr>
<tr>
<td><button type="button" class="pinpadBtn" data-btn="7">7</button></td>
<td><button type="button" class="pinpadBtn" data-btn="8">8</button></td>
<td><button type="button" class="pinpadBtn" data-btn="9">9</button></td>
</tr>
<tr>
<td><button type="button" class="pinpadBtn" data-btn="0">0</button></td>
<td><button type="button" class="pinpadBtn" data-btn="x">x</button></td>
<td><button type="button" class="pinpadBtn" data-btn="enter">&#9166;</button></td>
</tr>
</table>
</div>
<div class="horizontalButtonList">
<button type="button" id="pwoCancel">{% translate "cancel" %}</button>
<input class="button" id="submit_login" type="submit" value='{% translate "login" %}' />
</div>
</form>
</div>
</div>
<h1>{% translate "Choose your account" %}</h1>
<div class="userlistContainer">
<ul class="userlist">
{% for user_ in user_list %}
<li class="userlistButton button" data-username="{{ user_.username }}">
{% if user_.first_name %}
{{ user_.first_name }}
{% if user_.last_name %}
{{ user_.last_name }}
{% endif %}
{% else %}
{{ user_.username }}
{% endif %}
</li>
{% endfor %}
</ul>
</div>
<script src="/static/js/login.js"></script>
{% endblock %}

View file

@ -0,0 +1,179 @@
{% extends "baseLayout.html" %}
{% load i18n %}
{% block title %}
{% translate "Drinks - Statistics" %}
{% endblock %}
{% block headAdditional %}
<link rel="stylesheet" href="/static/css/statistics.css">
{% endblock %}
{% block heading %}
{% translate "Statistics" %}
{% endblock %}
{% block content %}
<div class="mainContainer">
<div class="dropDownMenu" id="statisticsDropDownMenu">
<button class="dropDownButton" id="statisticsDropDownMenuButton">
<div>
{% translate "Choose" %}
</div>
</button>
<div class="dropDownList">
<button class="sChoice dropDownChoice" data-statistics_div="noyopd">
{% translate "Your orders per drink" %}
</button>
<button class="sChoice dropDownChoice" data-statistics_div="yopwd">
{% translate "Your orders per weekday" %}
</button>
<button class="sChoice dropDownChoice" data-statistics_div="yopml12m">
{% translate "Your orders per month (last 12 months)" %}
</button>
<button class="sChoice dropDownChoice" data-statistics_div="noaopd">
{% translate "All orders per drink" %}
</button>
<button class="sChoice dropDownChoice" data-statistics_div="aopwd">
{% translate "All orders per weekday" %}
</button>
<button class="sChoice dropDownChoice" data-statistics_div="aopml12m">
{% translate "All orders per month (last 12 months)" %}
</button>
</div>
</div>
<div class="tablesContainer">
<div id="noyopd" class="statisticsTable nodisplay">
<h1>{% translate "Your orders per drink" %}</h1>
{% if noyopd %}
<table>
<tr>
<th>{% translate "drink" %}</th>
<th>{% translate "count" %}</th>
</tr>
{% for row in noyopd %}
<tr>
<td>{{ row.0 }}</td>
<td>{{ row.1 }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div>{% translate "No history." %}</div>
{% endif %}
</div>
<div id="noaopd" class="statisticsTable nodisplay">
<h1>{% translate "All orders per drink" %}</h1>
{% if noaopd %}
<table>
<tr>
<th>{% translate "drink" %}</th>
<th>{% translate "count" %}</th>
</tr>
{% for row in noaopd %}
<tr>
<td>{{ row.0 }}</td>
<td>{{ row.1 }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div>{% translate "No history." %}</div>
{% endif %}
</div>
<div id="yopml12m" class="statisticsTable nodisplay">
<h1>{% translate "Your orders per month (last 12 months)" %}</h1>
{% if yopml12m %}
<table>
<tr>
<th>{% translate "month" %}</th>
<th>{% translate "count" %}</th>
</tr>
{% for row in yopml12m %}
<tr>
<td>{{ row.0 }}</td>
<td>{{ row.1 }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div>{% translate "No history." %}</div>
{% endif %}
</div>
<div id="aopml12m" class="statisticsTable nodisplay">
<h1>{% translate "All orders per month (last 12 months)" %}</h1>
{% if aopml12m %}
<table>
<tr>
<th>{% translate "month" %}</th>
<th>{% translate "count" %}</th>
</tr>
{% for row in aopml12m %}
<tr>
<td>{{ row.0 }}</td>
<td>{{ row.1 }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div>{% translate "No history." %}</div>
{% endif %}
</div>
<div id="yopwd" class="statisticsTable nodisplay">
<h1>{% translate "Your orders per weekday" %}</h1>
{% if yopwd %}
<table>
<tr>
<th>{% translate "day" %}</th>
<th>{% translate "count" %}</th>
</tr>
{% for row in yopwd %}
<tr>
<td>{{ row.0 }}</td>
<td>{{ row.1 }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div>{% translate "No history." %}</div>
{% endif %}
</div>
<div id="aopwd" class="statisticsTable nodisplay">
<h1>{% translate "All orders per weekday" %}</h1>
{% if aopwd %}
<table>
<tr>
<th>{% translate "day" %}</th>
<th>{% translate "count" %}</th>
</tr>
{% for row in aopwd %}
<tr>
<td>{{ row.0 }}</td>
<td>{{ row.1 }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div>{% translate "No history." %}</div>
{% endif %}
</div>
</div>
</div>
<script src="/static/js/statistics.js"></script>
{% endblock %}

View file

@ -0,0 +1,31 @@
{% load i18n %}
<div class="dropDownMenu" id="dropDownMenu">
<button class="dropDownButton" id="dropDownMenuButton">
<div>
{% if user.first_name != "" %}
{% translate "User" %}: {{ user.first_name }} {{ user.last_name }} ({{ user.username }})
{% else %}
{% translate "User" %}: {{ user.username }}
{% endif %}
&nbsp;-&nbsp;
{% if user.balance < 0.01 %}
<span class="userBalanceWarn">{% translate "Balance" %}: {{ user.balance }}{{ currency_suffix }}</span>
{% else %}
<span>{% translate "Balance" %}: {{ user.balance }}{{ currency_suffix }}</span>
{% endif %}
</div>
</button>
<div class="dropDownList">
<a class="button dropDownChoice" id="navBarBtnHome" href="/">Home</a>
<a class="button dropDownChoice" id="navBarBtnHistory" href="/history">{% translate "History" %}</a>
<a class="button dropDownChoice" id="navBarBtnStatistics" href="/statistics">{% translate "Statistics" %}</a>
<a class="button dropDownChoice" id="navBarBtnDeposit" href="/deposit">{% translate "Deposit" %}</a>
{% if user.is_superuser %}
<a class="button dropDownChoice" href="/admin/">Admin Panel</a>
{% else %}
<a class="button dropDownChoice" href="/accounts/password_change/">{% translate "Change Password" %}</a>
{% endif %}
<a class="button dropDownChoice" href="/accounts/logout">{% translate "Logout" %}</a>
</div>
</div>

3
application/app/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

22
application/app/urls.py Normal file
View file

@ -0,0 +1,22 @@
from django.urls import path, include
from django.contrib.auth import views as auth_views
from . import views
from .admin import adminSite
urlpatterns = [
path('', views.index),
path('order/<drinkID>/', views.order),
path('history/', views.history),
path('deposit/', views.deposit),
path('statistics/', views.statistics),
path('accounts/login/', views.login_page, name="login"),
path('accounts/logout/', auth_views.LogoutView.as_view(), name='logout'),
path('accounts/password_change/', auth_views.PasswordChangeView.as_view(), name='password_change'),
path('accounts/password_change_done/', views.redirect_home, name='password_change_done'),
path('admin/', adminSite.urls),
# API #
path('api/order-drink', views.api_order_drink),
path('api/deposit', views.api_deposit),
#path('api/get-statistics', views.api_get_statistics)
]

167
application/app/views.py Normal file
View file

@ -0,0 +1,167 @@
import json
import sys
from django.contrib.auth import authenticate
from django.contrib.auth import get_user_model
from django.contrib.auth import login
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import AuthenticationForm
from django.http.response import HttpResponseRedirect
from django.http.response import HttpResponse
from django.shortcuts import render
from django.utils.translation import gettext as _
from django.utils.formats import decimal
from . import sql_queries
from .models import Drink
from .models import Order
from .models import RegisterTransaction
# login view
def login_page(request):
userlist = get_user_model().objects.filter(is_superuser=False).filter(is_active=True).order_by("username")
if request.method == "POST":
form = AuthenticationForm(request.POST)
username = request.POST['username']
password = request.POST['password']
user = authenticate(username=username,password=password)
if user:
if user.is_active:
login(request, user)
return HttpResponseRedirect("/")
else:
return render(request,'registration/login.html', {
"form": form,
"user_list": userlist,
"error_message": _("Invalid username or password.")
})
else:
if request.user.is_authenticated:
return HttpResponseRedirect("/")
form = AuthenticationForm()
return render(request,'registration/login.html', {
"form": form,
"user_list": userlist
})
# actual application
@login_required
def index(request):
context = {
"available_drinks": Drink.objects.filter(available__gt=0).filter(deleted=False),
}
return render(request, "index.html", context)
@login_required
def history(request):
context = {
"history": sql_queries.select_history(request.user, language_code=request.LANGUAGE_CODE),
}
return render(request, "history.html", context)
@login_required
def order(request, drinkID):
try:
drink_ = Drink.objects.get(pk=drinkID)
context = {
"drink": drink_
}
return render(request, "order.html", context)
except Drink.DoesNotExist:
return HttpResponseRedirect("/")
@login_required
def deposit(request):
return render(request, "deposit.html", {})
@login_required
def statistics(request):
context = {
"yopml12m": sql_queries.select_yopml12m(request.user),
"aopml12m": sql_queries.select_aopml12m(),
"yopwd": sql_queries.select_yopwd(request.user),
"aopwd": sql_queries.select_aopwd(),
"noyopd": sql_queries.select_noyopd(request.user),
"noaopd": sql_queries.select_noaopd()
}
return render(request, "statistics.html", context)
@login_required
def redirect_home(request):
return HttpResponseRedirect("/")
# API for XHR requests #
@login_required
def api_order_drink(request):
# check request -> make order
user = request.user
try:
if user.allow_order_with_negative_balance or user.balance > 0:
drinkID = int(request.POST["drinkID"])
amount = int(request.POST["numberOfDrinks"])
drink = Drink.objects.get(pk=drinkID)
if ((drink.binary_availability and drink.available > 0) or (drink.available >= amount)) and not drink.deleted:
Order.objects.create(drink=drink, user=user, amount=amount)
return HttpResponse("success", status=200)
else:
return HttpResponse("notAvailable", status=400)
else: raise Exception("Balance below zero.")
except Exception as e:
print(f"An exception occured while processing an order: User: {user.username} - Exception: {e}", file=sys.stderr)
return HttpResponse(b"", status=500)
@login_required
def api_deposit(request):
# check request -> deposit
user = request.user
try:
amount = decimal.Decimal(request.POST["depositAmount"])
if 0.00 < amount < 9999.99:
# create transaction
RegisterTransaction.objects.create(
transaction_sum=amount,
comment=f"User deposit by user {user.username}",
is_user_deposit=True,
user=user
)
#
return HttpResponse("success", status=200)
else: raise Exception("Deposit amount too big or small.")
except Exception as e:
print(f"An exception occured while processing an transaction: User: {user.username} - Exception: {e}", file=sys.stderr)
return HttpResponse(b"", status=500)

View file

View file

@ -0,0 +1,16 @@
"""
ASGI config for drinks_manager project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'drinks_manager.settings')
application = get_asgi_application()

View file

@ -0,0 +1,183 @@
"""
Django settings for drinks_manager project.
Generated by 'django-admin startproject' using Django 3.2.5.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.2/ref/settings/
"""
import os
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key secret!
django_secret_key_absolute_fp = os.environ["DJANGO_SK_ABS_FP"]
with open(django_secret_key_absolute_fp) as secret_key_file:
SECRET_KEY = secret_key_file.read().strip()
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = (os.environ["DJANGO_DEBUG"].lower() == "true")
ALLOWED_HOSTS = [
"*"
]
### CSP Configuration ###
CSP_DEFAULT_SRC = ("'self'", )
### ----------------- ###
# Application definition
INSTALLED_APPS = [
"app.apps.DAppConfig",
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
"django.middleware.locale.LocaleMiddleware",
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
"django_currentuser.middleware.ThreadLocalUserMiddleware",
"csp.middleware.CSPMiddleware"
]
ROOT_URLCONF = 'drinks_manager.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
"app.context_processors.app_version"
],
},
},
]
WSGI_APPLICATION = 'drinks_manager.wsgi.application'
# Database
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': os.environ["PGDB_DB"],
'USER': os.environ["PGDB_USER"],
'PASSWORD': os.environ["PGDB_PASSWORD"],
'HOST': os.environ["PGDB_HOST"],
'PORT': str(os.environ["PGDB_PORT"])
}
}
CONN_MAX_AGE = 20 # keep database connections alive for n seconds
# Password validation
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
if os.environ["DJANGO_ENABLE_PASSWORD_VALIDATION"].lower() == "true":
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
else:
AUTH_PASSWORD_VALIDATORS = []
AUTH_USER_MODEL = "app.User"
# user will be logged out after x seconds
SESSION_COOKIE_AGE = int(os.environ["DJANGO_SESSION_COOKIE_AGE"])
# more security settings
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
# Internationalization
# https://docs.djangoproject.com/en/3.2/topics/i18n/
LANGUAGE_CODE = os.environ["DJANGO_LANGUAGE_CODE"] # this is the default and fallback language (currently only de-de and en-us supported)
TIME_ZONE = os.environ["DJANGO_TIME_ZONE"]
USE_I18N = True
USE_L10N = True
USE_TZ = True
LOCALE_PATHS = [
BASE_DIR / "locale"
]
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.2/howto/static-files/
STATIC_URL = '/static/'
STATIC_ROOT = os.environ["STATIC_FILES"]
# Default primary key field type
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
#
APP_VERSION = os.environ["APP_VERSION"]
try:
CURRENCY_SUFFIX = os.environ["CURRENCY_SUFFIX"]
except KeyError:
CURRENCY_SUFFIX = "$"

View file

@ -0,0 +1,21 @@
"""drinks_manager URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/3.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.urls import path, include
urlpatterns = [
path('', include("app.urls"))
]

View file

@ -0,0 +1,16 @@
"""
WSGI config for drinks_manager project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'drinks_manager.settings')
application = get_wsgi_application()

Binary file not shown.

View file

@ -0,0 +1,241 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-12-22 11:07+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: app/templates/admin/base_site.html:7
msgid "Django site admin"
msgstr "Django Administrator"
#: app/templates/admin/base_site.html:15
msgid "Django administration"
msgstr "Django Administration"
#: app/templates/baseLayout.html:43
msgid "An error occured. Please log out and log in again."
msgstr "Ein Fehler ist aufgetreten. Bitte ab- und wieder anmelden."
#: app/templates/deposit.html:6
msgid "Drinks - Deposit"
msgstr "Getränke - Einzahlen"
#: app/templates/deposit.html:14 app/templates/userPanel.html:23
msgid "Deposit"
msgstr "Einzahlen"
#: app/templates/deposit.html:23
msgid "Amount"
msgstr "Summe"
#: app/templates/deposit.html:31 app/templates/order.html:71
#: app/templates/registration/login.html:56
msgid "cancel"
msgstr "Abbrechen"
#: app/templates/deposit.html:32
msgid "confirm"
msgstr "Bestätigen"
#: app/templates/history.html:6
msgid "Drinks - History"
msgstr "Getränke - Verlauf"
#: app/templates/history.html:14 app/templates/userPanel.html:21
msgid "History"
msgstr "Verlauf"
#: app/templates/history.html:22
msgid "last 30 actions"
msgstr "letzte 30 Vorgänge"
#: app/templates/history.html:33 app/templates/statistics.html:69
#: app/templates/statistics.html:89 app/templates/statistics.html:109
#: app/templates/statistics.html:129 app/templates/statistics.html:149
#: app/templates/statistics.html:169
msgid "No history."
msgstr "Kein Verlauf verfügbar."
#: app/templates/index.html:6
msgid "Drinks - Home"
msgstr "Getränke - Home"
#: app/templates/index.html:14
msgid "Available Drinks"
msgstr "Verfügbare Getränke"
#: app/templates/index.html:27 app/templates/index.html:34
msgid "available"
msgstr "verfügbar"
#: app/templates/index.html:43
msgid "No drinks available."
msgstr "Es sind gerade keine Getränke verfügbar."
#: app/templates/order.html:6
msgid "Drinks - Order"
msgstr "Getränke - Bestellen"
#: app/templates/order.html:15
msgid "Order"
msgstr "Bestellung"
#: app/templates/order.html:28
msgid "Drink"
msgstr "Getränk"
#: app/templates/order.html:33
msgid "Price per Item"
msgstr "Preis pro Getränk"
#: app/templates/order.html:39
msgid "Available"
msgstr "Verfügbar"
#: app/templates/order.html:45
msgid "Count"
msgstr "Anzahl"
#: app/templates/order.html:62
msgid "Sum"
msgstr "Summe"
#: app/templates/order.html:72
msgid "order"
msgstr "Bestellen"
#: app/templates/order.html:84
msgid "You can't order this, because you have a negative balance."
msgstr ""
"Sie können momentan keine Bestellungen aufgeben, da Sie einen negativen "
"Saldo haben."
#: app/templates/order.html:85 app/templates/order.html:94
msgid "back"
msgstr "zurück"
#: app/templates/order.html:93
msgid "This drink is not available."
msgstr "Dieses Getränk ist gerade nicht verfügbar."
#: app/templates/registration/logged_out.html:7
msgid "Drinks - Logged Out"
msgstr "Getränke - Abgemeldet"
#: app/templates/registration/logged_out.html:17
msgid "Logged out! You will be redirected shortly."
msgstr "Sie wurden abgemeldet und werden in Kürze weitergeleitet."
#: app/templates/registration/logged_out.html:19
msgid "Click here if automatic redirection does not work."
msgstr ""
"Bitte klicken Sie hier, wenn die automatische Weiterleitung nicht "
"funktionieren sollte."
#: app/templates/registration/login.html:7
msgid "Drinks - Login"
msgstr "Getränke - Anmeldung"
#: app/templates/registration/login.html:26
msgid "Log in"
msgstr "Anmelden"
#: app/templates/registration/login.html:28
msgid "Password/PIN"
msgstr "Passwort/PIN"
#: app/templates/registration/login.html:57
msgid "login"
msgstr "Anmelden"
#: app/templates/registration/login.html:65
msgid "Choose your account"
msgstr "Bitte wählen Sie Ihren Account"
#: app/templates/statistics.html:6
msgid "Drinks - Statistics"
msgstr "Getränke - Statistiken"
#: app/templates/statistics.html:15 app/templates/userPanel.html:22
msgid "Statistics"
msgstr "Statistiken"
#: app/templates/statistics.html:26
msgid "Choose"
msgstr "Auswählen"
#: app/templates/statistics.html:31 app/templates/statistics.html:54
msgid "Your orders per drink"
msgstr "Deine Bestellungen pro Getränk"
#: app/templates/statistics.html:34 app/templates/statistics.html:134
msgid "Your orders per weekday"
msgstr "Deine Bestellungen pro Wochentag"
#: app/templates/statistics.html:37 app/templates/statistics.html:94
msgid "Your orders per month (last 12 months)"
msgstr "Deine Bestellungen pro Monat (letzte 12 Monate)"
#: app/templates/statistics.html:40 app/templates/statistics.html:74
msgid "All orders per drink"
msgstr "Alle Bestellungen pro Getränk"
#: app/templates/statistics.html:43 app/templates/statistics.html:154
msgid "All orders per weekday"
msgstr "Alle Bestellungen pro Wochentag"
#: app/templates/statistics.html:46 app/templates/statistics.html:114
msgid "All orders per month (last 12 months)"
msgstr "Alle Bestellungen pro Monat (letzte 12 Monate)"
#: app/templates/statistics.html:58 app/templates/statistics.html:78
msgid "drink"
msgstr "Getränk"
#: app/templates/statistics.html:59 app/templates/statistics.html:79
#: app/templates/statistics.html:99 app/templates/statistics.html:119
#: app/templates/statistics.html:139 app/templates/statistics.html:159
msgid "count"
msgstr "Anzahl"
#: app/templates/statistics.html:98 app/templates/statistics.html:118
msgid "month"
msgstr "Monat"
#: app/templates/statistics.html:138 app/templates/statistics.html:158
msgid "day"
msgstr "Tag"
#: app/templates/userPanel.html:7 app/templates/userPanel.html:9
msgid "User"
msgstr "Benutzer"
#: app/templates/userPanel.html:13 app/templates/userPanel.html:15
msgid "Balance"
msgstr "Saldo"
#: app/templates/userPanel.html:27
msgid "Change Password"
msgstr "Passwort ändern"
#: app/templates/userPanel.html:29
msgid "Logout"
msgstr "Abmelden"
#: app/views.py:47
msgid "Invalid username or password."
msgstr "Benutzername oder Passwort ungültig."

22
application/manage.py Executable file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env python3
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'drinks_manager.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

0
archive/.gitkeep Normal file
View file

39
config/Caddyfile Normal file
View file

@ -0,0 +1,39 @@
{
# disable admin backend
admin off
# define the ports by the environment variables
http_port {$HTTP_PORT}
https_port {$HTTPS_PORT}
}
https:// {
# the tls certificates
tls ./config/tls/server.pem ./config/tls/server-key.pem
route {
# static files
file_server /static/* {
root {$STATIC_FILES}/..
}
# favicon
redir /favicon.ico /static/favicon.ico
# reverse proxy to the (django) application
reverse_proxy localhost:{$DJANGO_PORT}
}
# use compression
encode gzip
# logging
log {
output file {$CADDY_ACCESS_LOG}
format filter {
wrap console
fields {
common_log delete
request>headers delete
request>tls delete
user_id delete
resp_headers delete
}
}
level INFO
}
}

31
config/config.sample.sh Normal file
View file

@ -0,0 +1,31 @@
# environment variables
export HTTP_PORT=80 # required by caddy, will be redirected to https
export HTTPS_PORT=443 # actual port for webinterface
export DJANGO_PORT=8001 # caddy's http port (should be blocked by the firewall)
export DJANGO_SESSION_COOKIE_AGE=600 # auto-logout, in seconds
export SESSION_CLEAR_INTERVAL=120 # interval for automatic session clearing, in minutes
export DJANGO_LANGUAGE_CODE="en" # the default and fallback language. Currently only de and en are supported.
export DJANGO_TIME_ZONE="CET"
export CURRENCY_SUFFIX="$" # if you have another currency symbol, you can specify it here
# Do you want to enable password validation?
# (numeric PINs as Password will not be seen as valid)
export DJANGO_ENABLE_PASSWORD_VALIDATION="true"
# database connection (postgresql)
export PGDB_DB="" # The name of the databae
export PGDB_USER="" # The database user
export PGDB_PASSWORD='' # The password for the database user
export PGDB_HOST="" # The hostname of your database (e.g. example.org or 127.0.0.1)
export PGDB_PORT=5432 # The port your database is listening on
# log files
# only change if you know what you are doing
export CADDY_ACCESS_LOG="$(pwd)/logs/http-access.log"
export CADDY_LOG="$(pwd)/logs/caddy.log"
export APPLICATION_LOG="$(pwd)/logs/application.log"

View file

@ -0,0 +1,6 @@
# environment variables for tls generation
export TLS_EXPIRE_AFTER_DAYS=365
export TLS_COMMON_NAME="localhost"
export TLS_ALT_NAME1="127.0.0.1"
export TLS_ALT_NAME2="localhost.localdomain"

81
docs/Commands.md Normal file
View file

@ -0,0 +1,81 @@
# Commands
You run a command with
```
./run.sh <command>
```
## Available Commands
---
`server` - Start the server
This starts a caddy instance, hypercorn with the django application and a scheduler that automatically removes expired session data.
Log files will be written.
---
`setup` - Set up the application
This sets up some database tables, views, and more, generates a secret key for the application and lets you create an admin user.
---
`create-admin` - Lets you create an admin user
---
`generate-tls-cert` - generate a new self-signed tls certificate for https
This overwrites the original files, if present (see [Setup](Setup.md)).
---
`generate-secret-key` - generate a new random secret key for django
This will overwrite the old one.
Warning: After running this, current sessions will be invalid, and the users have to relogin. Don't run this command while the server is running.
---
`clear-sessions` - manually remove all expired sessions from the database
---
`force-db-upgrade` - force a database migration and -upgrade
This is mainly used in development.
---
`archive-tables` - archive (copy & delete) all entries in app_order and app_registertransaction
Use this to archive old orders or transactions (e.g. when the database gets too big).
---
`development-server` - Start the development server
This starts a caddy instance, the django development server with DEBUGGING enabled and the session-clear-scheduler.
Only the HTTP-Access-Log will be written to its logfile, other logs will be written to the console.
---
`run-script <path>` - Run a python script in the context of the django project (experimental)
`<path>` is the path to the python script
Keep in mind that the current directory won't be changed automatically to the parent folder of the script file.
---
`help` - Show a help text
---
## Examples
Run the production server:
```
./run.sh server
```
Create a new admin:
```
./run.sh create-admin
```

23
docs/Configuration.md Normal file
View file

@ -0,0 +1,23 @@
# Configuration
## Main Configuration
`./config/config.sh`
There is no default configuration available, only a sample configuration with explanations.
## Configuration files for tls certificates
This is the configuration for self-signed local TLS certificate generation.
`./config/tls/cert-config.sh`
This is already configured, but you can modify this for your needs.
## Caddy Server Configuration
`./config/Caddyfile`
The default configuration should work out of the box, don't edit this file unless you know what you're doing.

117
docs/Setup.md Normal file
View file

@ -0,0 +1,117 @@
# Setup
## I. Dependencies
Before the actual setup, you have to satisfy the following dependencies:
### System
- `pg_config`
- Fedora/RHEL: `libpq-dev`
- `Caddy` 2.4.3+ (HTTP Reverse Proxy & Static File Server)
- `gcc`, `gettext`
- `Python` 3.9 with pip
- `Python` header files
- Fedora/RHEL: `python3-devel`
### Python Packages (pip)
- `django~=3.2.7`
- `django-currentuser==0.5.3`
- `django-csp==3.7`
- `psycopg2~=2.9.1`
- `hypercorn~=0.11.2`
- `cryptography~=36.0.0` (for self-signed tls certificates)
You can install those pip-packages with the following command:
```bash
pip install -U -r pip-dependencies.txt
```
## II.A Installation
You can get the latest version with git:
```
git clone --branch release-x.x https://gitlab.com/W13R/drinks-manager.git
```
(replace x.x with the latest version)
Alternatively, you can download the [latest release](https://gitlab.com/W13R/drinks-manager/-/releases) and extract the files to your prefered destination.
<u>**Warning:**</u>
Make shure that you set the correct file permissions, especially for the config files !!
The following should be sufficient:
```bash
chmod -R u+rw,g+r,g-w,o-rwx <drinks_manager_directory>
```
## II.B Update
If you installed the application with git, you can run the following in the drinks-manager directory to update to the new version:
```
git fetch
git checkout release-x.x
```
(replace x.x with the new version)
If you downloaded the application from the releases page, you can download the new release in the same manner, and overwrite the old files with the new ones.
You have to restart the application server to apply the changes.
WARNING: The auto-upgrade mechanism may expect you to input information. Therefore, you should start the application from the command-line the first time after an update.
Further upgrading-instructions may be provided in the Release Notes on the Releases Page of this Project (Deployments -> Releases).
## III. Database
This project is using PostgreSQL. You have to set up a database by yourself.
The database must have the schema `public` (exists on a new database). Make shure that you create a database user with the necessary privileges to write to and read from the database (SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, CREATE, CONNECT):
```sql
-- connected to target database
grant SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES on all tables in schema public to <dbuser>;
grant CREATE, CONNECT on database <dbname> to <dbuser>;
```
You can configure your database connection in `config/config.sh`.
## IV. HTTPS & TLS Certificates
TLS/SSL certificates are required.
If you don't have a TLS/SSL certificate already, you can generate one
with the command `./run.sh generate-tls-cert`. This will generate a
new TLS certificate and key file at `config/tls/server.pem` (certificate)
and `config/tls/server-key.pem` (key).
WARNING: This will overwrite an existing certificate/key with the same filepath.
By default those generated certificates are valid for one year. After that year,
they have to be regenerated with the same command.
If you have a certificate and key file already, you can put them in the following places:
- `config/tls/server.pem` for the certificate
- `config/tls/server-key.pem` for the key
You can set another filepath for those files in your caddy configuration at `config/Caddyfile`.
## V. Configuration
see [Configuration](Configuration.md)
## VI. Run Setup Command
run `./run.sh setup`
This will automatically set up database tables, views and entries, set up Django and let you create a admin user.
After this, start the server with `./run.sh server` and navigate to `https://your.ip.add.ress:port/admin/`.

75
lib/archive-tables.py Normal file
View file

@ -0,0 +1,75 @@
#!/usr/bin/env python3
import os, sys
from datetime import datetime
from pathlib import Path
from psycopg2 import connect
# archive (copy & delete) all entries in app_order and app_registertransaction
timestamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
archive_folder = Path("./archive")
orders_archive_path = archive_folder / ("orders-archive-" + timestamp + ".csv")
transactions_archive_path = archive_folder / ("transactions-archive-" + timestamp + ".csv")
if __name__ == "__main__":
exit_code = 0
try:
print(f"Starting archiving to {orders_archive_path.__str__()} and {transactions_archive_path.__str__()}...")
connection = connect(
user = os.environ["PGDB_USER"],
password = os.environ["PGDB_PASSWORD"],
host = os.environ["PGDB_HOST"],
port = os.environ["PGDB_PORT"],
database = os.environ["PGDB_DB"]
)
cur = connection.cursor()
# # # # #
# copy
with orders_archive_path.open("w") as of:
cur.copy_expert(
"copy (select * from app_order) to STDOUT with csv delimiter ';'",
of
)
with transactions_archive_path.open("w") as tf:
cur.copy_expert(
"copy (select * from app_registertransaction) to STDOUT with csv delimiter ';'",
tf
)
# delete
cur.execute("delete from app_order;")
cur.execute("delete from app_registertransaction;")
connection.commit()
# # # # #
print("done.")
except (Error, Exception) as err:
connection.rollback()
print(f"An error occured while upgrading the database at {os.environ['PGDB_HOST']}:\n{err}")
exit_code = 1
finally:
cur.close()
connection.close()
exit(exit_code)

16
lib/auto-upgrade-db.sh Normal file
View file

@ -0,0 +1,16 @@
#!/usr/bin/env bash
echo -e "Checking if database needs an upgrade..."
if python3 $(pwd)/lib/verify-db-app-version.py; then
echo -e "No database upgrade needed."
else
echo -e "Starting automatic database upgrade..."
source "$(pwd)/lib/db-migrations.sh"
python3 $(pwd)/lib/upgrade-db.py
fi

145
lib/bootstrap.py Normal file
View file

@ -0,0 +1,145 @@
#!/usr/bin/env python3
from os import environ
from os import getcwd
from shlex import quote
from signal import SIGINT
from subprocess import run
from subprocess import Popen
from sys import argv
from sys import stdout
from sys import stderr
# devel or prod?
devel = False
try:
if argv[1] == "devel":
devel = True
except IndexError:
pass
# vars
pwd = getcwd()
APPLICATION_LOG = environ["APPLICATION_LOG"]
CADDY_ACCESS_LOG = environ["CADDY_ACCESS_LOG"]
CADDY_LOG = environ["CADDY_LOG"]
DJANGO_PORT = environ["DJANGO_PORT"]
HTTPS_PORT = environ["HTTPS_PORT"]
if devel:
environ["DJANGO_DEBUG"] = "true"
else:
environ["DJANGO_DEBUG"] = "false"
# info
print(f"\n\nStarting server on port {HTTPS_PORT}...\nYou should be able to access the application locally at https://localhost:{HTTPS_PORT}/\n\nPress Ctrl+C to stop all services.\n\n")
if not devel:
print(f"All further messages will be written to {APPLICATION_LOG} and {CADDY_LOG}")
print(f"HTTP Access Log will be written to {CADDY_ACCESS_LOG}")
try:
# start django/hypercorn
if devel:
run(
["python3", f"{pwd}/application/manage.py", "collectstatic", "--noinput"],
stdout=stdout,
stderr=stderr,
env=environ
)
app_process = Popen(
["python3", f"{pwd}/application/manage.py", "runserver", f"localhost:{DJANGO_PORT}"],
stdout=stdout,
stderr=stderr,
env=environ
)
else:
application_log_file = open(APPLICATION_LOG, "a")
run(
["python3", f"{pwd}/application/manage.py", "collectstatic", "--noinput"],
stdout=application_log_file,
stderr=application_log_file,
env=environ
)
app_process = Popen(
[
"hypercorn", "--bind", quote(f"localhost:{DJANGO_PORT}"), "drinks_manager.asgi:application"
],
stdout=application_log_file,
stderr=application_log_file,
cwd=f"{pwd}/application/",
env=environ
)
# start caddy
if devel:
caddy_log_file = stdout
caddy_log_file_stderr = stderr
else:
caddy_log_file = caddy_log_file_stderr = open(CADDY_LOG, "a")
caddy_process = Popen(
["caddy", "run", "--config", f"{pwd}/config/Caddyfile"],
stdout=caddy_log_file,
stderr=caddy_log_file_stderr,
env=environ
)
# start session-clear-scheduler
if devel:
clear_sched_log_file = stdout
clear_sched_log_file_stderr = stderr
else:
clear_sched_log_file = clear_sched_log_file_stderr = open(APPLICATION_LOG, "a")
scs_process = Popen(
["python3", f"{pwd}/lib/session-clear-scheduler.py"],
stdout=clear_sched_log_file,
stderr=clear_sched_log_file_stderr
)
caddy_process.wait()
scs_process.wait()
app_process.wait()
except KeyboardInterrupt:
# exit
print("\n\nStopping services.\n\n")
caddy_process.send_signal(SIGINT)
scs_process.send_signal(SIGINT)
app_process.send_signal(SIGINT)
caddy_process.wait()
print(f"Caddy stopped with exit code {caddy_process.returncode}.")
scs_process.wait()
print(f"session-clear-scheduler stopped with exit code {scs_process.returncode}.")
app_process.wait()
if devel:
print(f"Django stopped with exit code {app_process.returncode}.")
else:
print(f"Django/Hypercorn stopped with exit code {app_process.returncode}.")
if caddy_process.returncode != 0 or scs_process.returncode != 0 or app_process.returncode !=0:
exit(1)
else:
exit(0)

View file

@ -0,0 +1,7 @@
#!/usr/bin/env bash
# enable debugging for this command
export DJANGO_DEBUG="true"
# make migrations & migrate
python3 $(pwd)/application/manage.py clearsessions

10
lib/create-admin.sh Normal file
View file

@ -0,0 +1,10 @@
#!/usr/bin/env bash
# enable debugging for this command
export DJANGO_DEBUG="true"
# make migrations & migrate
python3 $(pwd)/application/manage.py createsuperuser
echo -e "done."

12
lib/db-migrations.sh Normal file
View file

@ -0,0 +1,12 @@
#!/usr/bin/env bash
# enable debugging for this command
export DJANGO_DEBUG="true"
# make migrations & migrate
python3 $(pwd)/application/manage.py makemigrations
python3 $(pwd)/application/manage.py makemigrations app
python3 $(pwd)/application/manage.py migrate
echo -e "done with db migration."

5
lib/env.sh Normal file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env bash
export DJANGO_SK_ABS_FP="$(pwd)/config/secret_key.txt"
export STATIC_FILES="$(pwd)/static/"
export APP_VERSION="2.6"

View file

@ -0,0 +1,30 @@
#!/usr/bin/env python3
import sys
from pathlib import Path
from secrets import token_bytes
from base64 import b85encode
#
override = False
if len(sys.argv) > 1:
if sys.argv[1] == "--override":
override = True
random_token_length = 128
secret_key_fp = Path("config/secret_key.txt")
#
if secret_key_fp.exists() and not override:
print(f"Warning: secret_key.txt already exists in directory {secret_key_fp.absolute()}. Won't override.", file=sys.stderr)
exit(1)
else:
print("Generating random secret key...")
random_key = b85encode(token_bytes(random_token_length))
with secret_key_fp.open("wb") as secret_key_f:
secret_key_f.write(random_key)
print("done.")

93
lib/generate-tls-cert.py Normal file
View file

@ -0,0 +1,93 @@
#!/usr/bin/env python3
import json
from datetime import datetime
from datetime import timedelta
from os import environ
from pathlib import Path
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
"""
this script creates a locally signed ca certificate.
"""
# paths
tls_root_dir = Path("config") / "tls"
path_server_cert = tls_root_dir / "server.pem"
path_server_key = tls_root_dir / "server-key.pem"
if __name__ == "__main__":
# get configuration from environment variable
conf_common_name = environ["TLS_COMMON_NAME"]
conf_tls_expire_after_days = int(environ["TLS_EXPIRE_AFTER_DAYS"])
try:
conf_alternative_name1 = environ["TLS_ALT_NAME1"]
except KeyError:
conf_alternative_name1 = None
try:
conf_alternative_name2 = environ["TLS_ALT_NAME2"]
except KeyError:
conf_alternative_name2 = None
# generate server cert & key
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=4096,
backend=default_backend()
)
subject = issuer = x509.Name([
x509.NameAttribute(x509.oid.NameOID.COUNTRY_NAME, "--"),
x509.NameAttribute(x509.oid.NameOID.STATE_OR_PROVINCE_NAME, "--"),
x509.NameAttribute(x509.oid.NameOID.LOCALITY_NAME, "--"),
x509.NameAttribute(x509.oid.NameOID.ORGANIZATION_NAME, "--"),
x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, conf_common_name)
])
cert_sans = []
if conf_alternative_name1 != None:
cert_sans.append(x509.DNSName(conf_alternative_name1))
if conf_alternative_name2 != None:
cert_sans.append(x509.DNSName(conf_alternative_name2))
certificate = x509.CertificateBuilder()\
.subject_name(subject)\
.issuer_name(issuer)\
.public_key(private_key.public_key())\
.serial_number(x509.random_serial_number())\
.not_valid_before(datetime.utcnow())\
.not_valid_after(datetime.utcnow() + timedelta(days=conf_tls_expire_after_days))\
.add_extension(x509.SubjectAlternativeName(cert_sans), critical=False)\
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)\
.sign(private_key, hashes.SHA512(), backend=default_backend())
with path_server_cert.open("wb") as certout:
certout.write(certificate.public_bytes(serialization.Encoding.PEM))
with path_server_key.open("wb") as keyout:
private_key_bytes = private_key.private_bytes(
encoding = serialization.Encoding.PEM,
format = serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
)
keyout.write(private_key_bytes)
print("Generated TLS certificate & key.")

21
lib/run-script.sh Normal file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env bash
# run a script in the context of the django project
export DJANGO_DEBUG="true"
if [ -z $2 ]; then
echo "Missing second argument <path>: the path to the script"
else
oldcwd="$(pwd)"
script_path=$2
echo "Starting $2 in a django shell:"
echo -e "--------------------------------------------------------------------------------\n"
cat "$script_path" | "$(pwd)/application/manage.py" shell
echo -e "\n--------------------------------------------------------------------------------"
cd "$oldcwd"
fi

View file

@ -0,0 +1,47 @@
#!/usr/bin/env python3
# This script clears expired sessions in a regular interval
# The interval is defined (in minutes) by config.sh (SESSION_CLEAR_INTERVAL)
import os
from pathlib import Path
from subprocess import run
from time import sleep
from datetime import datetime
try:
exiting = False
clear_running = False
print("[session-clear-scheduler] Starting session-clear-scheduler.")
session_clear_script_fp = Path("lib/clear-expired-sessions.sh")
clear_interval_seconds = int(os.environ["SESSION_CLEAR_INTERVAL"]) * 60
sleep(10) # wait some seconds before the first session clean-up
while True:
clear_running = True
run(["/bin/sh", session_clear_script_fp.absolute()])
clear_running = False
print(f"[session-clear-scheduler: {datetime.now()}] Cleared expired sessions.")
if exiting:
break
sleep(clear_interval_seconds)
except KeyboardInterrupt:
exiting = True
if clear_running:
print(f"[session-clear-scheduler: {datetime.now()}] Received SIGINT. Waiting for current clear process to finish.")
sleep(20) # wait some time
print(f"[session-clear-scheduler: {datetime.now()}] Exiting")
exit(0)

16
lib/setup-application.sh Normal file
View file

@ -0,0 +1,16 @@
#!/usr/bin/env bash
# enable debugging for this command
export DJANGO_DEBUG="true"
python3 "$(pwd)/lib/generate-secret-key.py"
source "$(pwd)/lib/db-migrations.sh"
python3 $(pwd)/lib/upgrade-db.py
echo -e "\nCreate admin account. Email is optional.\n"
source "$(pwd)/lib/create-admin.sh"
python3 $(pwd)/application/manage.py collectstatic --noinput

160
lib/upgrade-db.py Normal file
View file

@ -0,0 +1,160 @@
#!/usr/bin/env python3
import os, sys
from pathlib import Path
from psycopg2 import connect
from psycopg2._psycopg import cursor as _cursor
from psycopg2._psycopg import connection as _connection
from psycopg2 import Error
from psycopg2 import IntegrityError
from psycopg2 import errorcodes
# setup or upgrade the database
def log(s, error=False):
if error:
print(f"{s}", file=sys.stderr)
else:
print(f"{s}", file=sys.stdout)
def execute_sql_statement(cursor:_cursor, connection:_connection, sql_statement):
try:
cursor.execute(sql_statement)
connection.commit()
except IntegrityError as ie:
if ie.pgcode == errorcodes.UNIQUE_VIOLATION:
log("Skipping one row that already exists.")
connection.rollback()
else:
log(f"An integrity error occured:\n{ie}\nRolling back...", error=True)
connection.rollback()
except Error as e:
log(f"An SQL statement failed while upgrading the database at {os.environ['PGDB_HOST']}:\n{e}", error=True)
connection.rollback()
if __name__ == "__main__":
exit_code = 0
try:
log("\nSetting up/upgrading database...")
conn = connect(
user = os.environ["PGDB_USER"],
password = os.environ["PGDB_PASSWORD"],
host = os.environ["PGDB_HOST"],
port = os.environ["PGDB_PORT"],
database = os.environ["PGDB_DB"]
)
cur = conn.cursor()
# # # # #
execute_sql_statement(cur, conn, """
insert into app_global
values ('register_balance', 'This is the current balance of the register.', 0.0, '');
""")
execute_sql_statement(cur, conn, """
insert into app_global
values ('global_message', 'Here you can set a global message that will be shown to every user.', 0.0, '');
""")
execute_sql_statement(cur, conn, """
insert into app_global
values ('admin_info', 'Here you can set am infotext that will be displayed on the admin panel.', 0.0, '');
""")
execute_sql_statement(cur, conn, """
create or replace view app_userdeposits_view as
select * from app_registertransaction
where is_user_deposit = true;
""")
# # # # #
# set app_version in file and database
# database
try:
cur.execute("""
select value from application_info
where key = 'app_version';
""")
result = cur.fetchone()
if result == None:
cur.execute(f"""
insert into application_info values ('app_version', '{os.environ['APP_VERSION']}');
""")
conn.commit()
else:
cur.execute(f"""
update application_info set value = '{os.environ['APP_VERSION']}' where key = 'app_version';
""")
conn.commit()
except Error as err:
if err.pgcode == errorcodes.UNDEFINED_TABLE:
try:
conn.rollback()
cur.execute("""
create table application_info (
key varchar(32) primary key,
value text
);
""")
cur.execute(f"""
insert into application_info values ('app_version', '{os.environ['APP_VERSION']}');
""")
conn.commit()
except Error as err2:
log(f"An error occurred while setting app_version in table application_info: {err}", error=True)
exit_code = 1
else:
log(f"An error occurred while setting app_version in table application_info: {err}", error=True)
exit_code = 1
# file
Path("./config/db_app_version.txt").write_text(os.environ["APP_VERSION"])
log("done with db setup/upgrade.")
except (Error, Exception) as err:
log(f"An error occured while upgrading the database at {os.environ['PGDB_HOST']}:\n{err}", error=True)
exit_code = 1
finally:
cur.close()
conn.close()
exit(exit_code)

View file

@ -0,0 +1,105 @@
#!/usr/bin/env python3
from os import environ
from pathlib import Path
from psycopg2 import connect
from psycopg2._psycopg import cursor
from psycopg2 import Error
from psycopg2 import errorcodes
# verify if the installation
# exit code 0 -> no database update is necessary
# exit code 1 -> database update is necessary
def check_file():
db_app_version_file = Path("./config/db_app_version.txt")
if not db_app_version_file.exists():
exit(1)
if not db_app_version_file.is_file():
exit(1)
if not db_app_version_file.read_text().strip(" ").strip("\n") == environ["APP_VERSION"]:
exit(1)
def check_database():
try:
connection = connect(
user = environ["PGDB_USER"],
password = environ["PGDB_PASSWORD"],
host = environ["PGDB_HOST"],
port = environ["PGDB_PORT"],
database = environ["PGDB_DB"]
)
cur = connection.cursor()
# check application version in db
cur.execute("""
select value from application_info
where key = 'app_version';
""")
appinfo_result = list(cur.fetchone())[0]
if appinfo_result == None:
cur.close()
connection.close()
exit(1)
if appinfo_result != environ["APP_VERSION"]:
cur.close()
connection.close()
exit(1)
# check rows in app_global
required_rows = [
"global_message",
"register_balance",
"admin_info"
]
cur.execute("""
select name from app_global;
""")
table_global_result = list(cur.fetchall())
cur.close()
connection.close()
existing_rows = [list(row)[0] for row in table_global_result]
for r in required_rows:
if not r in existing_rows:
exit(1)
except Error:
cur.close()
connection.close()
exit(1)
except Exception as e:
print(f"An exception occured: {e}")
cur.close()
connection.close()
exit(1)
if __name__ == "__main__":
check_file()
check_database()
exit(0)

0
logs/.gitkeep Normal file
View file

View file

@ -0,0 +1,25 @@
# This is a sample service file for drinks manager
[Unit]
After=network.target network-online.target
Requires=network-online.target
Description=Drinks Manager
[Service]
User=drinks-manager
Group=drinks-manager
WorkingDirectory=/srv/drinks-manager/
# start the server:
ExecStart=/usr/bin/bash -c "/srv/drinks-manager/run.sh server"
# stop the process with a SIGINT:
ExecStop=/usr/bin/bash -c "/bin/kill -2 $MAINPID; /usr/bin/sleep 10"
Restart=on-failure
TimeoutStopSec=40s
LimitNPROC=512
LimitNOFILE=1048576
AmbientCapabilities=CAP_NET_BIND_SERVICE
PrivateTmp=true
ProtectSystem=full
[Install]
WantedBy=multi-user.target

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View file

@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="48"
height="48"
viewBox="0 0 12.7 12.7"
version="1.1"
id="svg5"
inkscape:version="1.1 (c4e8f9ed74, 2021-05-24)"
sodipodi:docname="drinksmanager-icon.src.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<sodipodi:namedview
id="namedview7"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:pageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:document-units="px"
showgrid="true"
inkscape:zoom="11.313709"
inkscape:cx="18.738329"
inkscape:cy="21.434173"
inkscape:window-width="1920"
inkscape:window-height="1135"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg5">
<inkscape:grid
type="xygrid"
id="grid9" />
</sodipodi:namedview>
<defs
id="defs2">
<linearGradient
inkscape:collect="always"
id="linearGradient3218">
<stop
style="stop-color:#ffc64a;stop-opacity:1"
offset="0"
id="stop3214" />
<stop
style="stop-color:#e63a44;stop-opacity:1"
offset="1"
id="stop3216" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient3218"
id="linearGradient47271"
x1="6.0854168"
y1="6.3499999"
x2="6.3499999"
y2="9.2604933"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(0,-1.0584098)" />
<filter
inkscape:collect="always"
style="color-interpolation-filters:sRGB"
id="filter3494"
x="-0.15966609"
y="-0.24991529"
width="1.3193322"
height="1.4998306">
<feGaussianBlur
inkscape:collect="always"
stdDeviation="0.4683102"
id="feGaussianBlur3496" />
</filter>
</defs>
<path
id="path13"
style="opacity:1;fill:none;fill-opacity:1;stroke:#6f6f6f;stroke-width:0.79375;stroke-linejoin:round;stroke-opacity:1;filter:url(#filter3494)"
inkscape:label="glass shadow"
d="m 11.1125,5.2915901 c 0,2.6302562 -2.1322439,4.7624999 -4.7625001,4.7624999 -2.6302561,0 -4.7625,-2.1322438 -4.7624998,-4.7624999 L 1.5875,3.96875 h 9.525 z"
sodipodi:nodetypes="cccccc" />
<path
id="path47880"
style="opacity:1;fill:#d3ecec;fill-opacity:1;stroke:#d3ecec;stroke-width:0.79375;stroke-linejoin:round;stroke-opacity:1"
inkscape:label="glass"
d="m 11.1125,5.2915901 c 0,2.6302562 -2.1322439,4.7624999 -4.7625001,4.7624999 -2.6302561,0 -4.7625,-2.1322438 -4.7624998,-4.7624999 L 1.5875,3.96875 h 9.525 z"
sodipodi:nodetypes="cccccc" />
<path
id="path36439"
style="opacity:1;fill:url(#linearGradient47271);fill-opacity:1;stroke:none;stroke-width:0.79375;stroke-linejoin:round"
inkscape:label="drink"
d="m 11.1125,5.2915901 c 0,2.6302562 -2.1322439,4.7624999 -4.7625001,4.7624999 -2.6302561,0 -4.7625,-2.1322438 -4.7624998,-4.7624999 0,0 2.1134564,-0.2957782 3.1749999,-0.2645832 1.2467196,0.036637 2.4571869,0.5028338 3.7041667,0.5291666 C 9.3528123,5.5748866 11.1125,5.2915901 11.1125,5.2915901 Z"
sodipodi:nodetypes="cccssc"
sodipodi:insensitive="true" />
<metadata
id="metadata5638">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:creator>
<cc:Agent>
<dc:title>Julian Müller (W13R)</dc:title>
</cc:Agent>
</dc:creator>
</cc:Work>
</rdf:RDF>
</metadata>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
misc/icons/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
misc/icons/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

6
pip-dependencies.txt Normal file
View file

@ -0,0 +1,6 @@
django~=3.2.7
django-currentuser==0.5.3
django-csp==3.7
psycopg2~=2.9.1
hypercorn~=0.11.2
cryptography~=36.0.0

100
run.sh Executable file
View file

@ -0,0 +1,100 @@
#!/usr/bin/env bash
function show_dm_help { # $1 = exit code
echo -e "Usage:\t./run.sh <command>\n"
echo -e "\nCommands:\n"
echo -e " server\t\tstart server"
echo -e " setup\t\t\tset up the application"
echo -e " create-admin\t\tcreate an admin account"
echo -e " generate-tls-cert\tgenerate a new self-signed tls certificate for https"
echo -e " generate-secret-key\tgenerate a new random secret key for django"
echo -e " clear-sessions\tmanually remove all expired sessions from the database"
echo -e " force-db-upgrade\tforce a database migration & upgrade"
echo -e " archive-tables\tarchive (copy & delete) all entries in app_order and app_registertransaction"
echo -e " development-server\tstart django development server and enable debugging"
echo -e " run-script <path>\tRun a python script in the context of the django project (experimental)"
echo -e " help\t\t\tShow this help text\n"
echo -e "\nExamples:\n"
echo -e " ./run.sh server"
echo -e " ./run.sh create-admin"
echo ""
exit $1
}
# set current working directory
cd $(dirname "$0")
source "$(pwd)/lib/env.sh"
echo -e "\n## Drinks Manager"
echo -e "## version $APP_VERSION\n"
if [ -z $1 ]; then
show_dm_help 1
else
source "$(pwd)/config/config.sh"
if [ $1 = 'server' ]; then
source "$(pwd)/lib/auto-upgrade-db.sh"
python3 "$(pwd)/lib/bootstrap.py"
elif [ $1 = 'development-server' ]; then
source "$(pwd)/lib/auto-upgrade-db.sh"
python3 "$(pwd)/lib/bootstrap.py" devel
elif [ $1 = 'setup' ]; then
source "$(pwd)/lib/setup-application.sh"
elif [ $1 = 'generate-tls-cert' ]; then
source "$(pwd)/config/tls/cert-config.sh"
python3 "$(pwd)/lib/generate-tls-cert.py"
elif [ $1 = 'generate-secret-key' ]; then
python3 "$(pwd)/lib/generate-secret-key.py" --override
elif [ $1 = 'force-db-upgrade' ]; then
source "$(pwd)/lib/db-migrations.sh"
python3 "$(pwd)/lib/upgrade-db.py"
elif [ $1 = 'create-admin' ]; then
source "$(pwd)/lib/create-admin.sh"
elif [ $1 = 'clear-sessions' ]; then
source "$(pwd)/lib/clear-expired-sessions.sh"
echo -e "done."
elif [ $1 = 'archive-tables' ]; then
python3 "$(pwd)/lib/archive-tables.py"
elif [ $1 = 'run-script' ]; then
source "$(pwd)/lib/run-script.sh"
elif [ $1 = 'help' ]; then
show_dm_help 0
else
show_dm_help 1
fi
fi

View file

@ -0,0 +1,40 @@
/* custom number input */
.customNumberInput {
display: flex;
flex-direction: row;
height: 2.2rem;
width: 100% !important;
}
.customNumberInput button {
min-width: 2.5rem !important;
width: 2.5rem !important;
padding: 0;
margin: 0;
height: 100%;
}
.customNumberInput-minus {
border-bottom-right-radius: 0;
border-top-right-radius: 0;
z-index: 10;
}
.customNumberInput-plus {
border-bottom-left-radius: 0;
border-top-left-radius: 0;
z-index: 10;
}
.customNumberInput input[type="number"] {
max-height: 100%;
width: 5rem;
padding: 0;
margin: 0;
font-size: .9rem;
color: var(--color);
text-align: center;
background: var(--glass-bg-color2);
outline: none;
border: none;
border-radius: 0 !important;
-webkit-appearance: textfield;
-moz-appearance: textfield;
appearance: textfield;
}

11
static/css/deposit.css Normal file
View file

@ -0,0 +1,11 @@
#depositAmount {
width: 10rem;
}
main {
margin-top: 0;
}
@media only screen and (max-width: 700px) {
main {
margin-top: -15vh;
}
}

23
static/css/history.css Normal file
View file

@ -0,0 +1,23 @@
.history {
margin: 0;
padding: 0;
width: 40%;
min-width: 30rem;
}
.history td {
padding-top: .4rem !important;
padding-bottom: .4rem !important;
font-size: .95rem;
}
.history .historyDate {
margin-left: auto;
text-align: right;
font-size: .8rem !important;
}
/* mobile devices */
@media only screen and (max-width: 700px) {
.history {
width: 90%;
min-width: 90%;
}
}

45
static/css/index.css Normal file
View file

@ -0,0 +1,45 @@
.availableDrinksList {
width: 50%;
max-width: 45rem;
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
justify-content: start;
align-items: center;
}
.availableDrinksList li {
display: flex;
width: 100%;
height: fit-content;
margin-bottom: .6rem;
}
.availableDrinksList li a {
display: flex;
width: 100%;
align-items: center;
justify-content: start;
color: var(--color);
padding: .8rem 1.1rem;
text-decoration: none;
font-size: 1rem;
}
.availableDrinksList li a span:first-child {
margin-right: 1rem !important;
}
.availableDrinksList li a span:last-child {
margin-left: auto;
text-align: right;
font-size: 1rem;
}
/* mobile devices */
@media only screen and (max-width: 700px) {
.availableDrinksList {
width: 95%;
}
.availableDrinksList li a {
width: calc(100vw - (2 * .8rem)) !important;
padding: .8rem !important;
}
}

117
static/css/login.css Normal file
View file

@ -0,0 +1,117 @@
/* login page */
body.overflowHidden {
overflow-y: hidden !important;
overflow-x: hidden !important;
}
main {
margin-top: 2vh;
}
main > h1 {
display: none;
}
.userlistContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: start;
}
.userlist {
width: 50vw;
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.userlist li {
display: flex;
width: 100%;
align-items: center;
justify-content: center;
padding: .8rem 1.1rem;
margin-bottom: .5rem;
text-align: center;
}
.userlistButton {
font-size: 1.1rem;
}
.passwordOverlayContainer {
position: absolute;
top: 0;
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: start;
background: var(--page-background);
z-index: 40;
}
.passwordOverlay {
display: flex;
flex-direction: column;
justify-content: start;
align-items: center;
margin-top: 10vh;
}
.passwordOverlay > form {
min-width: unset;
width: fit-content;
}
.passwordOverlay > form > h1 {
margin-top: 3rem;
margin-bottom: 3rem;
}
form input[type="password"], form input[type="text"] {
width: 94%;
padding-top: .5rem;
padding-bottom: .5rem;
font-size: 1rem;
margin: .1rem 0;
}
.pinpad {
margin-top: 3rem;
margin-bottom: 2rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
max-width: 30vw;
}
.pinpad table {
box-shadow: none !important;
}
.pinpad table tr, .pinpad td {
padding: unset;
background: unset;
}
.pinpad tr td button {
height: 4.0rem;
width: 4.1rem;
font-size: 1.16rem;
margin: .2rem !important;
}
@media only screen and (max-width: 700px) {
.userlistContainer {
width: 95vw;
}
.userlist {
width: 100%;
}
.userlist li {
width: 100%;
padding-left: 0;
padding-right: 0;
}
.pinpad table tr td button {
height: 4.2rem;
width: 4.2rem;
font-size: 1.16rem;
margin: .2rem;
}
.passwordOverlay {
margin-top: 2rem;
}
}

372
static/css/main.css Normal file
View file

@ -0,0 +1,372 @@
/* VARIABLES */
:root {
/** FONT **/
--font-family: 'Liberation Sans', sans-serif;
/** colors **/
--color: #fafafa;
--color-error: rgb(255, 70, 70);
/** glass **/
--glass-bg-dropDown: #3a3b44ef;
--glass-bg-dropDown-hover: #55565efa;
--glass-bg-color1: #ffffff31;
--glass-bg-color2: #ffffff1a;
--glass-bg-hover-color1: #ffffff46;
--glass-bg-hover-color2: #ffffff1a;
--glass-blur: none;
--glass-border-color: #ffffff77;
--glass-bg: linear-gradient(var(--glass-bg-color1), var(--glass-bg-color2));
--glass-bg-hover: linear-gradient(var(--glass-bg-hover-color1), var(--glass-bg-hover-color2));
--glass-corner-radius: .3rem;
/** page background **/
--page-background-color1: #131d25;
--page-background-color2: #311d30;
--page-background: linear-gradient(-190deg, var(--page-background-color1), var(--page-background-color2));
/** global message banner **/
--bg-globalMessage: linear-gradient(135deg, #4b351c, #411d52, #1c404b);
}
@supports(backdrop-filter: blur(10px)) {
:root {
--glass-bg-dropDown: #ffffff1a;
--glass-bg-dropDown-hover: #ffffff46;
--glass-blur: blur(18px);
}
}
/* BASE LAYOUT */
body {
margin: 0;
padding: 0;
width: 100vw;
min-height: 100vh;
font-family: var(--font-family);
background: var(--page-background);
color: var(--color);
overflow-x: hidden;
}
.baseLayout {
display: flex;
flex-direction: column;
justify-content: start;
align-items: center;
min-height: 100vh;
width: 100vw;
max-width: 100vw;
}
main {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
flex-grow: 1;
width: 100%;
margin-top: calc(-14rem + 2vh);
}
.userPanel {
width: 100%;
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-start;
min-width: fit-content;
margin-top: 1rem;
pointer-events: none;
}
.userPanel > div {
margin: 0 1rem;
}
.userBalanceWarn {
color: var(--color-error);
font-weight: bold;
}
.content {
display: flex;
flex-direction: column;
justify-content: start;
align-items: center;
width: 100%;
flex-grow: 1;
}
main > h1 {
margin-top: 0;
}
.globalMessage {
width: 100vw;
z-index: 999;
display: flex;
justify-content: center;
align-items: center;
background: var(--bg-globalMessage);
padding: .3rem 0;
}
.globalMessage div {
width: 96%;
text-align: center;
word-break: keep-all;
word-wrap: break-word;
box-sizing: border-box;
}
/* DROP DOWN MENUS */
.dropDownMenu {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
border-radius: var(--glass-corner-radius);
}
.dropDownButton {
width: fit-content;
z-index: 190;
box-shadow: none;
text-align: center;
justify-content: center;
pointer-events: all;
}
.dropDownButton, .dropDownChoice {
font-size: 1rem;
}
.dropDownButton > div::after {
content: '\25BC';
display: inline-block;
transition: transform 100ms;
padding: 0 .3rem;
}
.dropDownList {
display: flex;
flex-direction: column;
pointer-events: none;
border-radius: var(--glass-corner-radius) !important;
backdrop-filter: var(--glass-blur);
z-index: 200;
margin-top: .5rem;
opacity: 0%;
transition: opacity 100ms;
}
.dropDownChoice {
box-shadow: none;
border-radius: 0 !important;
margin: 0;
margin-top: -1px;
text-align: center;
justify-content: center;
background: var(--glass-bg-dropDown) !important;
backdrop-filter: none !important;
}
.dropDownChoice:hover {
background: var(--glass-bg-dropDown-hover) !important;
}
.dropDownList :first-child {
border-top-left-radius: var(--glass-corner-radius) !important;
border-top-right-radius: var(--glass-corner-radius) !important;
}
.dropDownList :last-child {
border-bottom-left-radius: var(--glass-corner-radius) !important;
border-bottom-right-radius: var(--glass-corner-radius) !important;
}
.dropDownVisible .dropDownList {
opacity: 100%;
visibility: visible;
pointer-events: visible;
}
.dropDownVisible > .dropDownButton > div::after {
transform: rotate(180deg);
}
.userPanel .dropDownButton, .userPanel .dropDownChoice {
font-size: 1.1rem;
}
/* FOOTER */
.footer {
z-index: 990;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
flex-wrap: wrap;
margin-top: auto;
padding-top: 3rem;
padding-bottom: .3rem;
}
.footer div {
font-size: .95rem;
margin-top: .15rem;
margin-bottom: .15rem;
}
.footer div::after {
margin-left: .5rem;
content: "-";
margin-right: .5rem;
}
.footer div:last-child::after {
content: none;
margin-left: 0;
margin-right: 0;
}
/* TABLES */
table {
border-collapse: collapse;
border-spacing: 0;
text-align: left;
border-radius: var(--glass-corner-radius);
backdrop-filter: var(--glass-blur);
}
tr {
background: var(--glass-bg-color1);
}
tr:nth-child(2n+2) {
background: var(--glass-bg-color2);
}
/*
Rounded corners on table cells apparently don't work with
Firefox (91), so Firefox users won't have rounded corners
on tables. Won't fix that by myself.
*/
table tr:first-child th:first-child {
border-top-left-radius: var(--glass-corner-radius);
}
table tr:first-child th:last-child {
border-top-right-radius: var(--glass-corner-radius);
}
table tr:last-child td:first-child {
border-bottom-left-radius: var(--glass-corner-radius);
}
table tr:last-child td:last-child {
border-bottom-right-radius: var(--glass-corner-radius);
}
/* - */
td, th {
padding: .5rem .8rem;
}
th {
text-align: left;
border-bottom: 1px solid var(--color);
}
/* FORMS */
form {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 18rem;
height: max-content;
}
form .row {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin: .2rem 0;
}
form .row .column {
display: flex;
flex-direction: row;
}
form h1 {
font-size: 1.6rem;
margin-bottom: 2rem;
}
form {
font-size: 1.1rem;
}
form .customNumberInput {
width: 100%;
}
form .statusInfo {
margin-top: .5rem;
}
form .horizontalButtonList {
margin-top: 2rem;
width: 100%;
}
form .button, form button {
font-size: 1rem;
}
/* BUTTONS & OTHER INPUT ELEMENTS */
.button, button {
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-family);
font-size: .9rem;
text-decoration: none;
text-align: center !important;
background: var(--glass-bg);
color: var(--color);
padding: .6rem .8rem;
outline: none;
border: 1px solid var(--glass-border-color);
border-radius: var(--glass-corner-radius);
backdrop-filter: var(--glass-blur);
cursor: pointer;
user-select: none;
}
.button:hover, button:hover, .button:active, button:active {
background: var(--glass-bg-hover);
}
.button:disabled, button:disabled {
opacity: 40%;
}
a {
color: var(--color);
}
input[type="number"] {
-webkit-appearance: textfield;
-moz-appearance: textfield;
appearance: textfield;
}
input[type="number"]::-webkit-inner-spin-button {
display: none;
}
input[type="text"], input[type="password"], input[type="number"] {
background: var(--glass-bg-color2);
outline: none;
padding: .4rem .6rem;
font-size: .9rem;
color: var(--color);
text-align: center;
border: none;
border-radius: var(--glass-corner-radius);
}
/**** CUSTOM CLASSES ****/
.centeringFlex {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding: 2rem 1rem;
}
.horizontalButtonList {
display: flex;
flex-direction: row;
align-items: flex-end;
justify-content: space-between;
width: 100%;
}
.errorText {
margin-top: 1rem;
color: var(--color-error);
}
.nodisplay {
display: none !important;
}
/* MISC / GENERAL */
h1 {
text-align: center;
font-size: 1.8rem;
}
/* MOBILE OPTIMIZATIONS */
@media only screen and (max-width: 700px) {
.globalMessage span {
width: 90%;
}
.userPanel {
flex-direction: column;
justify-content: start;
align-items: center;
}
.userPanel > div {
margin: 0;
margin-bottom: .5rem;
}
}

11
static/css/order.css Normal file
View file

@ -0,0 +1,11 @@
main {
margin-top: 0;
}
form {
width: 22rem;
}
@media only screen and (max-width: 700px) {
main {
margin-top: -15vh;
}
}

69
static/css/statistics.css Normal file
View file

@ -0,0 +1,69 @@
.mainContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: start;
width: 100%;
flex-grow: 1;
}
.statsHeading {
min-width: max-content;
margin-left: 2rem;
margin-top: 0;
}
.tablesContainer {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: start;
width: 95%;
margin-top: 5rem;
}
.statisticsTable {
margin-bottom: 2rem;
padding-bottom: 1rem;
display: flex;
flex-direction: column;
justify-content: start;
align-items: center;
text-align: center;
}
.statisticsTable h1 {
margin-top: 0;
font-size: 1.2rem;
text-align: left;
min-width: 10rem;
text-align: center;
}
.statisticsTable table {
min-width: 20vw;
width: fit-content;
}
.statisticsTable th:last-child {
text-align: right;
}
.statisticsTable td:last-child {
text-align: right;
}
#statisticsDropDownMenu .dropDownList {
z-index: 195;
}
@media only screen and (max-width: 700px) {
.statisticsTable h1 {
min-width: 90vw;
}
.statisticsTable table {
min-width: 80vw;
}
.statisticsTable {
margin-bottom: 2rem;
padding-bottom: 1rem;
}
.statsHeading {
margin-left: 0;
margin-right: 0;
padding-top: 1rem;
padding-bottom: 2rem;
}
}

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View file

@ -0,0 +1,30 @@
{
document.addEventListener("DOMContentLoaded", () => {
// get all customNumberInput Elements
let custom_number_inputs = document.getElementsByClassName("customNumberInput");
// Add Event Handler to the elements of the customNumberInputs
[...custom_number_inputs].forEach(element => {
// number input
let numberFieldElement = element.getElementsByClassName("customNumberInputField")[0];
// minus button
element.getElementsByClassName("customNumberInput-minus")[0].addEventListener("click", () => {
alterCustomNumberField(numberFieldElement, -1)
});
// plus button
element.getElementsByClassName("customNumberInput-plus")[0].addEventListener("click", () => {
alterCustomNumberField(numberFieldElement, +1)
});
})
})
function alterCustomNumberField(numberFieldElement, n) {
numberFieldElement.value = Math.min(
Math.max(
(parseInt(numberFieldElement.value) + n), numberFieldElement.min || Number.MIN_VALUE
),
numberFieldElement.max || Number.MAX_VALUE
);
}
}

49
static/js/deposit.js Normal file
View file

@ -0,0 +1,49 @@
document.addEventListener("DOMContentLoaded", () => {
// elements
let deposit_form = document.getElementById("depositForm");
let status_info = document.getElementById("statusInfo");
let deposit_submit_button = document.getElementById("depositSubmitBtn");
// event listener for deposit form
// this implements a custom submit method
deposit_form.addEventListener("submit", (event) => {
deposit_submit_button.disabled = true;
event.preventDefault(); // Don't do the default submit action!
let xhr = new XMLHttpRequest();
let formData = new FormData(deposit_form);
xhr.addEventListener("load", (event) => {
status_ = event.target.status;
response_ = event.target.responseText;
if (status_ == 200 && response_ == "success") {
status_info.innerText = "Success. Redirecting soon.";
window.location.replace("/");
}
else {
status_info.classList.add("errorText");
status_info.innerText = "An error occured. Redirecting in 5 seconds...";
window.setTimeout(() => { window.location.replace("/") }, 5000);
}
})
xhr.addEventListener("error", (event) => {
status_info.classList.add("errorText");
status_info.innerText = "An error occured. Redirecting in 5 seconds...";
window.setTimeout(() => { window.location.replace("/") }, 5000);
})
xhr.open("POST", "/api/deposit");
xhr.send(formData);
});
})

1
static/js/logged_out.js Normal file
View file

@ -0,0 +1 @@
window.location.replace("/");

87
static/js/login.js Normal file
View file

@ -0,0 +1,87 @@
{
// Define variables
let username_input;
let password_input;
let submit_button;
let username_display;
let password_overlay;
let pw_overlay_cancel;
let userlist_buttons;
let pinpad_buttons;
// Add event listeners after DOM Content loaded
document.addEventListener("DOMContentLoaded", () => {
// elements
username_input = document.getElementById("id_username");
password_input = document.getElementById("id_password");
submit_button = document.getElementById("submit_login");
password_overlay = document.getElementById("passwordOverlayContainer");
pw_overlay_cancel = document.getElementById("pwoCancel");
userlist_buttons = document.getElementsByClassName("userlistButton");
pinpad_buttons = document.getElementsByClassName("pinpadBtn");
// event listeners
// [...<html-collection>] converts an html collection to an array
[...userlist_buttons].forEach(element => {
element.addEventListener("click", () => {
set_username(element.dataset.username);
show_password_overlay();
})
});
[...pinpad_buttons].forEach(element => {
element.addEventListener("click", () => {
pinpad_press(element.dataset.btn);
})
})
pw_overlay_cancel.addEventListener("click", () => {
hide_password_overlay();
});
})
function set_username(username) {
username_input.value = username;
}
function show_password_overlay() {
window.scrollTo(0, 0);
password_overlay.classList.remove("nodisplay");
document.body.classList.add("overflowHidden");
//password_input.focus();
}
function hide_password_overlay() {
password_overlay.classList.add("nodisplay");
document.body.classList.remove("overflowHidden");
password_input.value = "";
}
function pinpad_press(key) {
if (key == "enter") {
submit_button.click();
}
else if (key == "x") {
password_input.value = "";
}
else {
password_input.value += key;
}
}
}

21
static/js/main.js Normal file
View file

@ -0,0 +1,21 @@
document.addEventListener("DOMContentLoaded", () => {
let dropDownMenuElement = document.getElementById("dropDownMenu");
let dropDownMenuButtonElement = document.getElementById("dropDownMenuButton");
if (dropDownMenuButtonElement != null) {
dropDownMenuButtonElement.addEventListener("click", () => {
if (dropDownMenuElement.classList.contains("dropDownVisible")) {
dropDownMenuElement.classList.remove("dropDownVisible");
}
else {
dropDownMenuElement.classList.add("dropDownVisible");
}
})
}
})

75
static/js/order.js Normal file
View file

@ -0,0 +1,75 @@
document.addEventListener("DOMContentLoaded", () => {
// elements
let order_number_of_drinks_input = document.getElementById("numberOfDrinks");
let order_number_of_drinks_btn_a = document.getElementById("numberOfDrinksBtnA");
let order_number_of_drinks_btn_b = document.getElementById("numberOfDrinksBtnB");
let order_sum_element = document.getElementById("orderCalculatedSum");
let order_form = document.getElementById("orderForm");
let status_info = document.getElementById("statusInfo");
let order_submit_button = document.getElementById("orderSubmitBtn");
// calculate & display sum
let order_price_per_drink = parseFloat(document.getElementById("pricePerDrink").dataset.drinkPrice);
function calculate_and_display_sum() {
setTimeout(() => {
let number_of_drinks = parseFloat(order_number_of_drinks_input.value);
let calculated_sum = order_price_per_drink * number_of_drinks;
order_sum_element.innerText = new Intl.NumberFormat(undefined, {minimumFractionDigits: 2}).format(calculated_sum);
}, 25);
}
order_number_of_drinks_input.addEventListener("input", calculate_and_display_sum);
order_number_of_drinks_btn_a.addEventListener("click", calculate_and_display_sum);
order_number_of_drinks_btn_b.addEventListener("click", calculate_and_display_sum);
// custom submit method
order_form.addEventListener("submit", (event) => {
order_submit_button.disabled = true;
event.preventDefault(); // Don't do the default submit action!
let xhr = new XMLHttpRequest();
let formData = new FormData(order_form);
xhr.addEventListener("load", (event) => {
status_ = event.target.status;
response_ = event.target.responseText;
if (status_ == 200 && response_ == "success") {
status_info.innerText = "Success.";
window.location.replace("/");
}
else {
status_info.classList.add("errorText");
status_info.innerText = "An error occured.";
window.setTimeout(() => { window.location.reload() }, 5000);
}
})
xhr.addEventListener("error", (event) => {
status_info.classList.add("errorText");
status_info.innerText = "An error occured.";
window.setTimeout(() => { window.location.reload() }, 5000);
})
xhr.open("POST", "/api/order-drink");
xhr.send(formData);
});
})

42
static/js/statistics.js Normal file
View file

@ -0,0 +1,42 @@
{
let statistics_dropdown_choices;
let statistics_tables;
let dropDownMenuActive = false;
document.addEventListener("DOMContentLoaded", () => {
// elements
let statistics_dropdown_menu = document.getElementById("statisticsDropDownMenu");
let statistics_dropdown_menu_button = document.getElementById("statisticsDropDownMenuButton");
statistics_dropdown_choices = [...statistics_dropdown_menu.getElementsByClassName("sChoice")];
statistics_tables = [...document.getElementsByClassName("statisticsTable")];
statistics_dropdown_menu_button.addEventListener("click", () => {
if (statistics_dropdown_menu.classList.contains("dropDownVisible")) {
statistics_dropdown_menu.classList.remove("dropDownVisible");
}
else {
statistics_dropdown_menu.classList.add("dropDownVisible");
}
})
statistics_dropdown_choices.forEach(element => {
element.addEventListener("click", () => {
changeStatisticsChoice(element.innerText, element.dataset.statistics_div);
})
})
})
function changeStatisticsChoice(choice_name, div_id) {
statistics_tables.forEach(element => {
element.classList.add("nodisplay");
})
document.getElementById(div_id).classList.remove("nodisplay");
}
}

18
tests/lib/__init__.py Normal file
View file

@ -0,0 +1,18 @@
def parse_config_from_file(filepath):
config = {}
with open(filepath, "r") as f:
lines = f.readlines()
for line in lines:
line = line.lstrip(" ").replace("\n", "")
if line.startswith("export "):
line = line.replace("export ", "").lstrip(" ")
varname = line[:line.find("=")]
varvalue = line[line.find("=")+1:]
if varvalue.startswith("'"): varvalue = varvalue.strip("'")
elif varvalue.startswith('"'): varvalue = varvalue.strip('"')
config[varname] = varvalue
return config

View file

@ -0,0 +1,84 @@
#!/usr/bin/env python3
import os, sys
from pathlib import Path
from psycopg2 import connect
from psycopg2 import Error
from lib import parse_config_from_file
USER_ID = 2
N_NEW_ORDER_ROWS = 1000000
COMMIT_AFTER = 50
AMOUNT_PER_ORDER = 1
PRODUCT_NAME = "Wasser"
DRINK_ID = 1
if __name__ == "__main__":
print("\nGetting config...")
config_file = Path(Path(os.path.dirname(__file__)).parent / "config" / "config.sh").absolute()
config = parse_config_from_file(config_file)
print(f"Commit will be done after every {COMMIT_AFTER} rows.")
x = input(f"Do you want to add {N_NEW_ORDER_ROWS} rows to the app_order table? (enter 'yes' to continue) ")
try:
if str(x) != "yes":
exit()
except ValueError:
exit()
try:
print("\nConnecting to database...")
conn = connect(
user = config["PGDB_USER"],
password = config["PGDB_PASSWORD"],
host = config["PGDB_HOST"],
port = config["PGDB_PORT"],
database = config["PGDB_DB"]
)
cur = conn.cursor()
for i in range(N_NEW_ORDER_ROWS):
cur.execute(f"""
insert into app_order (datetime, product_name, price_sum, content_litres, drink_id, user_id, amount)
values (
current_timestamp,
'{PRODUCT_NAME}',
10.00,
0.5,
{DRINK_ID},
{USER_ID},
{AMOUNT_PER_ORDER}
)
""")
if i % COMMIT_AFTER == 0 and not i == 0:
conn.commit()
print(f"\nAdded {i} rows")
conn.commit()
print(f"\nAdded {N_NEW_ORDER_ROWS} rows")
print("done with db setup.")
except (Error, Exception) as err:
print(f"An error occured while connecting to the database {config['PGDB_DB']} at {config['PGDB_HOST']}:\n{err}", file=sys.stderr)
finally:
cur.close()
conn.close()