commit c49798a9ea72befd58aa58986e198afaa30925de Author: W13R <9070224-W13R@users.noreply.gitlab.com> Date: Wed Mar 16 12:11:30 2022 +0100 Initial commit - existing project files diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1c2a32 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f910113 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7292121 --- /dev/null +++ b/README.md @@ -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 diff --git a/application/app/__init__.py b/application/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/application/app/admin.py b/application/app/admin.py new file mode 100644 index 0000000..c3b7b20 --- /dev/null +++ b/application/app/admin.py @@ -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) diff --git a/application/app/apps.py b/application/app/apps.py new file mode 100644 index 0000000..e61ab8c --- /dev/null +++ b/application/app/apps.py @@ -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' diff --git a/application/app/context_processors.py b/application/app/context_processors.py new file mode 100644 index 0000000..f3e345f --- /dev/null +++ b/application/app/context_processors.py @@ -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 + } diff --git a/application/app/forms.py b/application/app/forms.py new file mode 100644 index 0000000..6f14d23 --- /dev/null +++ b/application/app/forms.py @@ -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") diff --git a/application/app/models.py b/application/app/models.py new file mode 100644 index 0000000..2e544dc --- /dev/null +++ b/application/app/models.py @@ -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"" + 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 diff --git a/application/app/sql_queries.py b/application/app/sql_queries.py new file mode 100644 index 0000000..0448708 --- /dev/null +++ b/application/app/sql_queries.py @@ -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] \ No newline at end of file diff --git a/application/app/templates/admin/base_site.html b/application/app/templates/admin/base_site.html new file mode 100644 index 0000000..aa2f0c0 --- /dev/null +++ b/application/app/templates/admin/base_site.html @@ -0,0 +1,18 @@ +{% extends "admin/base.html" %} + +{% block title %} +{% if subtitle %} +{{ subtitle }} | +{% endif %} +{{ title }} | {{ site_title|default:_('Django site admin') }} +{% endblock %} + +{% block extrahead %} + +{% endblock %} + +{% block branding %} +

{{ site_header|default:_('Django administration') }}

+{% endblock %} + +{% block nav-global %}{% endblock %} \ No newline at end of file diff --git a/application/app/templates/admin/index.html b/application/app/templates/admin/index.html new file mode 100644 index 0000000..4e90d29 --- /dev/null +++ b/application/app/templates/admin/index.html @@ -0,0 +1,19 @@ +{% extends "admin/index.html" %} + +{% block sidebar %} + + {{ block.super }} + +
+
+

Current Register Balance: {{ registerBalance }}{{ currency_suffix }}

+ {% if global_message != "" %} +

Global Message: {{ global_message }}

+ {% endif %} + {% if admin_info != "" %} +

Admin Info: {{ admin_info }}

+ {% endif %} +
+
+ +{% endblock %} \ No newline at end of file diff --git a/application/app/templates/baseLayout.html b/application/app/templates/baseLayout.html new file mode 100644 index 0000000..22ff47c --- /dev/null +++ b/application/app/templates/baseLayout.html @@ -0,0 +1,59 @@ + + +{% load i18n %} + + + + + + + + + + {% block title %}{% endblock %} + {% block headAdditional %}{% endblock %} + + + + +
+ + {% include "globalMessage.html" %} + + {% if user.is_authenticated %} + +
+ {% include "userPanel.html" %} +
+ + {% endif %} + +
+ + {% if user.is_authenticated or "accounts/login/" in request.path or "accounts/logout/" in request.path %} + +

{% block heading %}{% endblock %}

+
+ {% block content %}{% endblock %} +
+ + {% else %} + +
+ {% translate "An error occured. Please log out and log in again." %} +
+ log out +
+ + {% endif %} + +
+ + {% include "footer.html" %} + +
+ + + + + \ No newline at end of file diff --git a/application/app/templates/deposit.html b/application/app/templates/deposit.html new file mode 100644 index 0000000..1093070 --- /dev/null +++ b/application/app/templates/deposit.html @@ -0,0 +1,39 @@ +{% extends "baseLayout.html" %} + +{% load i18n %} + +{% block title %} + {% translate "Drinks - Deposit" %} +{% endblock %} + +{% block headAdditional %} + +{% endblock %} + +{% block heading %} + {% translate "Deposit" %} +{% endblock %} + +{% block content %} + +
+ {% csrf_token %} + +
+
{% translate "Amount" %} {{ currency_suffix }}:
+
+
+ +
+ + + +
+ + + +{% endblock %} diff --git a/application/app/templates/footer.html b/application/app/templates/footer.html new file mode 100644 index 0000000..b490bce --- /dev/null +++ b/application/app/templates/footer.html @@ -0,0 +1,6 @@ +{% load i18n %} + + diff --git a/application/app/templates/globalMessage.html b/application/app/templates/globalMessage.html new file mode 100644 index 0000000..f6995fa --- /dev/null +++ b/application/app/templates/globalMessage.html @@ -0,0 +1,5 @@ +{% if global_message != "" %} +
+
{{ global_message }}
+
+{% endif %} \ No newline at end of file diff --git a/application/app/templates/history.html b/application/app/templates/history.html new file mode 100644 index 0000000..cdfa78f --- /dev/null +++ b/application/app/templates/history.html @@ -0,0 +1,36 @@ +{% extends "baseLayout.html" %} + +{% load i18n %} + +{% block title %} + {% translate "Drinks - History" %} +{% endblock %} + +{% block headAdditional %} + +{% endblock %} + +{% block heading %} + {% translate "History" %} +{% endblock %} + +{% block content %} + + {% if history %} + + + + + + {% for h in history %} + + + + + {% endfor %} +
{% translate "last 30 actions" %}
{{ h.0 }}{{ h.1 }}
+ {% else %} + {% translate "No history." %} + {% endif %} + +{% endblock %} diff --git a/application/app/templates/index.html b/application/app/templates/index.html new file mode 100644 index 0000000..d8d8b71 --- /dev/null +++ b/application/app/templates/index.html @@ -0,0 +1,47 @@ +{% extends "baseLayout.html" %} + +{% load i18n %} + +{% block title %} + {% translate "Drinks - Home" %} +{% endblock %} + +{% block headAdditional %} + +{% endblock %} + +{% block heading %} + {% translate "Available Drinks" %} +{% endblock %} + +{% block content %} + + {% if available_drinks %} + + + + {% else %} + + {% translate "No drinks available." %} + + {% endif %} + +{% endblock %} diff --git a/application/app/templates/order.html b/application/app/templates/order.html new file mode 100644 index 0000000..adbb8e3 --- /dev/null +++ b/application/app/templates/order.html @@ -0,0 +1,99 @@ +{% extends "baseLayout.html" %} + +{% load i18n %} + +{% block title %} +{% translate "Drinks - Order" %} +{% endblock %} + +{% block headAdditional %} + + +{% 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 %} + +
+ {% csrf_token %} + +
+
{% translate "Drink" %}:
+
{{ drink.product_name }}
+
+ +
+
{% translate "Price per Item" %} ({{ currency_suffix }}):
+
{{ drink.price }}
+
+ + {% if not drink.binary_availability %} +
+
{% translate "Available" %}:
+
{{ drink.available }}
+
+ {% endif %} + +
+
{% translate "Count" %}:
+
+ + + {% if drink.binary_availability %} + + {% else %} + + {% endif %} + + +
+
+ +
+
{% translate "Sum" %} ({{ currency_suffix }}):
+
{{ drink.price }}
+
+ +
+ + + + + +
+ + + + + + {% else %} + +
+

{% translate "You can't order this, because you have a negative balance." %}

+ {% translate "back" %} +
+ + {% endif %} + + {% else %} + +
+

{% translate "This drink is not available." %}

+ {% translate "back" %} +
+ + {% endif %} + +{% endblock %} diff --git a/application/app/templates/registration/logged_out.html b/application/app/templates/registration/logged_out.html new file mode 100644 index 0000000..4f36790 --- /dev/null +++ b/application/app/templates/registration/logged_out.html @@ -0,0 +1,24 @@ + +{% extends "baseLayout.html" %} + +{% load i18n %} + +{% block title %} + {% translate "Drinks - Logged Out" %} +{% endblock %} + +{% block headAdditional %} + +{% endblock %} + +{% block content %} + +
+ {% translate "Logged out! You will be redirected shortly." %} +

+ {% translate "Click here if automatic redirection does not work." %} +
+ + + +{% endblock %} diff --git a/application/app/templates/registration/login.html b/application/app/templates/registration/login.html new file mode 100644 index 0000000..c0179b4 --- /dev/null +++ b/application/app/templates/registration/login.html @@ -0,0 +1,91 @@ + +{% extends "baseLayout.html" %} + +{% load i18n %} + +{% block title %} + {% translate "Drinks - Login" %} +{% endblock %} + +{% block headAdditional %} + +{% endblock %} + +{% block content %} + + {% if error_message %} +

{{ error_message }}

+ {% endif %} + +
+ +
+ +
+ {% csrf_token %} +

{% translate "Log in" %}

+ + + +
+ + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + +
+ +
+ +
+
+ +

{% translate "Choose your account" %}

+ +
+ +
+ + + +{% endblock %} diff --git a/application/app/templates/statistics.html b/application/app/templates/statistics.html new file mode 100644 index 0000000..967fb1a --- /dev/null +++ b/application/app/templates/statistics.html @@ -0,0 +1,179 @@ +{% extends "baseLayout.html" %} + +{% load i18n %} + +{% block title %} + {% translate "Drinks - Statistics" %} +{% endblock %} + +{% block headAdditional %} + +{% endblock %} + + +{% block heading %} + {% translate "Statistics" %} +{% endblock %} + + +{% block content %} + +
+ + + +
+ +
+

{% translate "Your orders per drink" %}

+ {% if noyopd %} + + + + + + {% for row in noyopd %} + + + + + {% endfor %} +
{% translate "drink" %}{% translate "count" %}
{{ row.0 }}{{ row.1 }}
+ {% else %} +
{% translate "No history." %}
+ {% endif %} +
+ +
+

{% translate "All orders per drink" %}

+ {% if noaopd %} + + + + + + {% for row in noaopd %} + + + + + {% endfor %} +
{% translate "drink" %}{% translate "count" %}
{{ row.0 }}{{ row.1 }}
+ {% else %} +
{% translate "No history." %}
+ {% endif %} +
+ +
+

{% translate "Your orders per month (last 12 months)" %}

+ {% if yopml12m %} + + + + + + {% for row in yopml12m %} + + + + + {% endfor %} +
{% translate "month" %}{% translate "count" %}
{{ row.0 }}{{ row.1 }}
+ {% else %} +
{% translate "No history." %}
+ {% endif %} +
+ +
+

{% translate "All orders per month (last 12 months)" %}

+ {% if aopml12m %} + + + + + + {% for row in aopml12m %} + + + + + {% endfor %} +
{% translate "month" %}{% translate "count" %}
{{ row.0 }}{{ row.1 }}
+ {% else %} +
{% translate "No history." %}
+ {% endif %} +
+ +
+

{% translate "Your orders per weekday" %}

+ {% if yopwd %} + + + + + + {% for row in yopwd %} + + + + + {% endfor %} +
{% translate "day" %}{% translate "count" %}
{{ row.0 }}{{ row.1 }}
+ {% else %} +
{% translate "No history." %}
+ {% endif %} +
+ +
+

{% translate "All orders per weekday" %}

+ {% if aopwd %} + + + + + + {% for row in aopwd %} + + + + + {% endfor %} +
{% translate "day" %}{% translate "count" %}
{{ row.0 }}{{ row.1 }}
+ {% else %} +
{% translate "No history." %}
+ {% endif %} +
+ +
+ +
+ + + +{% endblock %} diff --git a/application/app/templates/userPanel.html b/application/app/templates/userPanel.html new file mode 100644 index 0000000..61c0d9e --- /dev/null +++ b/application/app/templates/userPanel.html @@ -0,0 +1,31 @@ +{% load i18n %} + + diff --git a/application/app/tests.py b/application/app/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/application/app/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/application/app/urls.py b/application/app/urls.py new file mode 100644 index 0000000..c7a221a --- /dev/null +++ b/application/app/urls.py @@ -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//', 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) +] \ No newline at end of file diff --git a/application/app/views.py b/application/app/views.py new file mode 100644 index 0000000..372a630 --- /dev/null +++ b/application/app/views.py @@ -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) diff --git a/application/drinks_manager/__init__.py b/application/drinks_manager/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/application/drinks_manager/asgi.py b/application/drinks_manager/asgi.py new file mode 100644 index 0000000..dcfe5ee --- /dev/null +++ b/application/drinks_manager/asgi.py @@ -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() diff --git a/application/drinks_manager/settings.py b/application/drinks_manager/settings.py new file mode 100644 index 0000000..2a6674d --- /dev/null +++ b/application/drinks_manager/settings.py @@ -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 = "$" diff --git a/application/drinks_manager/urls.py b/application/drinks_manager/urls.py new file mode 100644 index 0000000..5bf5958 --- /dev/null +++ b/application/drinks_manager/urls.py @@ -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")) +] \ No newline at end of file diff --git a/application/drinks_manager/wsgi.py b/application/drinks_manager/wsgi.py new file mode 100644 index 0000000..b42c9aa --- /dev/null +++ b/application/drinks_manager/wsgi.py @@ -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() diff --git a/application/locale/de/LC_MESSAGES/django.mo b/application/locale/de/LC_MESSAGES/django.mo new file mode 100644 index 0000000..c387ac7 Binary files /dev/null and b/application/locale/de/LC_MESSAGES/django.mo differ diff --git a/application/locale/de/LC_MESSAGES/django.po b/application/locale/de/LC_MESSAGES/django.po new file mode 100644 index 0000000..0cdfbab --- /dev/null +++ b/application/locale/de/LC_MESSAGES/django.po @@ -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 , 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 \n" +"Language-Team: LANGUAGE \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." diff --git a/application/manage.py b/application/manage.py new file mode 100755 index 0000000..dce56eb --- /dev/null +++ b/application/manage.py @@ -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() diff --git a/archive/.gitkeep b/archive/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/config/Caddyfile b/config/Caddyfile new file mode 100644 index 0000000..e5fe817 --- /dev/null +++ b/config/Caddyfile @@ -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 + } +} diff --git a/config/config.sample.sh b/config/config.sample.sh new file mode 100644 index 0000000..d82100a --- /dev/null +++ b/config/config.sample.sh @@ -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" diff --git a/config/tls/cert-config.sh b/config/tls/cert-config.sh new file mode 100644 index 0000000..a309af9 --- /dev/null +++ b/config/tls/cert-config.sh @@ -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" diff --git a/docs/Commands.md b/docs/Commands.md new file mode 100644 index 0000000..2cc39ca --- /dev/null +++ b/docs/Commands.md @@ -0,0 +1,81 @@ +# Commands + +You run a command with + +``` +./run.sh +``` + +## 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 ` - Run a python script in the context of the django project (experimental) +`` 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 +``` \ No newline at end of file diff --git a/docs/Configuration.md b/docs/Configuration.md new file mode 100644 index 0000000..1c99e3d --- /dev/null +++ b/docs/Configuration.md @@ -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. diff --git a/docs/Setup.md b/docs/Setup.md new file mode 100644 index 0000000..11f2ee4 --- /dev/null +++ b/docs/Setup.md @@ -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. + +**Warning:** + +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 +``` + + +## 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 ; +grant CREATE, CONNECT on database to ; +``` + +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/`. diff --git a/lib/archive-tables.py b/lib/archive-tables.py new file mode 100644 index 0000000..e698e73 --- /dev/null +++ b/lib/archive-tables.py @@ -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) diff --git a/lib/auto-upgrade-db.sh b/lib/auto-upgrade-db.sh new file mode 100644 index 0000000..beadb59 --- /dev/null +++ b/lib/auto-upgrade-db.sh @@ -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 diff --git a/lib/bootstrap.py b/lib/bootstrap.py new file mode 100644 index 0000000..2ce6bce --- /dev/null +++ b/lib/bootstrap.py @@ -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) diff --git a/lib/clear-expired-sessions.sh b/lib/clear-expired-sessions.sh new file mode 100644 index 0000000..c10b5ce --- /dev/null +++ b/lib/clear-expired-sessions.sh @@ -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 \ No newline at end of file diff --git a/lib/create-admin.sh b/lib/create-admin.sh new file mode 100644 index 0000000..ee46fd4 --- /dev/null +++ b/lib/create-admin.sh @@ -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." \ No newline at end of file diff --git a/lib/db-migrations.sh b/lib/db-migrations.sh new file mode 100644 index 0000000..7c17843 --- /dev/null +++ b/lib/db-migrations.sh @@ -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." \ No newline at end of file diff --git a/lib/env.sh b/lib/env.sh new file mode 100644 index 0000000..07a2b66 --- /dev/null +++ b/lib/env.sh @@ -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" diff --git a/lib/generate-secret-key.py b/lib/generate-secret-key.py new file mode 100644 index 0000000..7f98866 --- /dev/null +++ b/lib/generate-secret-key.py @@ -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.") \ No newline at end of file diff --git a/lib/generate-tls-cert.py b/lib/generate-tls-cert.py new file mode 100644 index 0000000..6867c9e --- /dev/null +++ b/lib/generate-tls-cert.py @@ -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.") diff --git a/lib/run-script.sh b/lib/run-script.sh new file mode 100644 index 0000000..ffc4ece --- /dev/null +++ b/lib/run-script.sh @@ -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 : 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 diff --git a/lib/session-clear-scheduler.py b/lib/session-clear-scheduler.py new file mode 100644 index 0000000..6096d72 --- /dev/null +++ b/lib/session-clear-scheduler.py @@ -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) diff --git a/lib/setup-application.sh b/lib/setup-application.sh new file mode 100644 index 0000000..56ae66d --- /dev/null +++ b/lib/setup-application.sh @@ -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 diff --git a/lib/upgrade-db.py b/lib/upgrade-db.py new file mode 100644 index 0000000..b773ac2 --- /dev/null +++ b/lib/upgrade-db.py @@ -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) diff --git a/lib/verify-db-app-version.py b/lib/verify-db-app-version.py new file mode 100644 index 0000000..4367f32 --- /dev/null +++ b/lib/verify-db-app-version.py @@ -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) diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/misc/drinks-manager.service.sample b/misc/drinks-manager.service.sample new file mode 100644 index 0000000..9dbc463 --- /dev/null +++ b/misc/drinks-manager.service.sample @@ -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 \ No newline at end of file diff --git a/misc/icons/drinksmanager-icon-1024.png b/misc/icons/drinksmanager-icon-1024.png new file mode 100644 index 0000000..81233aa Binary files /dev/null and b/misc/icons/drinksmanager-icon-1024.png differ diff --git a/misc/icons/drinksmanager-icon.src.svg b/misc/icons/drinksmanager-icon.src.svg new file mode 100644 index 0000000..f231677 --- /dev/null +++ b/misc/icons/drinksmanager-icon.src.svg @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + Julian Müller (W13R) + + + + + + diff --git a/misc/icons/favicon.ico b/misc/icons/favicon.ico new file mode 100644 index 0000000..aeae09f Binary files /dev/null and b/misc/icons/favicon.ico differ diff --git a/misc/icons/favicon.png b/misc/icons/favicon.png new file mode 100644 index 0000000..835e2d1 Binary files /dev/null and b/misc/icons/favicon.png differ diff --git a/pip-dependencies.txt b/pip-dependencies.txt new file mode 100644 index 0000000..b75bd9a --- /dev/null +++ b/pip-dependencies.txt @@ -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 \ No newline at end of file diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..48e4f16 --- /dev/null +++ b/run.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + + +function show_dm_help { # $1 = exit code + + echo -e "Usage:\t./run.sh \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 \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 \ No newline at end of file diff --git a/static/css/customNumberInput.css b/static/css/customNumberInput.css new file mode 100644 index 0000000..4df663f --- /dev/null +++ b/static/css/customNumberInput.css @@ -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; +} \ No newline at end of file diff --git a/static/css/deposit.css b/static/css/deposit.css new file mode 100644 index 0000000..8cb4e1c --- /dev/null +++ b/static/css/deposit.css @@ -0,0 +1,11 @@ +#depositAmount { + width: 10rem; +} +main { + margin-top: 0; +} +@media only screen and (max-width: 700px) { + main { + margin-top: -15vh; + } +} \ No newline at end of file diff --git a/static/css/history.css b/static/css/history.css new file mode 100644 index 0000000..cb0ce7a --- /dev/null +++ b/static/css/history.css @@ -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%; + } +} \ No newline at end of file diff --git a/static/css/index.css b/static/css/index.css new file mode 100644 index 0000000..b5caa67 --- /dev/null +++ b/static/css/index.css @@ -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; + } +} \ No newline at end of file diff --git a/static/css/login.css b/static/css/login.css new file mode 100644 index 0000000..22f8b43 --- /dev/null +++ b/static/css/login.css @@ -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; + } +} \ No newline at end of file diff --git a/static/css/main.css b/static/css/main.css new file mode 100644 index 0000000..5c5300c --- /dev/null +++ b/static/css/main.css @@ -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; + } +} \ No newline at end of file diff --git a/static/css/order.css b/static/css/order.css new file mode 100644 index 0000000..9c15743 --- /dev/null +++ b/static/css/order.css @@ -0,0 +1,11 @@ +main { + margin-top: 0; +} +form { + width: 22rem; +} +@media only screen and (max-width: 700px) { + main { + margin-top: -15vh; + } +} \ No newline at end of file diff --git a/static/css/statistics.css b/static/css/statistics.css new file mode 100644 index 0000000..2c97e82 --- /dev/null +++ b/static/css/statistics.css @@ -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; + } +} \ No newline at end of file diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..aeae09f Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/favicon.png b/static/favicon.png new file mode 100644 index 0000000..835e2d1 Binary files /dev/null and b/static/favicon.png differ diff --git a/static/js/customNumberInput.js b/static/js/customNumberInput.js new file mode 100644 index 0000000..fd50680 --- /dev/null +++ b/static/js/customNumberInput.js @@ -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 + ); + } + +} \ No newline at end of file diff --git a/static/js/deposit.js b/static/js/deposit.js new file mode 100644 index 0000000..fb3242c --- /dev/null +++ b/static/js/deposit.js @@ -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); + + }); + +}) \ No newline at end of file diff --git a/static/js/logged_out.js b/static/js/logged_out.js new file mode 100644 index 0000000..da9e66b --- /dev/null +++ b/static/js/logged_out.js @@ -0,0 +1 @@ +window.location.replace("/"); \ No newline at end of file diff --git a/static/js/login.js b/static/js/login.js new file mode 100644 index 0000000..0f11ba0 --- /dev/null +++ b/static/js/login.js @@ -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 + + // [...] 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; + } + } + +} \ No newline at end of file diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..8fdc085 --- /dev/null +++ b/static/js/main.js @@ -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"); + } + + }) + + } + +}) \ No newline at end of file diff --git a/static/js/order.js b/static/js/order.js new file mode 100644 index 0000000..d7f0f86 --- /dev/null +++ b/static/js/order.js @@ -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); + + }); + +}) \ No newline at end of file diff --git a/static/js/statistics.js b/static/js/statistics.js new file mode 100644 index 0000000..0cfdb26 --- /dev/null +++ b/static/js/statistics.js @@ -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"); + } + +} \ No newline at end of file diff --git a/tests/lib/__init__.py b/tests/lib/__init__.py new file mode 100644 index 0000000..3da5d92 --- /dev/null +++ b/tests/lib/__init__.py @@ -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 diff --git a/tests/test-database-stability.py b/tests/test-database-stability.py new file mode 100644 index 0000000..228bb97 --- /dev/null +++ b/tests/test-database-stability.py @@ -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()