Completely re-structured the project from scratch, wrote a better bootstrap script, changed configuration format to yaml, improved Caddyfile, and more. #15 #16 #20

This commit is contained in:
ChaoticByte 2023-02-11 17:23:57 +01:00
parent 0012214f9b
commit 5572fec9c1
91 changed files with 739 additions and 1345 deletions

0
app/__init__.py Normal file
View file

142
app/admin.py Normal file
View file

@ -0,0 +1,142 @@
#
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={
"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_.insert(2, (
"Supply",
{"fields": ("allowed_to_supply",)},
))
fieldsets_.insert(3, (
"Profile Picture",
{"fields": ("profile_picture_filename",)},
))
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", "do_not_count", "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)

6
app/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class AppConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "app"

16
app/context_processors.py Normal file
View file

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

47
app/forms.py Normal file
View file

@ -0,0 +1,47 @@
from django import forms
from django.conf import settings
from django.contrib.auth.forms import UserChangeForm
from .models import User
from .models import Drink
from .models import RegisterTransaction
from .models import Global
class CustomUserChangeForm(UserChangeForm):
balance = forms.DecimalField(max_digits=8, decimal_places=2, initial=0.00, label=f"Balance {settings.CURRENCY_SUFFIX}")
class Meta:
model = User
fields = ("username", "balance")
class CustomDrinkForm(forms.ModelForm):
product_name = forms.CharField(max_length=64, label="Product Name")
content_litres = forms.DecimalField(max_digits=6, decimal_places=3, initial=0.5, label="Content (l)")
price = forms.DecimalField(max_digits=6, decimal_places=2, label=f"Price {settings.CURRENCY_SUFFIX}")
class Meta:
model = Drink
fields = ("product_name", "content_litres", "price", "do_not_count", "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")

View file

@ -0,0 +1,267 @@
# Generated by Django 4.1.6 on 2023-02-11 15:24
from django.conf import settings
import django.contrib.auth.models
import django.contrib.auth.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
name="User",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"balance",
models.DecimalField(decimal_places=2, default=0.0, max_digits=8),
),
(
"allow_order_with_negative_balance",
models.BooleanField(default=False),
),
(
"profile_picture_filename",
models.CharField(default="default.svg", max_length=25),
),
("allowed_to_supply", models.BooleanField(default=False)),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"abstract": False,
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name="Drink",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("product_name", models.CharField(max_length=64)),
(
"content_litres",
models.DecimalField(decimal_places=3, default=0.5, max_digits=6),
),
(
"price",
models.DecimalField(decimal_places=2, default=0.0, max_digits=6),
),
("available", models.PositiveIntegerField(default=0)),
("deleted", models.BooleanField(default=False)),
("do_not_count", models.BooleanField(default=False)),
],
),
migrations.CreateModel(
name="Global",
fields=[
(
"name",
models.CharField(
max_length=42, primary_key=True, serialize=False, unique=True
),
),
("comment", models.TextField()),
("value_float", models.FloatField(default=0.0)),
("value_string", models.TextField()),
],
),
migrations.CreateModel(
name="RegisterTransaction",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"transaction_sum",
models.DecimalField(decimal_places=2, default=0.0, max_digits=6),
),
(
"old_transaction_sum",
models.DecimalField(decimal_places=2, default=0.0, max_digits=6),
),
("datetime", models.DateTimeField(default=django.utils.timezone.now)),
("is_user_deposit", models.BooleanField(default=False)),
("comment", models.TextField(default=" ")),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "transaction",
"verbose_name_plural": "transactions",
},
),
migrations.CreateModel(
name="Order",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("datetime", models.DateTimeField(default=django.utils.timezone.now)),
("amount", models.PositiveIntegerField(default=1, editable=False)),
("product_name", models.CharField(editable=False, max_length=64)),
(
"price_sum",
models.DecimalField(
decimal_places=2, default=0, editable=False, max_digits=6
),
),
(
"content_litres",
models.DecimalField(
decimal_places=3, default=0, editable=False, max_digits=6
),
),
(
"drink",
models.ForeignKey(
limit_choices_to=models.Q(("available__gt", 0)),
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="app.drink",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View file

152
app/models.py Normal file
View file

@ -0,0 +1,152 @@
from django.db import models
from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.forms import ValidationError
from django.utils import timezone
# 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)
profile_picture_filename = models.CharField(default="default.svg", max_length=25)
allowed_to_supply = models.BooleanField(default=False)
def delete(self, *args, **kwargs):
self.balance = 0
self.is_active = False
self.username = f"<deleted user #{self.pk}>"
self.last_name = ""
self.first_name = ""
self.email = ""
super().save()
#
class Drink(models.Model):
product_name = models.CharField(max_length=64)
content_litres = models.DecimalField(max_digits=6, decimal_places=3, default=0.5)
price = models.DecimalField(max_digits=6, decimal_places=2, default=0.00)
available = models.PositiveIntegerField(default=0)
deleted = models.BooleanField(default=False)
# when the following field is true, the amount of drinks will
# not change and will not be displayed.
# available > 0 -> there is a indefinetly amount of drinks left
# available < 1 -> there are no drinks left
do_not_count = models.BooleanField(default=False)
def delete(self, *args, **kwargs):
self.deleted = True
super().save()
def __str__(self): return f"{self.product_name} ({float(self.content_litres):.2f}l) - {self.price}{settings.CURRENCY_SUFFIX}"
class RegisterTransaction(models.Model):
class Meta:
verbose_name = "transaction"
verbose_name_plural = "transactions"
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 = models.ForeignKey(User, on_delete=models.CASCADE)
def save(self, *args, **kwargs):
if self._state.adding:
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
# 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):
# 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 = models.ForeignKey(User, on_delete=models.CASCADE)
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.do_not_count:
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.do_not_count:
drink.available += self.amount
drink.save()
super().delete(*args, **kwargs)
def __str__(self): return f"{self.drink.product_name} ({float(self.drink.content_litres):.2f}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

137
app/sql_queries.py Normal file
View file

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

View file

@ -0,0 +1,61 @@
.appform {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: max-content;
font-size: 1.1rem;
}
.appform > .forminfo {
width: 100%;
text-align: left;
margin: .4rem 0;
}
.forminfo > span:first-child {
margin-right: 1rem;
}
.forminfo > span:last-child {
float: right;
}
.appform > .forminput {
width: 100%;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin: .8rem 0;
gap: 1rem;
}
.appform > .statusinfo {
margin-top: .5rem;
}
.appform > .formbuttons {
border-top: 1px solid var(--glass-border-color);
padding-top: 1rem;
margin-top: 1rem;
width: 100%;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 1rem;
}
.formbuttons button, .formbuttons .button {
box-sizing: content-box;
font-size: 1rem;
width: fit-content;
}
.formheading {
text-align: left;
width: 100%;
margin-top: 0;
}
@media only screen and (max-width: 700px) {
.appform > .forminput {
flex-direction: column;
gap: .5rem;
}
.formheading {
text-align: center;
}
}

View file

@ -0,0 +1,39 @@
/* custom number input */
.customnumberinput {
display: flex;
flex-direction: row;
height: 2.2rem;
}
.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: 4rem;
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;
}

View file

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

46
app/static/css/index.css Normal file
View file

@ -0,0 +1,46 @@
.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;
text-align: left;
}
.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;
}
}

131
app/static/css/login.css Normal file
View file

@ -0,0 +1,131 @@
/* login page */
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;
margin-bottom: .5rem;
padding: 0 .5rem;
}
.userlist > li > img {
margin-right: auto;
margin-left: 0;
height: 2rem;
width: 2rem;
}
.userlist > li > div {
display: flex;
flex-grow: 1;
align-items: center;
justify-content: center;
text-align: center;
padding: .8rem 1.1rem;
}
.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;
}
.passwordoverlay > form {
min-width: unset;
width: fit-content;
}
.passwordoverlay > form > h1 {
margin-top: 2rem;
margin-bottom: 2rem;
}
/* loginform */
.loginform {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.loginform input[type="password"], form input[type="text"] {
width: 94%;
padding-top: .5rem;
padding-bottom: .5rem;
font-size: 1rem;
margin: .1rem 0;
}
.loginform .horizontalbuttonlist {
margin-top: 1.5rem;
}
.horizontalbuttonlist .button, .horizontalbuttonlist button {
font-size: 1rem;
}
/***/
.pinpad {
margin-top: 1.5rem;
margin-bottom: 0;
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%;
}
.pinpad table tr td button {
height: 4.2rem;
width: 4.2rem;
font-size: 1.16rem;
margin: .2rem;
}
}

341
app/static/css/main.css Normal file
View file

@ -0,0 +1,341 @@
/* 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: .5rem;
/** 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: 5vh;
}
.userpanel {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
margin-top: 1rem;
font-size: 1rem;
width: 94%;
}
.userinfo > span {
font-size: 1.1rem;
vertical-align: middle;
}
.userinfo > img {
vertical-align: middle;
width: 1.8rem;
height: 1.8rem;
margin: .5rem;
}
.userpanel > .horizontalbuttonlist {
margin-left: auto;
margin-right: 0;
}
.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;
}
.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;
}
.dropdownbutton, .dropdownchoice {
font-size: 1rem;
}
.dropdownlist {
position: absolute;
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: 3.2rem;
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;
}
/* FOOTER */
.footer {
z-index: 900;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
flex-wrap: wrap;
margin-top: auto;
padding-top: 3rem;
padding-bottom: .3rem;
text-align: center;
}
.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, so Firefox users won't have rounded corners
on tables. Can'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);
}
/* BUTTONS & OTHER INPUT ELEMENTS */
.button, button {
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-family);
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); disabled for performance reasons*/
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);
}
/**** OTHER 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;
}
.horizontalbuttonlist > .button, .horizontalbuttonlist > button, .horizontalbuttonlist > div {
margin: 0 .5rem;
}
.errortext {
margin-top: 1rem;
color: var(--color-error);
}
.nodisplay {
display: none !important;
}
.heading {
margin-top: 0;
}
/* MISC / GENERAL */
h1 {
text-align: center;
font-size: 1.8rem;
}
/* MOBILE OPTIMIZATIONS */
@media only screen and (max-width: 700px) {
main {
margin-top: 2rem;
}
.globalmessage span {
width: 90%;
}
.userpanel {
flex-direction: column;
justify-content: start;
align-items: center;
}
.userpanel > .horizontalbuttonlist {
margin-right: 0;
margin-left: 0;
margin-top: .5rem;
justify-content: center;
flex-wrap: wrap;
}
.userpanel > .horizontalbuttonlist > .button,
.userpanel > .horizontalbuttonlist > .dropdownmenu {
margin: 0.25rem;
}
}

View file

@ -0,0 +1,53 @@
.maincontainer {
min-width: 70vw;
display: flex;
flex-direction: column;
align-items: center;
justify-content: start;
}
.tablescontainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: start;
width: 95%;
margin-top: 2rem;
}
.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;
}
@media only screen and (max-width: 700px) {
.statisticstable h1 {
min-width: 90vw;
}
.statisticstable table {
min-width: 80vw;
}
.statisticstable {
margin-bottom: 2rem;
padding-bottom: 1rem;
}
}

BIN
app/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
app/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View file

@ -0,0 +1,3 @@
setInterval(() => {
location.reload();
}, 1000*60*2); // reload after 2 minutes

View file

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

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

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

View file

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

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

@ -0,0 +1,87 @@
(() => {
// Define variables
let usernameInputElement;
let passwordInputElement;
let submitButton;
let passwordOverlayElement;
let pwOverlayCancelButton;
let userlistButtons;
let pinpadButtons;
let userlistContainerElement;
// Add event listeners after DOM Content loaded
document.addEventListener("DOMContentLoaded", () => {
// elements
usernameInputElement = document.getElementById("id_username");
passwordInputElement = document.getElementById("id_password");
submitButton = document.getElementById("submit_login");
passwordOverlayElement = document.getElementById("passwordoverlaycontainer");
pwOverlayCancelButton = document.getElementById("pwocancel");
userlistContainerElement = document.getElementById("userlistcontainer");
userlistButtons = document.getElementsByClassName("userlistbutton");
pinpadButtons = document.getElementsByClassName("pinpadbtn");
// event listeners
// [...<html-collection>] converts an html collection to an array
[...userlistButtons].forEach(element => {
element.addEventListener("click", () => {
set_username(element.dataset.username);
show_password_overlay();
})
});
[...pinpadButtons].forEach(element => {
element.addEventListener("click", () => {
pinpad_press(element.dataset.btn);
})
})
pwOverlayCancelButton.addEventListener("click", () => {
hide_password_overlay();
});
})
function set_username(username) {
usernameInputElement.value = username;
}
function show_password_overlay() {
window.scrollTo(0, 0);
passwordOverlayElement.classList.remove("nodisplay");
userlistContainerElement.classList.add("nodisplay");
}
function hide_password_overlay() {
passwordOverlayElement.classList.add("nodisplay");
userlistContainerElement.classList.remove("nodisplay");
passwordInputElement.value = "";
}
function pinpad_press(key) {
if (key == "enter") {
submitButton.click();
}
else if (key == "x") {
passwordInputElement.value = "";
}
else {
passwordInputElement.value += key;
}
}
})()

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

@ -0,0 +1,21 @@
document.addEventListener("DOMContentLoaded", () => {
let dropdownmenuElement = document.getElementById("dropdownmenu");
let dropdownmenuButtonElement = document.getElementById("dropdownmenu-button");
if (dropdownmenuButtonElement != null) {
dropdownmenuButtonElement.addEventListener("click", () => {
if (dropdownmenuElement.classList.contains("dropdownvisible")) {
dropdownmenuElement.classList.remove("dropdownvisible");
}
else {
dropdownmenuElement.classList.add("dropdownvisible");
}
})
}
})

82
app/static/js/order.js Normal file
View file

@ -0,0 +1,82 @@
document.addEventListener("DOMContentLoaded", () => {
// elements
let orderNumberofdrinksInput = document.getElementById("numberofdrinks");
let orderNumberofdrinksBtnA = document.getElementById("numberofdrinks-btn-minus");
let orderNumberofdrinksBtnB = document.getElementById("numberofdrinks-btn-plus");
let orderSumElement = document.getElementById("ordercalculatedsum");
let orderFormElement = document.getElementById("orderform");
let statusInfoElement = document.getElementById("statusinfo");
let orderSubmitButton = document.getElementById("ordersubmitbtn");
// calculate & display sum
let orderPricePerDrink = parseFloat(document.getElementById("priceperdrink").dataset.drinkPrice);
function calculateAndDisplaySum() {
setTimeout(() => {
let numberOfDrinks = parseFloat(orderNumberofdrinksInput.value);
if (isNaN(numberOfDrinks)) {
numberOfDrinks = 1;
}
let calculated_sum = orderPricePerDrink * numberOfDrinks;
orderSumElement.innerText = new Intl.NumberFormat(undefined, {minimumFractionDigits: 2}).format(calculated_sum);
}, 25);
}
orderNumberofdrinksInput.addEventListener("input", calculateAndDisplaySum);
orderNumberofdrinksBtnA.addEventListener("click", calculateAndDisplaySum);
orderNumberofdrinksBtnB.addEventListener("click", calculateAndDisplaySum);
// custom submit method
orderFormElement.addEventListener("submit", (event) => {
orderSubmitButton.disabled = true;
event.preventDefault(); // Don't do the default submit action!
if (isNaN(parseFloat(orderNumberofdrinksInput.value))) {
orderNumberofdrinksInput.value = 1;
}
let xhr = new XMLHttpRequest();
let formData = new FormData(orderFormElement);
xhr.addEventListener("load", (event) => {
status_ = event.target.status;
response_ = event.target.responseText;
if (status_ == 200 && response_ == "success") {
statusInfoElement.innerText = "Success.";
window.location.replace("/");
}
else {
statusInfoElement.classList.add("errortext");
statusInfoElement.innerText = "An error occured.";
window.setTimeout(() => { window.location.reload() }, 5000);
}
})
xhr.addEventListener("error", (event) => {
statusInfoElement.classList.add("errortext");
statusInfoElement.innerText = "An error occured.";
window.setTimeout(() => { window.location.reload() }, 5000);
})
xhr.open("POST", "/api/order-drink");
xhr.send(formData);
});
})

56
app/static/js/supply.js Normal file
View file

@ -0,0 +1,56 @@
document.addEventListener("DOMContentLoaded", () => {
// elements
let supplyDescriptionElement = document.getElementById("supplydescription");
let supplyPriceElement = document.getElementById("supplyprice");
let supplyFormElement = document.getElementById("supplyform");
let statusInfoElement = document.getElementById("statusinfo");
let supplySubmitButton = document.getElementById("supplysubmitbtn");
// custom submit method
supplyFormElement.addEventListener("submit", (event) => {
supplySubmitButton.disabled = true;
event.preventDefault(); // Don't do the default submit action!
if (isNaN(parseFloat(supplyPriceElement.value)) || supplyDescriptionElement.value == "") {
statusInfoElement.innerText = "Please enter a description and price."
supplySubmitButton.disabled = false;
}
let xhr = new XMLHttpRequest();
let formData = new FormData(supplyFormElement);
xhr.addEventListener("load", (event) => {
status_ = event.target.status;
response_ = event.target.responseText;
if (status_ == 200 && response_ == "success") {
statusInfoElement.innerText = "Success.";
window.location.replace("/");
}
else {
statusInfoElement.classList.add("errortext");
statusInfoElement.innerText = "An error occured.";
window.setTimeout(() => { window.location.reload() }, 5000);
}
})
xhr.addEventListener("error", (event) => {
statusInfoElement.classList.add("errortext");
statusInfoElement.innerText = "An error occured.";
window.setTimeout(() => { window.location.reload() }, 5000);
})
xhr.open("POST", "/api/supply");
xhr.send(formData);
});
})

View file

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

View file

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

View file

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

View file

@ -0,0 +1,40 @@
{% extends "baselayout.html" %}
{% load i18n %}
{% block title %}
{% translate "Drinks - Deposit" %}
{% endblock %}
{% block headAdditional %}
<link rel="stylesheet" href="/static/css/appform.css">
{% endblock %}
{% block content %}
<form id="depositform" class="appform">
{% csrf_token %}
<h1 class="formheading">{% translate "Deposit" %}</h1>
<div class="forminput">
<span>{% translate "Amount" %} {{ currency_suffix }}:</span>
<span>
<input type="number" name="depositamount" id="depositamount" max="9999.99" min="1.00" step="0.01" autofocus>
</span>
</div>
<div id="statusinfo"></div>
<div class="formbuttons">
<a href="/" class="button">{% translate "cancel" %}</a>
<input type="submit" id="depositsubmitbtn" class="button" value='{% translate "confirm" %}'>
</div>
</form>
<script src="/static/js/deposit.js"></script>
<script src="/static/js/autoreload.js"></script>
{% endblock %}

View file

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

View file

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

View file

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

47
app/templates/index.html Normal file
View file

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

100
app/templates/order.html Normal file
View file

@ -0,0 +1,100 @@
{% extends "baselayout.html" %}
{% load i18n %}
{% load l10n %}
{% block title %}
{% translate "Drinks - Order" %}
{% endblock %}
{% block headAdditional %}
<link rel="stylesheet" href="/static/css/appform.css">
<link rel="stylesheet" href="/static/css/custom_number_input.css">
{% endblock %}
{% block content %}
{% if drink and drink.available > 0 and not drink.deleted %}
{% if user.balance > 0 or user.allow_order_with_negative_balance %}
<form id="orderform" class="appform">
{% csrf_token %}
<h1 class="formheading">{% translate "Order" %}</h1>
<div class="forminfo">
<span>{% translate "Drink" %}:</span>
<span>{{ drink.product_name }}</span>
</div>
<div class="forminfo">
<span>{% translate "Price per Item" %} ({{ currency_suffix }}):</span>
<span id="priceperdrink" data-drink-price="{% localize off %}{{ drink.price }}{% endlocalize %}">
{{ drink.price }}
</span>
</div>
{% if not drink.do_not_count %}
<div class="forminfo">
<span>{% translate "Available" %}:</span>
<span>{{ drink.available }}</span>
</div>
{% endif %}
<div class="forminfo">
<span>{% translate "Sum" %} ({{ currency_suffix }}):</span>
<span id="ordercalculatedsum">{{ drink.price }}</span>
</div>
<div class="forminput">
<span>{% translate "Count" %}:</span>
<span class="customnumberinput">
<button type="button" class="customnumberinput-minus" id="numberofdrinks-btn-minus">-</button>
{% if drink.do_not_count %}
<input type="number" class="customnumberinput-field" name="numberofdrinks" id="numberofdrinks"
min="1" max="100" value="1">
{% else %}
<input type="number" class="customnumberinput-field" name="numberofdrinks" id="numberofdrinks"
max="{{ drink.available }}" min="1" max="100" value="1">
{% endif %}
<button type="button" class="customnumberinput-plus" id="numberofdrinks-btn-plus">+</button>
</span>
</div>
<div id="statusinfo"></div>
<input type="hidden" name="drinkid" id="drinkid" value="{{ drink.id }}">
<div class="formbuttons">
<a href="/" class="button">{% translate "cancel" %}</a>
<input type="submit" id="ordersubmitbtn" class="button" value='{% translate "order" %}'>
</div>
</form>
<script src="/static/js/order.js"></script>
<script src="/static/js/custom_number_input.js"></script>
{% else %}
<div class="centeringflex">
<p>{% translate "Your balance is too low to order a drink." %}</p>
<a href="/">{% translate "back" %}</a>
</div>
{% endif %}
{% else %}
<div class="centeringflex">
<p>{% translate "This drink is not available." %}</p>
<a href="/">{% translate "back" %}</a>
</div>
{% endif %}
<script src="/static/js/autoreload.js"></script>
{% endblock %}

View file

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

View file

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

View file

@ -0,0 +1,148 @@
{% extends "baselayout.html" %}
{% load i18n %}
{% block title %}
{% translate "Drinks - Statistics" %}
{% endblock %}
{% block headAdditional %}
<link rel="stylesheet" href="/static/css/statistics.css">
{% endblock %}
{% block content %}
<h1 class="heading">{% translate "Statistics" %}</h1>
<div class="maincontainer">
<div class="tablescontainer">
<div id="noyopd" class="statisticstable">
<h1>{% translate "Your orders per drink" %}</h1>
{% if noyopd %}
<table>
<tr>
<th>{% translate "drink" %}</th>
<th>{% translate "count" %}</th>
</tr>
{% for row in noyopd %}
<tr>
<td>{{ row.0 }}</td>
<td>{{ row.1 }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div>{% translate "No history." %}</div>
{% endif %}
</div>
<div id="noaopd" class="statisticstable">
<h1>{% translate "All orders per drink" %}</h1>
{% if noaopd %}
<table>
<tr>
<th>{% translate "drink" %}</th>
<th>{% translate "count" %}</th>
</tr>
{% for row in noaopd %}
<tr>
<td>{{ row.0 }}</td>
<td>{{ row.1 }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div>{% translate "No history." %}</div>
{% endif %}
</div>
<div id="yopml12m" class="statisticstable">
<h1>{% translate "Your orders per month (last 12 months)" %}</h1>
{% if yopml12m %}
<table>
<tr>
<th>{% translate "month" %}</th>
<th>{% translate "count" %}</th>
</tr>
{% for row in yopml12m %}
<tr>
<td>{{ row.0 }}</td>
<td>{{ row.1 }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div>{% translate "No history." %}</div>
{% endif %}
</div>
<div id="aopml12m" class="statisticstable">
<h1>{% translate "All orders per month (last 12 months)" %}</h1>
{% if aopml12m %}
<table>
<tr>
<th>{% translate "month" %}</th>
<th>{% translate "count" %}</th>
</tr>
{% for row in aopml12m %}
<tr>
<td>{{ row.0 }}</td>
<td>{{ row.1 }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div>{% translate "No history." %}</div>
{% endif %}
</div>
<div id="yopwd" class="statisticstable">
<h1>{% translate "Your orders per weekday" %}</h1>
{% if yopwd %}
<table>
<tr>
<th>{% translate "day" %}</th>
<th>{% translate "count" %}</th>
</tr>
{% for row in yopwd %}
<tr>
<td>{{ row.0 }}</td>
<td>{{ row.1 }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div>{% translate "No history." %}</div>
{% endif %}
</div>
<div id="aopwd" class="statisticstable">
<h1>{% translate "All orders per weekday" %}</h1>
{% if aopwd %}
<table>
<tr>
<th>{% translate "day" %}</th>
<th>{% translate "count" %}</th>
</tr>
{% for row in aopwd %}
<tr>
<td>{{ row.0 }}</td>
<td>{{ row.1 }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div>{% translate "No history." %}</div>
{% endif %}
</div>
</div>
</div>
<script src="/static/js/autoreload.js"></script>
{% endblock %}

62
app/templates/supply.html Normal file
View file

@ -0,0 +1,62 @@
{% extends "baselayout.html" %}
{% load i18n %}
{% load l10n %}
{% block title %}
{% translate "Drinks - Supply" %}
{% endblock %}
{% block headAdditional %}
<link rel="stylesheet" href="/static/css/appform.css">
<link rel="stylesheet" href="/static/css/custom_number_input.css">
{% endblock %}
{% block content %}
{% if user.is_superuser or user.allowed_to_supply %}
<form id="supplyform" class="appform">
{% csrf_token %}
<h1 class="formheading">{% translate "Supply" %}</h1>
<div class="forminput">
<span>{% translate "Description" %}:</span>
<span>
<input type="text" name="supplydescription" id="supplydescription" autofocus>
</span>
</div>
<div class="forminput">
<span>{% translate "Price" %} ({{ currency_suffix }}):</span>
<span>
<input type="number" name="supplyprice" id="supplyprice" max="9999.99" min="1.00" step="0.01">
</span>
</div>
<div id="statusinfo"></div>
<div class="formbuttons">
<a href="/" class="button">{% translate "cancel" %}</a>
<input type="submit" id="supplysubmitbtn" class="button" value='{% translate "submit" %}'>
</div>
</form>
<script src="/static/js/supply.js"></script>
<script src="/static/js/custom_number_input.js"></script>
{% else %}
<div class="centeringflex">
<p>{% translate "You are not allowed to view this site." %}</p>
<a href="/">{% translate "back" %}</a>
</div>
{% endif %}
<script src="/static/js/autoreload.js"></script>
{% endblock %}

View file

@ -0,0 +1,42 @@
{% load i18n %}
{% load static %}
<div class="userpanel">
<div class="userinfo">
<img src="/profilepictures?name={{ user.profile_picture_filename|urlencode }}">
<span>
{% if user.first_name != "" %}
{% translate "User" %}: {{ user.first_name }} {{ user.last_name }} ({{ user.username }})
{% else %}
{% translate "User" %}: {{ user.username }}
{% endif %}
&nbsp;-&nbsp;
{% if user.balance < 0.01 %}
<span class="userbalancewarn">{% translate "Balance" %}: {{ user.balance }}{{ currency_suffix }}</span>
{% else %}
<span>{% translate "Balance" %}: {{ user.balance }}{{ currency_suffix }}</span>
{% endif %}
</span>
</div>
<div class="horizontalbuttonlist">
<a class="button" href="/">Home</a>
<a class="button" href="/deposit">{% translate "Deposit" %}</a>
<a class="button" href="/accounts/logout">{% translate "Logout" %}</a>
<div class="dropdownmenu" id="dropdownmenu">
<button class="dropdownbutton" id="dropdownmenu-button">
<div>{% translate "Account" %}</div>
</button>
<div class="dropdownlist">
<a class="button dropdownchoice" href="/history">{% translate "History" %}</a>
<a class="button dropdownchoice" href="/statistics">{% translate "Statistics" %}</a>
{% if user.is_superuser or user.is_staff %}
<a class="button dropdownchoice" href="/admin/">Admin Panel</a>
{% endif %}
{% if user.is_superuser or user.allowed_to_supply %}
<a class="button dropdownchoice" href="/supply/">{% translate "Supply" %}</a>
{% endif %}
<a class="button dropdownchoice" href="/accounts/password_change/">{% translate "Change Password" %}</a>
</div>
</div>
</div>
</div>

23
app/urls.py Normal file
View file

@ -0,0 +1,23 @@
from django.urls import path, include
from django.contrib.auth import views as auth_views
from . import views
from .admin import adminSite
urlpatterns = [
path('', views.index),
path('order/<drinkid>/', views.order),
path('history/', views.history),
path('deposit/', views.deposit),
path('statistics/', views.statistics),
path('supply/', views.supply),
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/supply', views.api_supply)
]

180
app/views.py Normal file
View file

@ -0,0 +1,180 @@
import json
import sys
from pathlib import Path
from django.conf import settings
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 FileResponse
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
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
})
@login_required
def index(request):
context = {
"available_drinks": Drink.objects.filter(available__gt=0).filter(deleted=False).order_by('product_name'),
}
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 supply(request):
return render(request, "supply.html")
@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.do_not_count 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("Unexpected input or missing privileges.")
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 a transaction: User: {user.username} - Exception: {e}", file=sys.stderr)
return HttpResponse(b"", status=500)
@login_required
def api_supply(request):
# check request -> supply
user = request.user
try:
price = decimal.Decimal(request.POST["supplyprice"])
description = str(request.POST["supplydescription"])
if 0.00 < price < 9999.99 and (user.allowed_to_supply or user.is_superuser):
# create transaction
RegisterTransaction.objects.create(
transaction_sum=-price,
comment=f"Supply: {description}",
is_user_deposit=False,
user=user
)
return HttpResponse("success", status=200)
else: raise Exception("Unexpected input or missing privileges.")
except Exception as e:
print(f"An exception occured while processing a supply transaction: User: {user.username} - Exception: {e}", file=sys.stderr)
return HttpResponse(b"", status=500)