Compare commits
21 Commits
97085b699b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b5142ce3c7 | |||
| ae9d749356 | |||
| 89ea65a0c8 | |||
| 2d611dcac5 | |||
| 752f129df9 | |||
| 5b7fa07b44 | |||
| 01b37fda79 | |||
| 29e4b231da | |||
| 9fb5373f98 | |||
| de140c3feb | |||
| a0ed28c587 | |||
| 3ef2037917 | |||
| 23b24ed0b9 | |||
| e0e2b08eef | |||
| 88456eb5eb | |||
| 6558cbe015 | |||
| 95d1481cb4 | |||
| d612acd715 | |||
| 51d079a467 | |||
| 50fc32c577 | |||
| 980decb3fd |
+10
-4
@@ -47,9 +47,9 @@ class SetPinForm(forms.Form):
|
||||
|
||||
@admin.register(User)
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
list_display = ("username", "consumed_drinks_price", "paid_amount", "open_balance")
|
||||
list_display = ("username", "is_staff", "is_superuser", "consumed_drinks_price", "paid_amount", "open_balance")
|
||||
ordering = ("username",)
|
||||
list_filter = []
|
||||
list_filter = ["is_staff", "is_superuser"]
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
@@ -58,6 +58,11 @@ class CustomUserAdmin(UserAdmin):
|
||||
"username",
|
||||
"password",
|
||||
"pin_status",
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"last_login",
|
||||
"user_permissions",
|
||||
)
|
||||
},
|
||||
),
|
||||
@@ -79,6 +84,7 @@ class CustomUserAdmin(UserAdmin):
|
||||
"paid_amount",
|
||||
"open_balance",
|
||||
"pin_status",
|
||||
"last_login",
|
||||
"drinks_breakdown",
|
||||
"free_drinks_breakdown",
|
||||
)
|
||||
@@ -204,8 +210,8 @@ class DonationAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(Payment)
|
||||
class PaymentAdmin(admin.ModelAdmin):
|
||||
list_display = ("date", "purpose", "amount")
|
||||
list_filter = ("date",)
|
||||
list_display = ("date", "purpose", "amount", "method")
|
||||
list_filter = ("method", "date")
|
||||
ordering = ("-date",)
|
||||
search_fields = ("purpose",)
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import F, Sum
|
||||
|
||||
from gaehsnitz.models import Consumption, Payment, UserPayment
|
||||
|
||||
|
||||
YEAR = 2026
|
||||
CASH_PREFILL = Decimal("500.00")
|
||||
|
||||
|
||||
def eur(amount):
|
||||
return str(Decimal(amount).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Print full 2026 festival finance summary"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
w = self.stdout.write
|
||||
sep = "-" * 40
|
||||
|
||||
# --- Ausgaben ---
|
||||
w("\nAUSGABEN")
|
||||
w(sep)
|
||||
payments = Payment.objects.filter(date__year=YEAR).order_by("date")
|
||||
total_out = Decimal("0")
|
||||
for p in payments:
|
||||
method_label = dict(Payment.Method.choices).get(p.method, p.method)
|
||||
w(f" {p.date} {eur(p.amount):>6}€ {method_label:<15} {p.purpose}")
|
||||
total_out += p.amount
|
||||
w(sep)
|
||||
w(f" {'TOTAL':<11} {eur(total_out):>6}€")
|
||||
|
||||
# by method
|
||||
w("")
|
||||
for method, label in Payment.Method.choices:
|
||||
subtotal = payments.filter(method=method).aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
||||
if subtotal:
|
||||
w(f" {label:<15} {eur(subtotal):>6}€")
|
||||
|
||||
# --- Einnahmen ---
|
||||
w("\nEINNAHMEN")
|
||||
w(sep)
|
||||
up = UserPayment.objects.filter(created_at__year=YEAR)
|
||||
|
||||
cash_prefill = CASH_PREFILL
|
||||
cash_payments = up.filter(method="cash").aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
||||
cash_from_sales = cash_payments - cash_prefill
|
||||
|
||||
non_cash = up.exclude(method="cash").aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
||||
|
||||
total_in = cash_from_sales + non_cash
|
||||
|
||||
drink_revenue = Consumption.objects.filter(for_free=False, drink__year=YEAR).annotate(
|
||||
cost=F("amount") * F("drink__sale_price_per_bottle")
|
||||
).aggregate(s=Sum("cost"))["s"] or Decimal("0")
|
||||
entry_donations = total_in - drink_revenue
|
||||
|
||||
w(f" Cash: {eur(cash_from_sales):>6}€")
|
||||
w(f" Cashless: {eur(non_cash):>6}€")
|
||||
w(f" Einnahmen Gesamt: {eur(total_in):>6}€")
|
||||
w(f" - Getränke-Umsatz: {eur(drink_revenue):>6}€")
|
||||
w(f" - Eintrittsspenden: {eur(entry_donations):>6}€")
|
||||
|
||||
# --- Kassensaldo ---
|
||||
w("\nKASSE")
|
||||
w(sep)
|
||||
cash_out = payments.filter(method="cash").aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
||||
expected_cash = cash_prefill + cash_from_sales - cash_out
|
||||
w(f" Kassen-Vorschuss: {eur(cash_prefill):>6}€")
|
||||
w(f" Cash-Einnahmen: {eur(cash_from_sales):>6}€")
|
||||
w(f" Cash-Ausgaben: {eur(cash_out):>6}€")
|
||||
w(f" -> in Kasse: {eur(expected_cash):>6}€")
|
||||
|
||||
# --- Gesamtbilanz ---
|
||||
w("\nGESAMTBILANZ")
|
||||
w(sep)
|
||||
net = total_in - total_out
|
||||
out_of_pocket = total_out - total_in
|
||||
w(f" Gesamtausgaben: {eur(total_out):>6}€")
|
||||
w(f" Gesamteinnahmen: {eur(total_in):>6}€")
|
||||
w(sep)
|
||||
label = "Verlust" if out_of_pocket > 0 else "Gewinn"
|
||||
w(f" {label}: {eur(abs(net)):>6}€\n")
|
||||
@@ -0,0 +1,46 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
|
||||
from gaehsnitz.models import User
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Merge source user into target user, then delete source"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("source", help="Username of user to merge FROM (will be deleted)")
|
||||
parser.add_argument("target", help="Username of user to merge INTO (will be kept)")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
source_name = options["source"]
|
||||
target_name = options["target"]
|
||||
|
||||
if source_name == target_name:
|
||||
raise CommandError("Source and target must be different users")
|
||||
|
||||
try:
|
||||
source = User.objects.get(username=source_name)
|
||||
except User.DoesNotExist:
|
||||
raise CommandError(f"Source user '{source_name}' not found")
|
||||
|
||||
try:
|
||||
target = User.objects.get(username=target_name)
|
||||
except User.DoesNotExist:
|
||||
raise CommandError(f"Target user '{target_name}' not found")
|
||||
|
||||
with transaction.atomic():
|
||||
payments = source.user_payments.count()
|
||||
consumptions = source.consumption_list.count()
|
||||
|
||||
source.user_payments.update(user=target)
|
||||
source.consumption_list.update(user=target)
|
||||
|
||||
source.delete()
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Merged '{source_name}' into '{target_name}': "
|
||||
f"{payments} payment(s), {consumptions} consumption(s) reassigned. "
|
||||
f"Source user deleted."
|
||||
)
|
||||
)
|
||||
+6
-4
@@ -8,7 +8,8 @@ from gaehsnitz.models import Drink, Payment
|
||||
PAYMENTS = [
|
||||
# purpose, date, amount
|
||||
("Toiletten", date(2026, 5, 6), 210.01),
|
||||
("Anzahlung Getränke+Kühlschrank+Bänke", date(2026, 5, 18), 400.00),
|
||||
("Anzahlung Getränke+Kühlschrank+Bänke", date(2026, 6, 8), 400.00),
|
||||
("Baumarkt", date(2026, 5, 23), 194.00),
|
||||
("Band: Six Good Years", date(2026, 6, 12), 150.00),
|
||||
("Band: Melo-Komplott", date(2026, 6, 12), 100.00),
|
||||
("Band: Mörtel", date(2026, 6, 12), 150.00),
|
||||
@@ -19,10 +20,10 @@ PAYMENTS = [
|
||||
|
||||
DRINKS = [
|
||||
# name, category, crates, btl/crate, size, price/crate, deposit/crate, sale/btl
|
||||
("Sternburg Export", "beer", 12, 20, 0.5, 10.99, 3.10, 2.00),
|
||||
("Sternburg Export", "beer", 12, 20, 0.5, 9.49, 3.10, 2.00),
|
||||
("Ur-Krostitzer", "beer", 5, 20, 0.5, 16.49, 3.10, 2.50),
|
||||
("Budweiser", "beer", 5, 20, 0.5, 20.99, 3.10, 2.50),
|
||||
("Altenburger Helles", "beer", 5, 20, 0.5, 15.99, 4.50, 2.50),
|
||||
("Altenburger Helles", "beer", 5, 20, 0.5, 13.99, 4.50, 2.50),
|
||||
("Feldschl. Radler", "radler", 2, 20, 0.5, 14.99, 3.10, 2.50),
|
||||
("Lübzer Grapef. 0,0", "alc_free_radler", 1, 20, 0.5, 17.99, 3.10, 2.50),
|
||||
("Freiberger 0,0", "alc_free_beer", 4, 20, 0.5, 15.49, 3.10, 2.50),
|
||||
@@ -30,11 +31,12 @@ DRINKS = [
|
||||
("Vita Cola", "soft", 2, 12, 1.0, 10.99, 3.30, 2.50),
|
||||
("Paulaner Spezi", "soft", 2, 20, 0.5, 17.99, 3.10, 2.50),
|
||||
("Wasser", "water", 10, 12, 1.0, 5.99, 3.30, 1.50),
|
||||
("Sekty Drink", "sekt", 0, 1, 0.33, 0.00, 0.00, 3.50),
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seed Drink rows for the 2026 festival from the supplier invoice."
|
||||
help = "Seed Drink and Payment rows for the 2026 festival."
|
||||
|
||||
@transaction.atomic
|
||||
def handle(self, *args, **options):
|
||||
@@ -0,0 +1,39 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from gaehsnitz.models import Drink
|
||||
|
||||
# Actual crates bought from supplier (all others were returned full, no cost)
|
||||
# (name, crates_ordered, crates_purchased)
|
||||
# crates_returned = crates_purchased (all empties back, full deposit refunded)
|
||||
ACTUAL_CRATES = [
|
||||
("Sternburg Export", 12, 11),
|
||||
("Ur-Krostitzer", 5, 2),
|
||||
("Budweiser", 5, 2),
|
||||
("Altenburger Helles", 5, 5),
|
||||
("Feldschl. Radler", 2, 1),
|
||||
("Lübzer Grapef. 0,0", 1, 1),
|
||||
("Freiberger 0,0", 4, 3),
|
||||
("Club Mate", 2, 2),
|
||||
("Vita Cola", 2, 1),
|
||||
("Paulaner Spezi", 2, 2),
|
||||
("Wasser", 10, 5),
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Update 2026 drink crates to actual purchased amounts"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
with transaction.atomic():
|
||||
for name, ordered, purchased in ACTUAL_CRATES:
|
||||
updated = Drink.objects.filter(name=name, year=2026).update(
|
||||
crates_ordered=ordered,
|
||||
crates_purchased=purchased,
|
||||
crates_returned=purchased,
|
||||
)
|
||||
if updated:
|
||||
self.stdout.write(f" {name}: ordered={ordered}, purchased={purchased}, returned={purchased}")
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING(f" NOT FOUND: {name}"))
|
||||
self.stdout.write(self.style.SUCCESS("Done."))
|
||||
@@ -0,0 +1,27 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
ANONYMOUS_USERNAME = "anonym"
|
||||
|
||||
|
||||
def create_anonymous(apps, schema_editor):
|
||||
User = apps.get_model("gaehsnitz", "User")
|
||||
User.objects.get_or_create(
|
||||
username=ANONYMOUS_USERNAME,
|
||||
defaults={"pin": "", "is_staff": False, "is_superuser": False},
|
||||
)
|
||||
|
||||
|
||||
def delete_anonymous(apps, schema_editor):
|
||||
User = apps.get_model("gaehsnitz", "User")
|
||||
User.objects.filter(username=ANONYMOUS_USERNAME).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("gaehsnitz", "0008_alter_drink_category"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_anonymous, delete_anonymous),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.5 on 2026-06-09 13:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('gaehsnitz', '0009_anonymous_user'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='drink',
|
||||
name='category',
|
||||
field=models.CharField(choices=[('beer', 'Bier'), ('radler', 'Radler'), ('sekt', 'Sekt'), ('alc_free_beer', 'Bier alkoholfrei'), ('alc_free_radler', 'Radler alkoholfrei'), ('soft', 'Softdrink'), ('water', 'Wasser')], default='beer', max_length=16, verbose_name='Kategorie'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.6 on 2026-06-20 12:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('gaehsnitz', '0010_add_sekt_category'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='payment',
|
||||
name='method',
|
||||
field=models.CharField(choices=[('cash', 'Bar'), ('card', 'EC-Karte'), ('paypal', 'PayPal'), ('bank', 'Überweisung')], default='paypal', max_length=16, verbose_name='Methode'),
|
||||
),
|
||||
]
|
||||
@@ -73,9 +73,16 @@ class Payment(models.Model):
|
||||
bands = 11, "Bands"
|
||||
supply_purchase = 12, "Getränke-/Essenseinkauf"
|
||||
|
||||
class Method(models.TextChoices):
|
||||
cash = "cash", "Bar"
|
||||
card = "card", "EC-Karte"
|
||||
paypal = "paypal", "PayPal"
|
||||
bank = "bank", "Überweisung"
|
||||
|
||||
purpose = models.CharField("Zweck", max_length=64)
|
||||
date = models.DateField("Datum")
|
||||
amount = PriceField("Betrag")
|
||||
method = models.CharField("Methode", max_length=16, choices=Method.choices, default=Method.paypal)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Ausgabe"
|
||||
@@ -86,6 +93,7 @@ class Drink(models.Model):
|
||||
class Category(models.TextChoices):
|
||||
beer = "beer", "Bier"
|
||||
radler = "radler", "Radler"
|
||||
sekt = "sekt", "Sekt"
|
||||
alc_free_beer = "alc_free_beer", "Bier alkoholfrei"
|
||||
alc_free_radler = "alc_free_radler", "Radler alkoholfrei"
|
||||
soft = "soft", "Softdrink"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 630 KiB |
@@ -264,6 +264,24 @@ hr {
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
.main-poster-link {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.main-poster {
|
||||
max-width: 90%;
|
||||
max-height: 600px;
|
||||
box-shadow: 0 0 24px #CC6611, 0 0 8px #EE9933;
|
||||
transition: transform 150ms, box-shadow 150ms;
|
||||
}
|
||||
|
||||
.main-poster:hover {
|
||||
transform: scale(1.03);
|
||||
box-shadow: 0 0 36px #EE9933, 0 0 12px #FFCC77;
|
||||
}
|
||||
|
||||
/* archive pages: neutral blue-grey color scheme (content only, nav/title stay amber) */
|
||||
.archive #content h2 { color: #D8DEF0; }
|
||||
.archive #content h3 { color: #B8C0D8; }
|
||||
|
||||
+298
-14
@@ -140,6 +140,25 @@ button {
|
||||
transition: transform 80ms ease, background-color 100ms ease;
|
||||
}
|
||||
|
||||
a.btn-primary {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
margin-left: auto;
|
||||
background-color: #EE9933;
|
||||
color: #161616;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
padding: 10px 18px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background-color 100ms ease;
|
||||
}
|
||||
|
||||
a.btn-primary:hover, a.btn-primary:focus {
|
||||
background-color: #FFCC77;
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
@@ -150,11 +169,100 @@ button:active {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.intro {
|
||||
font-size: 0.95rem;
|
||||
color: #CCCCCC;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.intro strong {
|
||||
color: #FFCC77;
|
||||
}
|
||||
|
||||
.amount-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.amount-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 68px;
|
||||
padding: 10px 8px;
|
||||
border: 2px solid #885522;
|
||||
border-radius: 6px;
|
||||
background: #1f1f1f;
|
||||
color: #FFCC77;
|
||||
text-decoration: none;
|
||||
transition: background-color 100ms ease, border-color 100ms ease;
|
||||
}
|
||||
|
||||
.amount-main {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.amount-sub {
|
||||
font-size: 0.8rem;
|
||||
color: #AAAAAA;
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.amount-btn:hover, .amount-btn:focus {
|
||||
border-color: #EE9933;
|
||||
background: #2a1f10;
|
||||
}
|
||||
|
||||
.amount-btn-active {
|
||||
border-color: #EE9933;
|
||||
background: #2a1f10;
|
||||
color: #EE9933;
|
||||
}
|
||||
|
||||
.amount-btn-weak {
|
||||
border-color: #553311;
|
||||
background: #181818;
|
||||
color: #AAAAAA;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.amount-btn-weak .amount-sub {
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.amount-link {
|
||||
color: #FFCC77;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.muted-left {
|
||||
font-size: 0.9rem;
|
||||
color: #AAAAAA;
|
||||
}
|
||||
|
||||
.user-self {
|
||||
color: #888888;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.muted-inline {
|
||||
color: #888888;
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.history-sub {
|
||||
margin-top: 18px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: normal;
|
||||
@@ -191,6 +299,76 @@ section {
|
||||
text-shadow: 0 0 12px #CC6611;
|
||||
}
|
||||
|
||||
.total-box-settled {
|
||||
border-color: #3a7a44;
|
||||
background-color: rgba(30, 80, 40, 0.35);
|
||||
}
|
||||
|
||||
.total-settled {
|
||||
color: #66cc77;
|
||||
font-size: 1.6rem;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 0 12px #2a6633;
|
||||
}
|
||||
|
||||
.total-breakdown {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 4px 6px;
|
||||
margin-top: 4px;
|
||||
font-size: 0.82rem;
|
||||
color: #889988;
|
||||
}
|
||||
|
||||
.for-free-toggle {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 14px 20px;
|
||||
margin-bottom: 12px;
|
||||
min-height: 52px;
|
||||
border: 2px solid #885522;
|
||||
border-radius: 6px;
|
||||
background: #1f1f1f;
|
||||
color: #FFCC77;
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color 100ms ease, border-color 100ms ease;
|
||||
}
|
||||
|
||||
.for-free-toggle input {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
accent-color: #EE9933;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.for-free-toggle:has(input:checked) {
|
||||
border-color: #EE9933;
|
||||
background: #2a1f10;
|
||||
color: #EE9933;
|
||||
}
|
||||
|
||||
|
||||
.drink-group-heading {
|
||||
font-size: 0.95rem;
|
||||
font-weight: bold;
|
||||
color: #EE9933;
|
||||
margin: 14px 0 8px 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.drink-group-heading:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.drink-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
@@ -216,45 +394,52 @@ section {
|
||||
}
|
||||
|
||||
.drink-btn-beer {
|
||||
background: linear-gradient(180deg, #f0c878 0%, #b8731a 100%);
|
||||
background: linear-gradient(180deg, #f5d088 0%, #c8831a 100%);
|
||||
}
|
||||
.drink-btn-beer:hover, .drink-btn-beer:focus {
|
||||
background: linear-gradient(180deg, #f7d699 0%, #cc8520 100%);
|
||||
background: linear-gradient(180deg, #fadfaa 0%, #dc9228 100%);
|
||||
}
|
||||
|
||||
.drink-btn-radler {
|
||||
background: linear-gradient(180deg, #f7e36b 0%, #d8a02a 100%);
|
||||
background: linear-gradient(180deg, #faea7a 0%, #e8b038 100%);
|
||||
}
|
||||
.drink-btn-radler:hover, .drink-btn-radler:focus {
|
||||
background: linear-gradient(180deg, #fff08c 0%, #e8b034 100%);
|
||||
background: linear-gradient(180deg, #fff59a 0%, #f4c048 100%);
|
||||
}
|
||||
|
||||
.drink-btn-sekt {
|
||||
background: linear-gradient(180deg, #e890cc 0%, #e8a040 25%, #e8cc55 50%, #90e865 75%, #65b8e8 100%);
|
||||
}
|
||||
.drink-btn-sekt:hover, .drink-btn-sekt:focus {
|
||||
background: linear-gradient(180deg, #f0a0d8 0%, #f0b055 25%, #f0dc70 50%, #a8f090 75%, #88ccf0 100%);
|
||||
}
|
||||
|
||||
.drink-btn-alc_free_beer {
|
||||
background: linear-gradient(180deg, #f0c878 0%, #5e8bbf 100%);
|
||||
background: linear-gradient(180deg, #f5d088 0%, #7099c8 100%);
|
||||
}
|
||||
.drink-btn-alc_free_beer:hover, .drink-btn-alc_free_beer:focus {
|
||||
background: linear-gradient(180deg, #f7d699 0%, #7aa3d1 100%);
|
||||
background: linear-gradient(180deg, #fadfaa 0%, #88aed8 100%);
|
||||
}
|
||||
|
||||
.drink-btn-alc_free_radler {
|
||||
background: linear-gradient(180deg, #f7e36b 0%, #5e8bbf 100%);
|
||||
background: linear-gradient(180deg, #faea7a 0%, #7099c8 100%);
|
||||
}
|
||||
.drink-btn-alc_free_radler:hover, .drink-btn-alc_free_radler:focus {
|
||||
background: linear-gradient(180deg, #fff08c 0%, #7aa3d1 100%);
|
||||
background: linear-gradient(180deg, #fff59a 0%, #88aed8 100%);
|
||||
}
|
||||
|
||||
.drink-btn-soft {
|
||||
background: linear-gradient(180deg, #f0a35e 0%, #a85a22 100%);
|
||||
background: linear-gradient(180deg, #f5b070 0%, #ba6a30 100%);
|
||||
}
|
||||
.drink-btn-soft:hover, .drink-btn-soft:focus {
|
||||
background: linear-gradient(180deg, #f7b878 0%, #c06a2c 100%);
|
||||
background: linear-gradient(180deg, #fac488 0%, #cc7a3a 100%);
|
||||
}
|
||||
|
||||
.drink-btn-water {
|
||||
background: linear-gradient(180deg, #d5e8f4 0%, #95c2dc 100%);
|
||||
background: linear-gradient(180deg, #daeef8 0%, #a8d0e6 100%);
|
||||
}
|
||||
.drink-btn-water:hover, .drink-btn-water:focus {
|
||||
background: linear-gradient(180deg, #e4f0f8 0%, #aad0e6 100%);
|
||||
background: linear-gradient(180deg, #eaf4fc 0%, #bcdcf0 100%);
|
||||
}
|
||||
|
||||
.drink-plus {
|
||||
@@ -286,7 +471,8 @@ section {
|
||||
|
||||
.history li {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background-color: rgba(80, 40, 10, 0.2);
|
||||
@@ -294,6 +480,68 @@ section {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.hist-delete {
|
||||
color: #885522;
|
||||
font-size: 1.1rem;
|
||||
text-decoration: none;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hist-delete:hover, .hist-delete:focus {
|
||||
color: #FFCC77;
|
||||
background-color: rgba(80, 40, 10, 0.4);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #cc4422;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-danger:hover, .btn-danger:focus {
|
||||
background-color: #ee6644;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.staff-target {
|
||||
color: #66ddee;
|
||||
background-color: rgba(102, 221, 238, 0.12);
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(102, 221, 238, 0.4);
|
||||
}
|
||||
|
||||
.self-username {
|
||||
color: #EE9933;
|
||||
background-color: rgba(238, 153, 51, 0.12);
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(238, 153, 51, 0.4);
|
||||
}
|
||||
|
||||
.confirm-actions > button,
|
||||
.confirm-actions > a {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 14px 20px;
|
||||
border-radius: 6px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.history li:nth-child(odd) {
|
||||
background-color: rgba(80, 40, 10, 0.35);
|
||||
}
|
||||
@@ -319,10 +567,42 @@ section {
|
||||
color: #FFCC77;
|
||||
}
|
||||
|
||||
.logout-form {
|
||||
.bottom-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.bottom-actions form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bottom-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-secondary-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14px 20px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
border-radius: 6px;
|
||||
border: 2px solid #885522;
|
||||
color: #885522;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
min-height: 52px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.btn-secondary-link:hover, .btn-secondary-link:focus {
|
||||
background-color: rgba(80, 40, 10, 0.4);
|
||||
color: #FFCC77;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background-color: rgba(238, 153, 51, 0.15);
|
||||
border: 2px solid #EE9933;
|
||||
@@ -475,6 +755,10 @@ section {
|
||||
grid-template-columns: 1fr auto auto;
|
||||
}
|
||||
|
||||
.history li.fin-row {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.link-btn-paypal {
|
||||
background-color: #003087;
|
||||
color: #FFFFFF;
|
||||
|
||||
+641
-54
@@ -1,4 +1,6 @@
|
||||
from datetime import datetime
|
||||
import math
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from django.conf import settings
|
||||
@@ -6,7 +8,7 @@ from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.contrib.auth import authenticate, login, logout
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import Case, F, IntegerField, Sum, Value, When
|
||||
from django.db.models import Case, CharField, Count, F, IntegerField, Sum, Value, When
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
@@ -18,14 +20,20 @@ from gaehsnitz.models import Consumption, Donation, Drink, Payment, UserPayment,
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
ANONYMOUS_USERNAME = "anonym"
|
||||
|
||||
BERLIN = ZoneInfo("Europe/Berlin")
|
||||
# TEST WINDOW (2026-05-15 – 2026-05-31): enabled early for pre-festival testing.
|
||||
# Original festival phase: BOOKING_START = 2026-06-11, BOOKING_END = 2026-06-14.
|
||||
# Switch back to original dates before the festival.
|
||||
BOOKING_START = datetime(2026, 5, 15, 0, 0, 0, tzinfo=BERLIN)
|
||||
BOOKING_END = datetime(2026, 5, 31, 23, 59, 59, tzinfo=BERLIN)
|
||||
# Festival window: 2026-05-30 10:00 – 2026-06-14 22:00 Berlin time.
|
||||
BOOKING_START = datetime(2026, 5, 30, 10, 0, 0, tzinfo=BERLIN)
|
||||
BOOKING_END = datetime(2026, 6, 17, 23, 59, 0, tzinfo=BERLIN)
|
||||
|
||||
DAY_BY_WEEKDAY = {3: 1, 4: 2, 5: 3, 6: 4}
|
||||
DAY_CUTOFF_HOUR = 6
|
||||
|
||||
|
||||
def _shifted_local(dt):
|
||||
"""Local Berlin time minus 6h — so 05:59 belongs to previous calendar day."""
|
||||
return dt.astimezone(BERLIN) - timedelta(hours=DAY_CUTOFF_HOUR)
|
||||
|
||||
|
||||
def _now():
|
||||
@@ -52,12 +60,79 @@ def _require_open(request):
|
||||
|
||||
|
||||
def _current_festival_day():
|
||||
weekday = _now().weekday()
|
||||
# Day rolls at 06:00 Berlin: bookings before 06:00 count as previous day.
|
||||
weekday = _shifted_local(timezone.now()).weekday()
|
||||
return DAY_BY_WEEKDAY.get(weekday, 4)
|
||||
|
||||
|
||||
def _normalize_name(raw):
|
||||
return slugify(raw or "")[:150]
|
||||
return slugify(raw or "")[:20]
|
||||
|
||||
|
||||
def _username_error(username):
|
||||
if len(username) < 2:
|
||||
return "Name muss mindestens 2 Zeichen lang sein."
|
||||
return None
|
||||
|
||||
|
||||
def _user_has_activity(user):
|
||||
return Consumption.objects.filter(user=user).exists() or UserPayment.objects.filter(user=user).exists()
|
||||
|
||||
|
||||
def _is_anonymous(user):
|
||||
return user.username == ANONYMOUS_USERNAME
|
||||
|
||||
|
||||
_WEAK_PINS = {"123", "234", "345", "456", "567", "678", "789", "987", "876", "765", "654", "543", "432", "321"} | {d * 3 for d in "0123456789"}
|
||||
|
||||
|
||||
def _pin_error(pin):
|
||||
if not (pin.isdigit() and len(pin) == 3):
|
||||
return "PIN muss aus genau 3 Ziffern bestehen."
|
||||
if pin in _WEAK_PINS:
|
||||
return "Diese PIN ist zu einfach. Bitte eine andere wählen."
|
||||
return None
|
||||
|
||||
|
||||
def _staff_required(view):
|
||||
def wrapped(request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
return HttpResponseRedirect(reverse("suff:name"))
|
||||
if not request.user.is_staff:
|
||||
raise Http404
|
||||
return view(request, *args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def _drink_grid_qs():
|
||||
return (
|
||||
Drink.objects.filter(year=current_year())
|
||||
.annotate(
|
||||
alcohol_order=Case(
|
||||
When(category__in=["beer", "radler", "sekt"], then=Value(0)),
|
||||
default=Value(1),
|
||||
output_field=IntegerField(),
|
||||
),
|
||||
alcohol_label=Case(
|
||||
When(category__in=["beer", "radler", "sekt"], then=Value("mit Alkohol")),
|
||||
default=Value("ohne Alkohol"),
|
||||
output_field=CharField(),
|
||||
),
|
||||
category_order=Case(
|
||||
When(category="beer", then=Value(0)),
|
||||
When(category="radler", then=Value(1)),
|
||||
When(category="sekt", then=Value(2)),
|
||||
When(category="alc_free_beer", then=Value(3)),
|
||||
When(category="alc_free_radler", then=Value(4)),
|
||||
When(category="soft", then=Value(5)),
|
||||
When(category="water", then=Value(6)),
|
||||
default=Value(99),
|
||||
output_field=IntegerField(),
|
||||
),
|
||||
)
|
||||
.order_by("alcohol_order", "category_order", "name")
|
||||
)
|
||||
|
||||
|
||||
def _tab_context(user):
|
||||
@@ -78,7 +153,7 @@ def _tab_context(user):
|
||||
"paid": paid,
|
||||
"open_balance": total - paid,
|
||||
"user_payments": payments,
|
||||
"payment_methods": UserPayment.Method.choices,
|
||||
"payment_methods": [(m, l) for m, l in UserPayment.Method.choices if m != "other"],
|
||||
}
|
||||
|
||||
|
||||
@@ -94,8 +169,8 @@ def name_view(request):
|
||||
if request.method == "POST":
|
||||
raw_name = request.POST.get("name", "")
|
||||
username = _normalize_name(raw_name)
|
||||
if not username:
|
||||
error = "Bitte einen Namen eingeben."
|
||||
if not username or (error := _username_error(username)):
|
||||
error = error or "Bitte einen Namen eingeben."
|
||||
else:
|
||||
try:
|
||||
existing = User.objects.get(username=username)
|
||||
@@ -103,16 +178,22 @@ def name_view(request):
|
||||
existing = None
|
||||
|
||||
if existing is None:
|
||||
request.session["pending_username"] = username
|
||||
request.session["pending_mode"] = "create"
|
||||
return HttpResponseRedirect(reverse("suff:pin"))
|
||||
return render(
|
||||
request,
|
||||
"suff/party_over.html",
|
||||
{"phase": phase, "username": username},
|
||||
)
|
||||
|
||||
if not existing.pin:
|
||||
if _user_has_activity(existing):
|
||||
return render(
|
||||
request,
|
||||
"suff/no_pin.html",
|
||||
{"phase": phase, "username": username},
|
||||
)
|
||||
request.session["pending_username"] = username
|
||||
request.session["pending_mode"] = "claim"
|
||||
return HttpResponseRedirect(reverse("suff:pin"))
|
||||
|
||||
request.session["pending_username"] = username
|
||||
request.session["pending_mode"] = "login"
|
||||
@@ -128,14 +209,14 @@ def pin_view(request):
|
||||
return redirect
|
||||
username = request.session.get("pending_username")
|
||||
mode = request.session.get("pending_mode")
|
||||
if not username or mode not in ("create", "login"):
|
||||
if not username or mode not in ("create", "login", "claim"):
|
||||
return HttpResponseRedirect(reverse("suff:name"))
|
||||
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
pin = request.POST.get("pin", "")
|
||||
if not (pin.isdigit() and len(pin) == 3):
|
||||
error = "PIN muss aus genau 3 Ziffern bestehen."
|
||||
if error := _pin_error(pin):
|
||||
pass
|
||||
elif mode == "create":
|
||||
if User.objects.filter(username=username).exists():
|
||||
error = "Name bereits vergeben."
|
||||
@@ -151,6 +232,23 @@ def pin_view(request):
|
||||
request.session.pop("pending_username", None)
|
||||
request.session.pop("pending_mode", None)
|
||||
return HttpResponseRedirect(reverse("suff:me"))
|
||||
elif mode == "claim":
|
||||
try:
|
||||
user = User.objects.get(username=username)
|
||||
except User.DoesNotExist:
|
||||
return HttpResponseRedirect(reverse("suff:name"))
|
||||
if user.pin or _user_has_activity(user):
|
||||
return HttpResponseRedirect(reverse("suff:name"))
|
||||
user.set_pin(pin)
|
||||
user.save()
|
||||
authed = authenticate(request, username=username, pin=pin)
|
||||
if authed is None:
|
||||
error = "Login nach Anlegen fehlgeschlagen."
|
||||
else:
|
||||
login(request, authed, backend="gaehsnitz.auth_backends.PinBackend")
|
||||
request.session.pop("pending_username", None)
|
||||
request.session.pop("pending_mode", None)
|
||||
return HttpResponseRedirect(reverse("suff:me"))
|
||||
else:
|
||||
authed = authenticate(request, username=username, pin=pin)
|
||||
if authed is None:
|
||||
@@ -173,30 +271,33 @@ def pin_view(request):
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def change_pin_view(request):
|
||||
redirect, phase = _require_open(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
pin = request.POST.get("pin", "")
|
||||
if error := _pin_error(pin):
|
||||
pass
|
||||
else:
|
||||
request.user.set_pin(pin)
|
||||
request.user.save()
|
||||
return HttpResponseRedirect(reverse("suff:me"))
|
||||
|
||||
return render(request, "suff/change_pin.html", {"phase": phase, "error": error})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def me_view(request):
|
||||
redirect, phase = _require_open(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
drinks = (
|
||||
Drink.objects.filter(year=current_year())
|
||||
.annotate(
|
||||
category_order=Case(
|
||||
When(category="beer", then=Value(0)),
|
||||
When(category="alc_free_beer", then=Value(1)),
|
||||
When(category="radler", then=Value(2)),
|
||||
When(category="alc_free_radler", then=Value(3)),
|
||||
When(category="soft", then=Value(4)),
|
||||
When(category="water", then=Value(5)),
|
||||
default=Value(99),
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
)
|
||||
.order_by("category_order", "name")
|
||||
if phase == "booking"
|
||||
else Drink.objects.none()
|
||||
)
|
||||
drinks = _drink_grid_qs() if phase == "booking" else Drink.objects.none()
|
||||
booked_drink = None
|
||||
booked_id = request.GET.get("booked")
|
||||
if booked_id:
|
||||
@@ -226,7 +327,13 @@ def book_view(request):
|
||||
if phase != "booking":
|
||||
raise Http404
|
||||
|
||||
if _is_anonymous(request.user):
|
||||
raise Http404
|
||||
|
||||
drink_id = request.POST.get("drink_id")
|
||||
booking_mode = request.POST.get("booking_mode", "normal")
|
||||
for_free = booking_mode == "for_free"
|
||||
cash_paid = booking_mode == "cash_paid"
|
||||
try:
|
||||
drink = Drink.objects.get(pk=int(drink_id))
|
||||
except (Drink.DoesNotExist, TypeError, ValueError):
|
||||
@@ -237,9 +344,67 @@ def book_view(request):
|
||||
drink=drink,
|
||||
amount=1,
|
||||
day=_current_festival_day(),
|
||||
for_free=False,
|
||||
for_free=for_free,
|
||||
)
|
||||
if cash_paid:
|
||||
UserPayment.objects.create(
|
||||
user=request.user,
|
||||
amount=drink.sale_price_per_bottle,
|
||||
method=UserPayment.Method.cash,
|
||||
note=f"Auto: {drink.name}",
|
||||
)
|
||||
free_suffix = "&free=1" if for_free else ""
|
||||
return HttpResponseRedirect(f"{reverse('suff:me')}?booked={drink.id}{free_suffix}")
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def delete_consumption_view(request, consumption_id):
|
||||
redirect, phase = _require_open(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
if phase != "booking":
|
||||
raise Http404
|
||||
|
||||
try:
|
||||
consumption = Consumption.objects.select_related("drink").get(pk=consumption_id, user=request.user)
|
||||
except Consumption.DoesNotExist:
|
||||
raise Http404
|
||||
|
||||
if request.method == "POST":
|
||||
consumption.delete()
|
||||
return HttpResponseRedirect(reverse("suff:me"))
|
||||
|
||||
return render(
|
||||
request,
|
||||
"suff/confirm_delete.html",
|
||||
{"phase": phase, "consumption": consumption},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def delete_payment_view(request, payment_id):
|
||||
redirect, phase = _require_open(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
if phase != "booking":
|
||||
raise Http404
|
||||
|
||||
try:
|
||||
payment = UserPayment.objects.get(pk=payment_id, user=request.user)
|
||||
except UserPayment.DoesNotExist:
|
||||
raise Http404
|
||||
|
||||
if request.method == "POST":
|
||||
payment.delete()
|
||||
return HttpResponseRedirect(reverse("suff:pay"))
|
||||
|
||||
return render(
|
||||
request,
|
||||
"suff/confirm_delete_payment.html",
|
||||
{"phase": phase, "payment": payment},
|
||||
)
|
||||
return HttpResponseRedirect(f"{reverse('suff:me')}?booked={drink.id}")
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -275,10 +440,306 @@ def pay_view(request):
|
||||
return HttpResponseRedirect(f"{reverse('suff:me')}?paid=1")
|
||||
|
||||
context = _tab_context(request.user)
|
||||
context.update({"phase": phase, "error": error})
|
||||
open_balance = max(float(context["open_balance"]), 0.0)
|
||||
drinks_rounded = int(math.ceil(open_balance / 5.0) * 5) if open_balance > 0 else 0
|
||||
suggestions = [
|
||||
{"amount": drinks_rounded + d, "entry": round(drinks_rounded + d - open_balance, 2)}
|
||||
for d in (10, 15, 20, 25, 30)
|
||||
]
|
||||
prefill_raw = request.GET.get("amount")
|
||||
try:
|
||||
prefill_amount = float(prefill_raw) if prefill_raw else None
|
||||
if prefill_amount is not None and prefill_amount <= 0:
|
||||
prefill_amount = None
|
||||
except ValueError:
|
||||
prefill_amount = None
|
||||
if prefill_amount is None:
|
||||
prefill_amount = float(suggestions[2]["amount"])
|
||||
prefill_value = f"{prefill_amount:.2f}"
|
||||
context.update(
|
||||
{
|
||||
"phase": phase,
|
||||
"error": error,
|
||||
"drinks_rounded": drinks_rounded,
|
||||
"suggestions": suggestions,
|
||||
"prefill_amount": prefill_amount,
|
||||
"prefill_value": prefill_value,
|
||||
"open_balance_url": f"{open_balance:.2f}",
|
||||
}
|
||||
)
|
||||
return render(request, "suff/pay.html", context)
|
||||
|
||||
|
||||
def _get_staff_target(username):
|
||||
try:
|
||||
return User.objects.get(username=username)
|
||||
except User.DoesNotExist:
|
||||
raise Http404
|
||||
|
||||
|
||||
@_staff_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def staff_register_view(request):
|
||||
redirect, phase = _require_open(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
|
||||
error = None
|
||||
prefill_name = ""
|
||||
prefill_pin = ""
|
||||
if request.method == "POST":
|
||||
raw_name = request.POST.get("name", "")
|
||||
username = _normalize_name(raw_name)
|
||||
pin = (request.POST.get("pin") or "").strip()
|
||||
prefill_name = raw_name
|
||||
prefill_pin = pin
|
||||
|
||||
if not username:
|
||||
error = "Bitte einen Namen eingeben."
|
||||
elif error := _username_error(username):
|
||||
pass
|
||||
elif User.objects.filter(username=username).exists():
|
||||
error = "Name bereits vergeben."
|
||||
elif pin and (error := _pin_error(pin)):
|
||||
pass
|
||||
else:
|
||||
user = User(username=username)
|
||||
if pin:
|
||||
user.set_pin(pin)
|
||||
user.save()
|
||||
return HttpResponseRedirect(reverse("suff:staff_user", args=[username]))
|
||||
|
||||
return render(
|
||||
request,
|
||||
"suff/staff_register.html",
|
||||
{
|
||||
"phase": phase,
|
||||
"error": error,
|
||||
"prefill_name": prefill_name,
|
||||
"prefill_pin": prefill_pin,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@_staff_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def staff_pin_reset_view(request, username):
|
||||
redirect, phase = _require_open(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
|
||||
target = _get_staff_target(username)
|
||||
if _is_anonymous(target):
|
||||
raise Http404
|
||||
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
pin = (request.POST.get("pin") or "").strip()
|
||||
if error := _pin_error(pin):
|
||||
pass
|
||||
else:
|
||||
target.set_pin(pin)
|
||||
target.save()
|
||||
return HttpResponseRedirect(reverse("suff:staff_user", args=[username]))
|
||||
|
||||
return render(
|
||||
request,
|
||||
"suff/staff_pin_reset.html",
|
||||
{"phase": phase, "tab_user": target, "error": error},
|
||||
)
|
||||
|
||||
|
||||
@_staff_required
|
||||
@require_http_methods(["GET"])
|
||||
def staff_index_view(request):
|
||||
redirect, phase = _require_open(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
|
||||
anon = User.objects.filter(username=ANONYMOUS_USERNAME).first()
|
||||
others = User.objects.exclude(username=ANONYMOUS_USERNAME).order_by("username")
|
||||
return render(
|
||||
request,
|
||||
"suff/staff_index.html",
|
||||
{"phase": phase, "anonymous": anon, "users": others},
|
||||
)
|
||||
|
||||
|
||||
@_staff_required
|
||||
@require_http_methods(["GET"])
|
||||
def staff_user_view(request, username):
|
||||
redirect, phase = _require_open(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
|
||||
target = _get_staff_target(username)
|
||||
drinks = _drink_grid_qs() if phase == "booking" else Drink.objects.none()
|
||||
booked_drink = None
|
||||
booked_id = request.GET.get("booked")
|
||||
if booked_id:
|
||||
try:
|
||||
booked_drink = Drink.objects.get(pk=int(booked_id))
|
||||
except (Drink.DoesNotExist, TypeError, ValueError):
|
||||
booked_drink = None
|
||||
|
||||
context = _tab_context(target)
|
||||
context.update(
|
||||
{
|
||||
"phase": phase,
|
||||
"drinks": drinks,
|
||||
"current_day": _current_festival_day(),
|
||||
"booked_drink": booked_drink,
|
||||
"booked_free": request.GET.get("free") == "1",
|
||||
"paid_toast": request.GET.get("paid") == "1",
|
||||
"is_anonymous_target": _is_anonymous(target),
|
||||
}
|
||||
)
|
||||
return render(request, "suff/staff_user.html", context)
|
||||
|
||||
|
||||
@_staff_required
|
||||
@require_http_methods(["POST"])
|
||||
def staff_book_view(request, username):
|
||||
redirect, phase = _require_open(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
if phase != "booking":
|
||||
raise Http404
|
||||
|
||||
target = _get_staff_target(username)
|
||||
drink_id = request.POST.get("drink_id")
|
||||
booking_mode = request.POST.get("booking_mode", "normal")
|
||||
for_free = booking_mode == "for_free"
|
||||
cash_paid = booking_mode == "cash_paid"
|
||||
try:
|
||||
drink = Drink.objects.get(pk=int(drink_id))
|
||||
except (Drink.DoesNotExist, TypeError, ValueError):
|
||||
return HttpResponseRedirect(reverse("suff:staff_user", args=[username]))
|
||||
|
||||
Consumption.objects.create(
|
||||
user=target,
|
||||
drink=drink,
|
||||
amount=1,
|
||||
day=_current_festival_day(),
|
||||
for_free=for_free,
|
||||
)
|
||||
if cash_paid or (_is_anonymous(target) and not for_free):
|
||||
UserPayment.objects.create(
|
||||
user=target,
|
||||
amount=drink.sale_price_per_bottle,
|
||||
method=UserPayment.Method.cash,
|
||||
note=f"Auto: {drink.name}",
|
||||
)
|
||||
suffix = "&free=1" if for_free else ""
|
||||
return HttpResponseRedirect(f"{reverse('suff:staff_user', args=[username])}?booked={drink.id}{suffix}")
|
||||
|
||||
|
||||
@_staff_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def staff_pay_view(request, username):
|
||||
redirect, phase = _require_open(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
|
||||
target = _get_staff_target(username)
|
||||
if _is_anonymous(target):
|
||||
raise Http404
|
||||
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
raw_amount = (request.POST.get("amount") or "").replace(",", ".").strip()
|
||||
method = request.POST.get("method") or ""
|
||||
note = (request.POST.get("note") or "").strip()[:64]
|
||||
|
||||
valid_methods = {m for m, _ in UserPayment.Method.choices}
|
||||
try:
|
||||
amount = float(raw_amount)
|
||||
except ValueError:
|
||||
amount = 0.0
|
||||
|
||||
if method not in valid_methods:
|
||||
error = "Bitte eine Zahlungsmethode wählen."
|
||||
elif amount <= 0:
|
||||
error = "Bitte einen Betrag größer 0 eingeben."
|
||||
else:
|
||||
UserPayment.objects.create(
|
||||
user=target,
|
||||
amount=amount,
|
||||
method=method,
|
||||
note=note,
|
||||
)
|
||||
return HttpResponseRedirect(f"{reverse('suff:staff_user', args=[username])}?paid=1")
|
||||
|
||||
context = _tab_context(target)
|
||||
context.update({"phase": phase, "error": error, "is_anonymous_target": False})
|
||||
return render(request, "suff/staff_pay.html", context)
|
||||
|
||||
|
||||
@_staff_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def staff_delete_payment_view(request, username, payment_id):
|
||||
redirect, phase = _require_open(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
if phase != "booking":
|
||||
raise Http404
|
||||
|
||||
target = _get_staff_target(username)
|
||||
try:
|
||||
payment = UserPayment.objects.get(pk=payment_id, user=target)
|
||||
except UserPayment.DoesNotExist:
|
||||
raise Http404
|
||||
|
||||
if request.method == "POST":
|
||||
payment.delete()
|
||||
return HttpResponseRedirect(reverse("suff:staff_pay", args=[username]))
|
||||
|
||||
return render(
|
||||
request,
|
||||
"suff/staff_confirm_delete_payment.html",
|
||||
{"phase": phase, "payment": payment, "tab_user": target},
|
||||
)
|
||||
|
||||
|
||||
@_staff_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def staff_delete_consumption_view(request, username, consumption_id):
|
||||
redirect, phase = _require_open(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
if phase != "booking":
|
||||
raise Http404
|
||||
|
||||
target = _get_staff_target(username)
|
||||
try:
|
||||
consumption = Consumption.objects.select_related("drink").get(pk=consumption_id, user=target)
|
||||
except Consumption.DoesNotExist:
|
||||
raise Http404
|
||||
|
||||
if request.method == "POST":
|
||||
if _is_anonymous(target):
|
||||
matching = (
|
||||
UserPayment.objects.filter(
|
||||
user=target,
|
||||
method=UserPayment.Method.cash,
|
||||
amount=consumption.drink.sale_price_per_bottle,
|
||||
note=f"Auto: {consumption.drink.name}",
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
if matching:
|
||||
matching.delete()
|
||||
consumption.delete()
|
||||
return HttpResponseRedirect(reverse("suff:staff_user", args=[username]))
|
||||
|
||||
return render(
|
||||
request,
|
||||
"suff/staff_confirm_delete.html",
|
||||
{"phase": phase, "consumption": consumption, "tab_user": target},
|
||||
)
|
||||
|
||||
|
||||
DAY_LABELS = {1: "Donnerstag", 2: "Freitag", 3: "Samstag", 4: "Sonntag"}
|
||||
|
||||
|
||||
@@ -287,11 +748,14 @@ DAY_LABELS = {1: "Donnerstag", 2: "Freitag", 3: "Samstag", 4: "Sonntag"}
|
||||
def dashboard_view(request):
|
||||
year = current_year()
|
||||
|
||||
CASH_PREFILL = Decimal("500.00")
|
||||
|
||||
total_donations = Donation.objects.filter(date__year=year).aggregate(s=Sum("amount"))["s"] or 0
|
||||
total_payments = Payment.objects.filter(date__year=year).aggregate(s=Sum("amount"))["s"] or 0
|
||||
total_balance = total_donations - total_payments
|
||||
total_pct = int(round((total_donations / total_payments) * 100)) if total_payments else 0
|
||||
total_pct_capped = min(total_pct, 100)
|
||||
total_costs = Payment.objects.filter(date__year=year).aggregate(s=Sum("amount"))["s"] or 0
|
||||
cash_raw = UserPayment.objects.filter(created_at__year=year, method="cash").aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
||||
cash_net = cash_raw - CASH_PREFILL
|
||||
non_cash = UserPayment.objects.filter(created_at__year=year).exclude(method="cash").aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
||||
user_payments_total = cash_net + non_cash # real income, prefill excluded
|
||||
|
||||
drinks = list(Drink.objects.filter(year=year).order_by("name"))
|
||||
drink_rows = [
|
||||
@@ -305,9 +769,14 @@ def dashboard_view(request):
|
||||
]
|
||||
sales_revenue = sum((d.sale_price_total for d in drinks), 0)
|
||||
purchase_cost = sum((d.purchase_price_total for d in drinks), 0)
|
||||
drinks_profit = sum((d.balance for d in drinks), 0)
|
||||
refinance_pct = int(round((sales_revenue / purchase_cost) * 100)) if purchase_cost else 0
|
||||
refinance_pct_capped = min(refinance_pct, 100)
|
||||
free_drinks_value = sum((d.giveaway_purchase_value for d in drinks), 0)
|
||||
unsold_purchase_value = sum((d.remaining_purchase_value for d in drinks), 0)
|
||||
unsold_sale_value = sum((d.bottles_remaining * d.sale_price_per_bottle for d in drinks), 0)
|
||||
|
||||
income_total = total_donations + user_payments_total
|
||||
finance_balance = income_total - total_costs
|
||||
finance_pct = int(round((income_total / total_costs) * 100)) if total_costs else 0
|
||||
finance_pct_capped = min(finance_pct, 100)
|
||||
|
||||
user_rows = []
|
||||
for user in User.objects.all():
|
||||
@@ -315,12 +784,15 @@ def dashboard_view(request):
|
||||
if not consumed:
|
||||
continue
|
||||
paid = user.paid_amount
|
||||
open_amount = consumed - paid
|
||||
if open_amount <= 0:
|
||||
continue
|
||||
user_rows.append(
|
||||
{
|
||||
"username": user.username,
|
||||
"consumed": consumed,
|
||||
"paid": paid,
|
||||
"open": consumed - paid,
|
||||
"open": open_amount,
|
||||
}
|
||||
)
|
||||
user_rows.sort(key=lambda r: r["open"], reverse=True)
|
||||
@@ -363,24 +835,139 @@ def dashboard_view(request):
|
||||
r = rows[0]
|
||||
top_per_day.append({"label": label, "username": r["user__username"], "amount": r["amount"]})
|
||||
|
||||
cons_qs = Consumption.objects.filter(drink__year=year)
|
||||
paid_cons_qs = cons_qs.filter(for_free=False)
|
||||
|
||||
# Time facts
|
||||
first_cons = cons_qs.exclude(created_at__isnull=True).order_by("created_at").select_related("user", "drink").first()
|
||||
last_cons = cons_qs.exclude(created_at__isnull=True).order_by("-created_at").select_related("user", "drink").first()
|
||||
|
||||
# Day rolls at 06:00 Berlin: 05:59 counts as late "previous day", 07:00 = early "today".
|
||||
# Compute time-of-day relative to 06:00 cutoff.
|
||||
earliest_per_user = None
|
||||
latest_per_user = None
|
||||
earliest_key = None
|
||||
latest_key = None
|
||||
hour_buckets = {}
|
||||
for c in cons_qs.exclude(created_at__isnull=True).values("user__username", "created_at", "amount"):
|
||||
local = c["created_at"].astimezone(BERLIN)
|
||||
# minutes after 06:00 cutoff (0 = 06:00, 1439 = 05:59 next morning)
|
||||
shifted = (local.hour * 60 + local.minute - DAY_CUTOFF_HOUR * 60) % (24 * 60)
|
||||
if earliest_key is None or shifted < earliest_key:
|
||||
earliest_key = shifted
|
||||
earliest_per_user = {"user__username": c["user__username"], "t": c["created_at"]}
|
||||
if latest_key is None or shifted > latest_key:
|
||||
latest_key = shifted
|
||||
latest_per_user = {"user__username": c["user__username"], "t": c["created_at"]}
|
||||
hour_buckets[local.hour] = hour_buckets.get(local.hour, 0) + (c["amount"] or 0)
|
||||
|
||||
golden_hour = None
|
||||
if hour_buckets:
|
||||
top_hour, top_amount = max(hour_buckets.items(), key=lambda kv: kv[1])
|
||||
golden_hour = {
|
||||
"label": f"{top_hour:02d} – {(top_hour + 1) % 24:02d} Uhr",
|
||||
"amount": top_amount,
|
||||
}
|
||||
|
||||
# Drink mix / variety
|
||||
variety_rows = (
|
||||
paid_cons_qs.values("user__username")
|
||||
.annotate(distinct=Count("drink", distinct=True))
|
||||
.order_by("-distinct")
|
||||
)
|
||||
variety_champ = variety_rows[0] if variety_rows and variety_rows[0]["distinct"] > 1 else None
|
||||
|
||||
cat_rows = (
|
||||
paid_cons_qs.values("drink__category")
|
||||
.annotate(amount=Sum("amount"))
|
||||
)
|
||||
cat_total = sum(r["amount"] for r in cat_rows) or 0
|
||||
beer_amount = sum(r["amount"] for r in cat_rows if r["drink__category"] == "beer")
|
||||
beer_share = int(round(beer_amount / cat_total * 100)) if cat_total else 0
|
||||
|
||||
def _top_in_categories(cats, label):
|
||||
rows = (
|
||||
paid_cons_qs.filter(drink__category__in=cats)
|
||||
.values("user__username")
|
||||
.annotate(amount=Sum("amount"))
|
||||
.order_by("-amount")
|
||||
)
|
||||
if rows:
|
||||
return {"label": label, "username": rows[0]["user__username"], "amount": rows[0]["amount"]}
|
||||
return None
|
||||
|
||||
alcfree_top = _top_in_categories(["alc_free_beer", "alc_free_radler"], "Alkoholfrei-Held")
|
||||
radler_top = _top_in_categories(["radler"], "Radler-Fan")
|
||||
water_top = _top_in_categories(["water"], "Wassertrinker")
|
||||
|
||||
# Money / behavior
|
||||
overpay_rows = []
|
||||
for user in User.objects.all():
|
||||
consumed = user.consumed_drinks_price or 0
|
||||
paid = user.paid_amount or 0
|
||||
if paid > consumed and consumed > 0:
|
||||
overpay_rows.append({"username": user.username, "tip": paid - consumed})
|
||||
overpay_rows.sort(key=lambda r: r["tip"], reverse=True)
|
||||
biggest_tip = overpay_rows[0] if overpay_rows else None
|
||||
total_entry = sum(r["tip"] for r in overpay_rows)
|
||||
|
||||
method_rows = (
|
||||
UserPayment.objects.filter(created_at__year=year)
|
||||
.values("method")
|
||||
.annotate(s=Sum("amount"))
|
||||
)
|
||||
method_split = {r["method"]: r["s"] for r in method_rows}
|
||||
cash_total = method_split.get(UserPayment.Method.cash, 0)
|
||||
|
||||
# Free drinks
|
||||
free_recipient_rows = (
|
||||
cons_qs.filter(for_free=True)
|
||||
.values("user__username")
|
||||
.annotate(amount=Sum("amount"))
|
||||
.order_by("-amount")
|
||||
)
|
||||
top_free_recipient = free_recipient_rows[0] if free_recipient_rows else None
|
||||
free_total_count = cons_qs.filter(for_free=True).aggregate(s=Sum("amount"))["s"] or 0
|
||||
|
||||
|
||||
context = {
|
||||
"year": year,
|
||||
"total_donations": total_donations,
|
||||
"total_payments": total_payments,
|
||||
"total_balance": total_balance,
|
||||
"total_pct": total_pct,
|
||||
"total_pct_capped": total_pct_capped,
|
||||
"total_costs": total_costs,
|
||||
"user_payments_total": user_payments_total,
|
||||
"income_total": income_total,
|
||||
"finance_balance": finance_balance,
|
||||
"finance_pct": finance_pct,
|
||||
"finance_pct_capped": finance_pct_capped,
|
||||
"drink_rows": drink_rows,
|
||||
"sales_revenue": sales_revenue,
|
||||
"purchase_cost": purchase_cost,
|
||||
"drinks_profit": drinks_profit,
|
||||
"refinance_pct": refinance_pct,
|
||||
"refinance_pct_capped": refinance_pct_capped,
|
||||
"free_drinks_value": free_drinks_value,
|
||||
"unsold_purchase_value": unsold_purchase_value,
|
||||
"unsold_sale_value": unsold_sale_value,
|
||||
"user_rows": user_rows,
|
||||
"top_spender": top_spender,
|
||||
"top_drink": top_drink,
|
||||
"busiest_day": busiest_day,
|
||||
"top_per_day": top_per_day,
|
||||
"first_cons": first_cons,
|
||||
"last_cons": last_cons,
|
||||
"earliest_per_user": earliest_per_user,
|
||||
"latest_per_user": latest_per_user,
|
||||
"golden_hour": golden_hour,
|
||||
"variety_champ": variety_champ,
|
||||
"beer_share": beer_share,
|
||||
"alcfree_top": alcfree_top,
|
||||
"radler_top": radler_top,
|
||||
"water_top": water_top,
|
||||
"biggest_tip": biggest_tip,
|
||||
"total_entry": total_entry,
|
||||
"method_split": method_split,
|
||||
"cash_total": cash_total,
|
||||
"cash_prefill": CASH_PREFILL,
|
||||
"cash_net": cash_net,
|
||||
"top_free_recipient": top_free_recipient,
|
||||
"free_total_count": free_total_count,
|
||||
}
|
||||
return render(request, "suff/dashboard.html", context)
|
||||
|
||||
|
||||
@@ -2,13 +2,24 @@ from django.urls import path
|
||||
|
||||
from gaehsnitz.suff import (
|
||||
book_view,
|
||||
change_pin_view,
|
||||
closed_view,
|
||||
dashboard_view,
|
||||
delete_consumption_view,
|
||||
delete_payment_view,
|
||||
logout_view,
|
||||
me_view,
|
||||
name_view,
|
||||
pay_view,
|
||||
pin_view,
|
||||
staff_book_view,
|
||||
staff_delete_consumption_view,
|
||||
staff_delete_payment_view,
|
||||
staff_index_view,
|
||||
staff_pay_view,
|
||||
staff_pin_reset_view,
|
||||
staff_register_view,
|
||||
staff_user_view,
|
||||
)
|
||||
|
||||
app_name = "suff"
|
||||
@@ -18,8 +29,27 @@ urlpatterns = [
|
||||
path("closed/", closed_view, name="closed"),
|
||||
path("pin/", pin_view, name="pin"),
|
||||
path("me/", me_view, name="me"),
|
||||
path("me/change-pin/", change_pin_view, name="change_pin"),
|
||||
path("book/", book_view, name="book"),
|
||||
path("book/<int:consumption_id>/delete/", delete_consumption_view, name="delete_consumption"),
|
||||
path("pay/", pay_view, name="pay"),
|
||||
path("pay/<int:payment_id>/delete/", delete_payment_view, name="delete_payment"),
|
||||
path("dashboard/", dashboard_view, name="dashboard"),
|
||||
path("staff/", staff_index_view, name="staff_index"),
|
||||
path("staff/new/", staff_register_view, name="staff_register"),
|
||||
path("staff/u/<str:username>/", staff_user_view, name="staff_user"),
|
||||
path("staff/u/<str:username>/pin/", staff_pin_reset_view, name="staff_pin_reset"),
|
||||
path("staff/u/<str:username>/book/", staff_book_view, name="staff_book"),
|
||||
path("staff/u/<str:username>/pay/", staff_pay_view, name="staff_pay"),
|
||||
path(
|
||||
"staff/u/<str:username>/pay/<int:payment_id>/delete/",
|
||||
staff_delete_payment_view,
|
||||
name="staff_delete_payment",
|
||||
),
|
||||
path(
|
||||
"staff/u/<str:username>/book/<int:consumption_id>/delete/",
|
||||
staff_delete_consumption_view,
|
||||
name="staff_delete_consumption",
|
||||
),
|
||||
path("logout/", logout_view, name="logout"),
|
||||
]
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{% extends "gaehsnitz/base.html" %} {% load money %} {% block content %}
|
||||
{% extends "gaehsnitz/base.html" %}
|
||||
{% load static %} {% load money %}
|
||||
{% block content %}
|
||||
|
||||
<h2>Gähsnitz Open Air</h2>
|
||||
<p>Unser eigenes kleines Festival ... oder auch nur eine große Gartenparty.</p>
|
||||
<p>--- Plakat folgt ---</p>
|
||||
<p>Do 11. bis So 14. Juni 2026</p>
|
||||
<p>Gähsnitzer Ring 9, 04603 Nobitz</p>
|
||||
<p>Live-Bands: Knast, Kotpiloten, Melo-Komplott, Mörtel, Quast</p>
|
||||
<a href="{% static 'gaehsnitz/poster-2026.jpg' %}" target="_blank" class="main-poster-link">
|
||||
<img src="{% static 'gaehsnitz/poster-2026.jpg' %}" alt="Plakat 2026" class="main-poster">
|
||||
</a>
|
||||
|
||||
<h2>Allgemeine Infos</h2>
|
||||
|
||||
<p>🗺️ Adresse: Gähsnitzer Ring 9, 04603 Nobitz</p>
|
||||
<p>💰 Eintritt gegen Spende. Getränke für'n schmalen Taler. Beides hilft uns sehr bei der Finanzierung. :)</p>
|
||||
<p>🤫 Es ist eine Privatparty - bitte keine öffentliche Werbung machen! Bringt aber gern Freund*innen mit.</p>
|
||||
<p>
|
||||
@@ -44,7 +44,10 @@
|
||||
<p>Donnerstag: Aufbau, Grundeinstellung, Soundcheck, evtl. Jam-Session 😎</p>
|
||||
<p>Freitag:</p>
|
||||
<ul>
|
||||
<li>18:45 Umbau + Line-Check Melo-Komplott</li>
|
||||
<li>16:30 Umbau + Line-Check Six Good Years</li>
|
||||
<li>17:00 Six Good Years</li>
|
||||
<li>18:00 Essenspause</li>
|
||||
<li>19:00 Umbau + Line-Check Melo-Komplott</li>
|
||||
<li>19:30 Melo-Komplott</li>
|
||||
<li>20:30 Umbau + Line-Check Mörtel</li>
|
||||
<li>21:00 Mörtel</li>
|
||||
@@ -61,14 +64,14 @@
|
||||
</ul>
|
||||
|
||||
<h3>Backline für alle</h3>
|
||||
<p>Drums von Josi – bitte mit ❤ behandeln</p>
|
||||
<p>Drums von <span class="marked">Josi</span> – bitte mit ❤ behandeln</p>
|
||||
<ul>
|
||||
<li>Bassdrum</li>
|
||||
<li>1 Hänge- + 2 Standtoms</li>
|
||||
<li>Stative für Snare, Hi-Hat, 1x Crash und 1x Ride</li>
|
||||
<li>Drum-Hocker</li>
|
||||
<li>Bassbox: Markbass 4x10", 4 Ohm, Speakon</li>
|
||||
<li>Gitarrenbox: <span class="marked">Ohli/Steve fragen / reicht eine?</span></li>
|
||||
<li>Gitarrenbox: 2x12", 8 Ohm, Klinke <span class="marked">(Steve)</span></li>
|
||||
</ul>
|
||||
|
||||
<h3>Was mitgenutzt werden kann ...</h3>
|
||||
@@ -93,8 +96,8 @@
|
||||
|
||||
<h3>Nur zum Jammen / was wir ungern verleihen ...</h3>
|
||||
<ul>
|
||||
<li>Snare (Josi)</li>
|
||||
<li>Becken: Crash, Ride, HiHat (Tobi)</li>
|
||||
<li>Snare <span class="marked">(Tobi)</span></li>
|
||||
<li>Becken: Crash, Ride, HiHat <span class="marked">(Tobi)</span></li>
|
||||
</ul>
|
||||
|
||||
<h3>Mikrofonierung</h3>
|
||||
@@ -104,14 +107,14 @@
|
||||
<li>Toms: 3x Audix f2</li>
|
||||
<li>Overheads: 2x Rode M5</li>
|
||||
<li>Amps: 1x Sennheiser E609 | 2x Superlux PRA 628</li>
|
||||
<li>Gesang: 1x Shure Beta 58 | <span class="marked">noch ein paar auftreiben</span></li>
|
||||
<li>DI: <span class="marked">im Proberaum schauen / ausleihen</span></li>
|
||||
<li>Gesang: 1x Shure Beta 58 | 2x Shure SM58 <span class="marked">(geplant)</span></li>
|
||||
<li>DI: 1x Mono (Pedalboard) | 1x Mono <span class="marked">(PR)</span> | 2x Mono Palmer <span class="marked">(geplant)</span></li>
|
||||
<li>Stative: <span class="marked">mal Inventur machen</span></li>
|
||||
</ul>
|
||||
|
||||
<h3>Monitoring</h3>
|
||||
<ul>
|
||||
<li class="marked">2x Alto Proberaum-PA reservieren</li>
|
||||
<li>2x Alto PA <span class="marked">(PR)</span></li>
|
||||
<li class="marked">1x JBL alte Proberaum-PA ausprobieren</li>
|
||||
</ul>
|
||||
|
||||
@@ -124,7 +127,7 @@
|
||||
|
||||
<h3>Beleuchtung</h3>
|
||||
<ul>
|
||||
<li class="marked">Carsten / Jakob fragen?</li>
|
||||
<li class="marked">alles von Franz</li>
|
||||
</ul>
|
||||
|
||||
<h3>Kabel</h3>
|
||||
|
||||
@@ -14,5 +14,30 @@
|
||||
<h1>Suff</h1>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<script>
|
||||
// Track last clicked submit button so its value survives being disabled on submit.
|
||||
document.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('button[type="submit"], button:not([type])');
|
||||
if (!btn || !btn.form) return;
|
||||
var form = btn.form;
|
||||
var existing = form.querySelector('input[data-submitter]');
|
||||
if (existing) existing.remove();
|
||||
if (btn.name) {
|
||||
var hidden = document.createElement('input');
|
||||
hidden.type = 'hidden';
|
||||
hidden.name = btn.name;
|
||||
hidden.value = btn.value;
|
||||
hidden.setAttribute('data-submitter', '1');
|
||||
form.appendChild(hidden);
|
||||
}
|
||||
});
|
||||
document.addEventListener('submit', function(e) {
|
||||
var form = e.target;
|
||||
form.querySelectorAll('button[type="submit"], button:not([type])').forEach(function(btn) {
|
||||
btn.disabled = true;
|
||||
btn.style.opacity = '0.5';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "suff/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>PIN ändern</h2>
|
||||
|
||||
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'suff:change_pin' %}">
|
||||
{% csrf_token %}
|
||||
<label>
|
||||
Neue PIN:
|
||||
<input type="text" name="pin" inputmode="numeric" pattern="[0-9]{3}" maxlength="3"
|
||||
minlength="3" required autofocus autocomplete="off" />
|
||||
</label>
|
||||
<button type="submit">PIN speichern</button>
|
||||
</form>
|
||||
|
||||
<p><a href="{% url 'suff:me' %}">Abbrechen</a></p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "suff/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Buchung löschen?</h2>
|
||||
|
||||
<p>
|
||||
Willst du wirklich
|
||||
<strong>{{ consumption.drink.name }}</strong>
|
||||
({% if consumption.for_free %}gratis{% else %}{{ consumption.drink.sale_price_per_bottle|floatformat:2 }} €{% endif %})
|
||||
von {{ consumption.get_day_display }}{% if consumption.created_at %}, {{ consumption.created_at|date:"H:i" }}{% endif %}
|
||||
löschen?
|
||||
</p>
|
||||
|
||||
<form method="post" action="{% url 'suff:delete_consumption' consumption.id %}" class="confirm-actions">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn-danger">Ja, löschen</button>
|
||||
<a href="{% url 'suff:me' %}" class="btn-secondary">Nein, zurück</a>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "suff/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Zahlung löschen?</h2>
|
||||
|
||||
<p>
|
||||
Willst du wirklich diese Zahlung löschen?
|
||||
</p>
|
||||
|
||||
<section class="total-box">
|
||||
<span class="total-label">{{ payment.get_method_display }}{% if payment.note %} – {{ payment.note }}{% endif %}</span>
|
||||
<span class="total-value">{{ payment.amount|floatformat:2 }} €</span>
|
||||
<span class="total-label">{{ payment.created_at|date:"d.m. H:i" }}</span>
|
||||
</section>
|
||||
|
||||
<form method="post" action="{% url 'suff:delete_payment' payment.id %}" class="confirm-actions">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn-danger">Ja, löschen</button>
|
||||
<a href="{% url 'suff:pay' %}" class="btn-secondary">Nein, zurück</a>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -5,31 +5,63 @@
|
||||
<h2>Dashboard {{ year }}</h2>
|
||||
|
||||
<section>
|
||||
<h3>Refinanzierung gesamt</h3>
|
||||
<h3>Finanzen</h3>
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-bar" style="width: {{ total_pct_capped }}%;"></div>
|
||||
<span class="progress-label">{{ total_pct }}%</span>
|
||||
<div class="progress-bar" style="width: {{ finance_pct_capped }}%;"></div>
|
||||
<span class="progress-label">{{ finance_pct }}%</span>
|
||||
</div>
|
||||
<p class="muted-left">
|
||||
Spenden {{ total_donations|euro }} / Ausgaben {{ total_payments|euro }}
|
||||
</p>
|
||||
<p class="muted-left">
|
||||
Bilanz: <b>{{ total_balance|euro }}</b>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Refinanzierung Getränke</h3>
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-bar" style="width: {{ refinance_pct_capped }}%;"></div>
|
||||
<span class="progress-label">{{ refinance_pct }}%</span>
|
||||
</div>
|
||||
<p class="muted-left">
|
||||
Verkaufserlös {{ sales_revenue|euro }} / Einkaufspreis {{ purchase_cost|euro }}
|
||||
</p>
|
||||
<p class="muted-left">
|
||||
Aktueller Gewinn: <b>{{ drinks_profit|euro }}</b>
|
||||
</p>
|
||||
<ul class="history">
|
||||
<li class="fin-row">
|
||||
<span class="hist-what"><b>Ausgaben gesamt</b></span>
|
||||
<span class="hist-price"><b>{{ total_costs|euro }}</b></span>
|
||||
</li>
|
||||
<li class="fin-row">
|
||||
<span class="hist-what"><b>Einnahmen gesamt</b></span>
|
||||
<span class="hist-price"><b>{{ income_total|euro }}</b></span>
|
||||
</li>
|
||||
<li class="fin-row">
|
||||
<span class="hist-what"><b>Bilanz</b></span>
|
||||
<span class="hist-price"><b>{{ finance_balance|euro }}</b></span>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="history history-sub">
|
||||
<li class="fin-row">
|
||||
<span class="hist-what">Spenden (Donations)</span>
|
||||
<span class="hist-price">{{ total_donations|euro }}</span>
|
||||
</li>
|
||||
<li class="fin-row">
|
||||
<span class="hist-what">Cashless (User-Tab)</span>
|
||||
<span class="hist-price">{{ user_payments_total|euro }}</span>
|
||||
</li>
|
||||
<li class="fin-row">
|
||||
<span class="hist-what">Kasse (bar, nach Vorschuss)</span>
|
||||
<span class="hist-price">{{ cash_net|euro }}</span>
|
||||
</li>
|
||||
<li class="fin-row">
|
||||
<span class="hist-what">Kassen-Vorschuss</span>
|
||||
<span class="hist-price">{{ cash_prefill|euro }}</span>
|
||||
</li>
|
||||
<li class="fin-row">
|
||||
<span class="hist-what">Einkaufspreis Getränke</span>
|
||||
<span class="hist-price">{{ purchase_cost|euro }}</span>
|
||||
</li>
|
||||
<li class="fin-row">
|
||||
<span class="hist-what">Verkaufserlös Getränke</span>
|
||||
<span class="hist-price">{{ sales_revenue|euro }}</span>
|
||||
</li>
|
||||
<li class="fin-row">
|
||||
<span class="hist-what">Gratis-Getränke (EK-Wert)</span>
|
||||
<span class="hist-price">{{ free_drinks_value|euro }}</span>
|
||||
</li>
|
||||
<li class="fin-row">
|
||||
<span class="hist-what">Unverkauft (EK-Wert)</span>
|
||||
<span class="hist-price">{{ unsold_purchase_value|euro }}</span>
|
||||
</li>
|
||||
<li class="fin-row">
|
||||
<span class="hist-what">Unverkauft (potenzieller VK)</span>
|
||||
<span class="hist-price">{{ unsold_sale_value|euro }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -63,7 +95,7 @@
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Fun Facts</h3>
|
||||
<h3>Fun Facts: Trinker</h3>
|
||||
<ul class="history">
|
||||
{% if top_spender %}
|
||||
<li>
|
||||
@@ -72,6 +104,26 @@
|
||||
<span class="hist-price">{{ top_spender.total|euro }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if variety_champ %}
|
||||
<li>
|
||||
<span class="hist-when">Vielfalt-Champion</span>
|
||||
<span class="hist-what">{{ variety_champ.user__username }}</span>
|
||||
<span class="hist-price">{{ variety_champ.distinct }} Sorten</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% for f in top_per_day %}
|
||||
<li>
|
||||
<span class="hist-when">Top {{ f.label }}</span>
|
||||
<span class="hist-what">{{ f.username }}</span>
|
||||
<span class="hist-price">{{ f.amount }} Flaschen</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Fun Facts: Getränke</h3>
|
||||
<ul class="history">
|
||||
{% if top_drink %}
|
||||
<li>
|
||||
<span class="hist-when">Top-Getränk</span>
|
||||
@@ -79,6 +131,73 @@
|
||||
<span class="hist-price">{{ top_drink.amount }}x</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<span class="hist-when">Bier-Anteil</span>
|
||||
<span class="hist-what">am Konsum</span>
|
||||
<span class="hist-price">{{ beer_share }}%</span>
|
||||
</li>
|
||||
{% if alcfree_top %}
|
||||
<li>
|
||||
<span class="hist-when">{{ alcfree_top.label }}</span>
|
||||
<span class="hist-what">{{ alcfree_top.username }}</span>
|
||||
<span class="hist-price">{{ alcfree_top.amount }} Flaschen</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if radler_top %}
|
||||
<li>
|
||||
<span class="hist-when">{{ radler_top.label }}</span>
|
||||
<span class="hist-what">{{ radler_top.username }}</span>
|
||||
<span class="hist-price">{{ radler_top.amount }} Flaschen</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if water_top %}
|
||||
<li>
|
||||
<span class="hist-when">{{ water_top.label }}</span>
|
||||
<span class="hist-what">{{ water_top.username }}</span>
|
||||
<span class="hist-price">{{ water_top.amount }} Flaschen</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Fun Facts: Zeit</h3>
|
||||
<ul class="history">
|
||||
{% if first_cons %}
|
||||
<li>
|
||||
<span class="hist-when">Erste Buchung</span>
|
||||
<span class="hist-what">{{ first_cons.user.username }} – {{ first_cons.drink.name }}</span>
|
||||
<span class="hist-price">{{ first_cons.created_at|date:"d.m. H:i" }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if last_cons %}
|
||||
<li>
|
||||
<span class="hist-when">Letzter Schluck</span>
|
||||
<span class="hist-what">{{ last_cons.user.username }} – {{ last_cons.drink.name }}</span>
|
||||
<span class="hist-price">{{ last_cons.created_at|date:"d.m. H:i" }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if earliest_per_user %}
|
||||
<li>
|
||||
<span class="hist-when">Frühaufsteher</span>
|
||||
<span class="hist-what">{{ earliest_per_user.user__username }}</span>
|
||||
<span class="hist-price">{{ earliest_per_user.t|date:"d.m. H:i" }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if latest_per_user %}
|
||||
<li>
|
||||
<span class="hist-when">Nachtschwärmer</span>
|
||||
<span class="hist-what">{{ latest_per_user.user__username }}</span>
|
||||
<span class="hist-price">{{ latest_per_user.t|date:"d.m. H:i" }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if golden_hour %}
|
||||
<li>
|
||||
<span class="hist-when">Goldene Stunde</span>
|
||||
<span class="hist-what">{{ golden_hour.label }}</span>
|
||||
<span class="hist-price">{{ golden_hour.amount }} Flaschen</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if busiest_day %}
|
||||
<li>
|
||||
<span class="hist-when">Härtester Tag</span>
|
||||
@@ -86,16 +205,52 @@
|
||||
<span class="hist-price">{{ busiest_day.amount }} Flaschen</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% for f in top_per_day %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Fun Facts: Geld</h3>
|
||||
<ul class="history">
|
||||
{% if biggest_tip %}
|
||||
<li>
|
||||
<span class="hist-when">{{ f.label }}</span>
|
||||
<span class="hist-what">{{ f.username }}</span>
|
||||
<span class="hist-price">{{ f.amount }} Flaschen</span>
|
||||
<span class="hist-when">Bigspender-Trinkgeld</span>
|
||||
<span class="hist-what">{{ biggest_tip.username }}</span>
|
||||
<span class="hist-price">{{ biggest_tip.tip|euro }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li>
|
||||
<span class="hist-when">Eintrittsspenden gesamt</span>
|
||||
<span class="hist-what">aus Überzahlungen</span>
|
||||
<span class="hist-price">{{ total_entry|euro }}</span>
|
||||
</li>
|
||||
{% for method, sum in method_split.items %}
|
||||
<li>
|
||||
<span class="hist-when">Zahlungen {{ method }}</span>
|
||||
<span class="hist-what"></span>
|
||||
<span class="hist-price">{{ sum|euro }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Fun Facts: Gratis</h3>
|
||||
<ul class="history">
|
||||
<li>
|
||||
<span class="hist-when">Aufmerksamkeit des Hauses</span>
|
||||
<span class="hist-what">Gratis-Flaschen</span>
|
||||
<span class="hist-price">{{ free_total_count }}x</span>
|
||||
</li>
|
||||
{% if top_free_recipient %}
|
||||
<li>
|
||||
<span class="hist-when">Glücklicher Empfänger</span>
|
||||
<span class="hist-what">{{ top_free_recipient.user__username }}</span>
|
||||
<span class="hist-price">{{ top_free_recipient.amount }} gratis</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div class="logout-form">
|
||||
<a href="{% url 'suff:me' %}" class="link-btn link-btn-secondary">Zurück</a>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
{% extends "suff/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Hallo {{ tab_user.username }}</h2>
|
||||
<h2>Hallo <span class="self-username">{{ tab_user.username }}</span></h2>
|
||||
|
||||
<p class="intro">
|
||||
Tipp dich rein, sobald du was trinkst. Am Ende deines Besuchs kannst du
|
||||
alles zusammen mit deiner <strong>Eintrittsspende</strong> bezahlen –
|
||||
bar oder per PayPal.
|
||||
</p>
|
||||
|
||||
{% if booked_drink %}
|
||||
<div class="toast" role="status">
|
||||
@@ -15,7 +21,21 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="total-box">
|
||||
<section class="total-box {% if paid and open_balance <= 0 %}total-box-settled{% endif %}">
|
||||
{% if paid and open_balance <= 0 %}
|
||||
<span class="total-settled">Bezahlt ✓</span>
|
||||
<div class="total-breakdown">
|
||||
<span>Drinks {{ total|floatformat:2 }} €</span>
|
||||
<span>·</span>
|
||||
<span>Bezahlt {{ paid|floatformat:2 }} €</span>
|
||||
<span>·</span>
|
||||
{% if open_balance < 0 %}
|
||||
<span>Spende {{ open_balance|floatformat:2|slice:"1:" }} €</span>
|
||||
{% else %}
|
||||
<span>Genau bezahlt</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="total-label">Deine Rechnung</span>
|
||||
<span class="total-value">{{ total|floatformat:2 }} €</span>
|
||||
{% if paid %}
|
||||
@@ -24,28 +44,49 @@
|
||||
<span class="total-label">Offen</span>
|
||||
<span class="total-value">{{ open_balance|floatformat:2 }} €</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if open_balance > 0 %}
|
||||
<p><a href="{% url 'suff:pay' %}" class="btn-primary">Bezahlen</a></p>
|
||||
{% endif %}
|
||||
<p><a href="{% url 'suff:pay' %}" class="btn-primary">Für Drinks und Eintritt spenden</a></p>
|
||||
|
||||
{% if phase == "booking" %}
|
||||
<section>
|
||||
<h3>Neues Getränk buchen</h3>
|
||||
<div class="drink-grid">
|
||||
{% for drink in drinks %}
|
||||
<form method="post" action="{% url 'suff:book' %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="drink_id" value="{{ drink.id }}" />
|
||||
<button type="submit" class="drink-btn drink-btn-{{ drink.category }}">
|
||||
<label class="for-free-toggle">
|
||||
<input type="checkbox" name="booking_mode" value="for_free" />
|
||||
<span>Gratis (z.B. Artists am Spieltag)</span>
|
||||
</label>
|
||||
<label class="for-free-toggle">
|
||||
<input type="checkbox" name="booking_mode" value="cash_paid" />
|
||||
<span>Direkt bar bezahlt</span>
|
||||
</label>
|
||||
<script>
|
||||
document.querySelectorAll('input[name="booking_mode"]').forEach(function(cb) {
|
||||
cb.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
document.querySelectorAll('input[name="booking_mode"]').forEach(function(other) {
|
||||
if (other !== cb) other.checked = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% regroup drinks by alcohol_label as drink_groups %}
|
||||
{% for group in drink_groups %}
|
||||
<h4 class="drink-group-heading">{{ group.grouper }}</h4>
|
||||
<div class="drink-grid">
|
||||
{% for drink in group.list %}
|
||||
<button type="submit" name="drink_id" value="{{ drink.id }}" class="drink-btn drink-btn-{{ drink.category }}">
|
||||
<span class="drink-plus">+1</span>
|
||||
<span class="drink-name">{{ drink.name }}</span>
|
||||
<span class="drink-price">{{ drink.sale_price_per_bottle|floatformat:2 }} €</span>
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
@@ -64,6 +105,9 @@
|
||||
<span class="hist-price">
|
||||
{% if c.for_free %}gratis{% else %}{{ c.drink.sale_price_per_bottle|floatformat:2 }} €{% endif %}
|
||||
</span>
|
||||
{% if phase == "booking" %}
|
||||
<a href="{% url 'suff:delete_consumption' c.id %}" class="hist-delete" aria-label="Buchung löschen">🗑</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@@ -73,19 +117,22 @@
|
||||
<div class="empty-state">
|
||||
<p class="empty-emoji">🍺</p>
|
||||
<p>Noch nichts gebucht.</p>
|
||||
{% if phase == "booking" %}<p class="muted">Tipp dich rein, sobald du was trinkst!</p>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if request.user.is_staff %}
|
||||
<div class="link-row">
|
||||
<a href="{% url 'suff:dashboard' %}" class="link-btn link-btn-secondary">Dashboard</a>
|
||||
<a href="{% url 'suff:staff_index' %}" class="link-btn link-btn-secondary">Crew: Buchen für andere</a>
|
||||
<a href="{% url 'suff:dashboard' %}" class="link-btn link-btn-secondary">Crew: Dashboard</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'suff:logout' %}" class="logout-form">
|
||||
<div class="bottom-actions">
|
||||
<a href="{% url 'suff:change_pin' %}" class="btn-secondary btn-secondary-link">PIN ändern</a>
|
||||
<form method="post" action="{% url 'suff:logout' %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn-secondary">Logout</button>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
{% extends "suff/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Keine PIN gesetzt</h2>
|
||||
<h2>PIN benötigt</h2>
|
||||
<p>
|
||||
Für den Namen <b>{{ username }}</b> ist noch keine PIN hinterlegt.
|
||||
Für den Namen <b>{{ username }}</b> ist keine PIN gesetzt, aber
|
||||
der Account hat schon Buchungen oder Zahlungen.
|
||||
</p>
|
||||
<p>
|
||||
Das ist ein Staff-Account. Aus Sicherheitsgründen kann die PIN für solche
|
||||
Accounts nicht selbst gesetzt werden – sonst könnte sich jeder mit dem
|
||||
Namen eines Admins eine eigene PIN anlegen und damit hier einloggen.
|
||||
</p>
|
||||
<p>
|
||||
Bitte einen Admin bitten, die PIN über das Admin-Panel zu setzen.
|
||||
Aus Sicherheit kannst du diesen Account nicht selbst übernehmen.
|
||||
Bitte jemanden an der Bar, dir eine neue PIN zu setzen.
|
||||
</p>
|
||||
<div class="link-row">
|
||||
<a href="{% url 'suff:name' %}" class="link-btn">Zurück</a>
|
||||
<a href="/admin/" class="link-btn link-btn-secondary">Admin-Panel</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "suff/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Party vorbei!</h2>
|
||||
<p>
|
||||
Der Name <b>{{ username }}</b> existiert noch nicht —
|
||||
neue Accounts können nicht mehr angelegt werden, weil das Festival vorbei ist.
|
||||
</p>
|
||||
<p>
|
||||
Du hast schon einen Account? Gib deinen Namen nochmal ein.
|
||||
</p>
|
||||
<div class="link-row">
|
||||
<a href="{% url 'suff:name' %}" class="link-btn">Zurück</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,29 +1,64 @@
|
||||
{% extends "suff/base.html" %}
|
||||
{% load l10n %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Bezahlen</h2>
|
||||
<h2>Spenden</h2>
|
||||
|
||||
<p>
|
||||
Bitte zahle deinen offenen Betrag mit deiner bevorzugten Methode
|
||||
(z. B. Bar an der Kasse oder PayPal an Flo) und trage
|
||||
den bezahlten Betrag anschließend hier ein.
|
||||
</p>
|
||||
|
||||
<div class="link-row">
|
||||
<a href="https://www.paypal.com/paypalme/lotharwiener" target="_blank" rel="noopener noreferrer" class="link-btn link-btn-paypal">
|
||||
PayPal öffnen
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<section class="total-box">
|
||||
<section class="total-box {% if paid and open_balance <= 0 %}total-box-settled{% endif %}">
|
||||
{% if paid and open_balance <= 0 %}
|
||||
<span class="total-settled">Bezahlt ✓</span>
|
||||
<div class="total-breakdown">
|
||||
<span>Drinks {{ total|floatformat:2 }} €</span>
|
||||
<span>·</span>
|
||||
<span>Bezahlt {{ paid|floatformat:2 }} €</span>
|
||||
<span>·</span>
|
||||
{% if open_balance < 0 %}
|
||||
<span>Spende {{ open_balance|floatformat:2|slice:"1:" }} €</span>
|
||||
{% else %}
|
||||
<span>Genau bezahlt</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="total-label">Deine Rechnung</span>
|
||||
<span class="total-value">{{ total|floatformat:2 }} €</span>
|
||||
{% if paid %}
|
||||
<span class="total-label">Bezahlt</span>
|
||||
<span class="total-value">{{ paid|floatformat:2 }} €</span>
|
||||
{% endif %}
|
||||
<span class="total-label">Offen</span>
|
||||
<span class="total-label">Offen (Drinks)</span>
|
||||
<span class="total-value">{{ open_balance|floatformat:2 }} €</span>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<p class="intro">
|
||||
Dein Beitrag deckt die <strong>Drinks</strong> und deine
|
||||
<strong>Eintrittsspende</strong>. Die Vorschläge unten runden deinen
|
||||
offenen Drink-Betrag auf die nächsten 5 € und legen 10–30 €
|
||||
Eintritt drauf.
|
||||
</p>
|
||||
<p class="intro">
|
||||
Du darfst gerne <strong>weniger</strong> geben, wenn das gerade besser
|
||||
passt – kein Problem. Und wenn du <strong>mehr</strong> geben kannst,
|
||||
hilft uns das sehr, die Kosten für Bands, Toiletten usw. zu
|
||||
decken. So oder so: <strong>danke, dass du da bist!</strong>
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h3>Vorschläge</h3>
|
||||
<div class="amount-grid">
|
||||
{% for s in suggestions %}
|
||||
<a href="?amount={{ s.amount }}" class="amount-btn{% if prefill_amount == s.amount %} amount-btn-active{% endif %}">
|
||||
<span class="amount-main">{{ s.amount }} €</span>
|
||||
<span class="amount-sub">→ {{ s.entry|floatformat:2 }} € Eintritt</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% if open_balance > 0 %}
|
||||
<a href="?amount={{ open_balance_url }}" class="amount-btn amount-btn-weak">
|
||||
<span class="amount-main">{{ open_balance|floatformat:2 }} €</span>
|
||||
<span class="amount-sub">→ Nur Drinks</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if error %}
|
||||
@@ -34,8 +69,8 @@
|
||||
{% csrf_token %}
|
||||
<label>
|
||||
Betrag (€)
|
||||
<input type="number" name="amount" step="0.01" min="0.01"
|
||||
value="{{ open_balance|floatformat:2 }}" required />
|
||||
<input type="text" name="amount" inputmode="decimal" pattern="[0-9]+([.,][0-9]{1,2})?"
|
||||
value="{% if prefill_value %}{{ prefill_value }}{% else %}{{ suggestions.0.amount }}{% endif %}" required />
|
||||
</label>
|
||||
<label>
|
||||
Methode
|
||||
@@ -53,6 +88,12 @@
|
||||
<button type="submit" class="btn-primary">Zahlung eintragen</button>
|
||||
</form>
|
||||
|
||||
<div class="link-row">
|
||||
<a href="https://www.paypal.com/paypalme/lotharwiener" target="_blank" rel="noopener noreferrer" class="link-btn link-btn-paypal">
|
||||
PayPal öffnen
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if user_payments %}
|
||||
<section>
|
||||
<h3>Bisherige Zahlungen</h3>
|
||||
@@ -62,6 +103,9 @@
|
||||
<span class="hist-when">{{ p.created_at|date:"d.m. H:i" }}</span>
|
||||
<span class="hist-what">{{ p.get_method_display }}{% if p.note %} – {{ p.note }}{% endif %}</span>
|
||||
<span class="hist-price">{{ p.amount|floatformat:2 }} €</span>
|
||||
{% if phase == "booking" %}
|
||||
<a href="{% url 'suff:delete_payment' p.id %}" class="hist-delete" aria-label="Zahlung löschen">🗑</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
<h2>Neuer Account: {{ username }}</h2>
|
||||
<p>Merk dir diesen Namen: <b>{{ username }}</b>. Du brauchst ihn beim nächsten Login.</p>
|
||||
<p>Wähl eine 3-stellige PIN.</p>
|
||||
{% elif mode == "claim" %}
|
||||
<h2>Account übernehmen: {{ username }}</h2>
|
||||
<p>Dieser Account hat noch keine PIN und keine Buchungen. Setz jetzt eine 3-stellige PIN, um ihn zu übernehmen.</p>
|
||||
{% else %}
|
||||
<h2>Hallo {{ username }}</h2>
|
||||
<p>Gib deine 3-stellige PIN ein.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}<p><b>{{ error }}</b></p>{% endif %}
|
||||
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'suff:pin' %}">
|
||||
{% csrf_token %}
|
||||
@@ -20,7 +23,7 @@
|
||||
minlength="3" required autofocus autocomplete="off" />
|
||||
</label>
|
||||
<button type="submit">
|
||||
{% if mode == "create" %}Account anlegen{% else %}Login{% endif %}
|
||||
{% if mode == "create" %}Account anlegen{% elif mode == "claim" %}Account übernehmen{% else %}Login{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "suff/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<p class="muted">Crew-Ansicht</p>
|
||||
<h2>Buchung von <span class="staff-target">{{ tab_user.username }}</span> löschen?</h2>
|
||||
|
||||
<p>
|
||||
Willst du wirklich
|
||||
<strong>{{ consumption.drink.name }}</strong>
|
||||
({% if consumption.for_free %}gratis{% else %}{{ consumption.drink.sale_price_per_bottle|floatformat:2 }} €{% endif %})
|
||||
von {{ consumption.get_day_display }}{% if consumption.created_at %}, {{ consumption.created_at|date:"H:i" }}{% endif %}
|
||||
löschen?
|
||||
</p>
|
||||
|
||||
{% if tab_user.username == "anonym" %}
|
||||
<p class="muted">Die zugehörige Bar-Zahlung wird ebenfalls entfernt.</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'suff:staff_delete_consumption' tab_user.username consumption.id %}" class="confirm-actions">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn-danger">Ja, löschen</button>
|
||||
<a href="{% url 'suff:staff_user' tab_user.username %}" class="btn-secondary">Nein, zurück</a>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends "suff/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<p class="muted">Crew-Ansicht</p>
|
||||
<h2>Zahlung löschen für <span class="staff-target">{{ tab_user.username }}</span>?</h2>
|
||||
|
||||
<section class="total-box">
|
||||
<span class="total-label">{{ payment.get_method_display }}{% if payment.note %} – {{ payment.note }}{% endif %}</span>
|
||||
<span class="total-value">{{ payment.amount|floatformat:2 }} €</span>
|
||||
<span class="total-label">{{ payment.created_at|date:"d.m. H:i" }}</span>
|
||||
</section>
|
||||
|
||||
<form method="post" action="{% url 'suff:staff_delete_payment' tab_user.username payment.id %}" class="confirm-actions">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn-danger">Ja, löschen</button>
|
||||
<a href="{% url 'suff:staff_pay' tab_user.username %}" class="btn-secondary">Nein, zurück</a>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,45 @@
|
||||
{% extends "suff/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<p class="muted">Crew-Ansicht</p>
|
||||
<h2>Benutzer wählen</h2>
|
||||
|
||||
<p>Buchung oder Zahlung im Auftrag eines Benutzers eintragen.</p>
|
||||
|
||||
<div class="link-row">
|
||||
<a href="{% url 'suff:staff_register' %}" class="link-btn">Neuen Benutzer anlegen</a>
|
||||
</div>
|
||||
|
||||
{% if anonymous %}
|
||||
<div class="link-row">
|
||||
<a href="{% url 'suff:staff_user' anonymous.username %}" class="link-btn">
|
||||
Anonymer Gast (Bar)
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<section>
|
||||
<h3>Registrierte Benutzer</h3>
|
||||
{% if users %}
|
||||
<ul class="history">
|
||||
{% for u in users %}
|
||||
<li>
|
||||
<span class="hist-what">
|
||||
{% if u.username == request.user.username %}
|
||||
<span class="user-self">{{ u.username }} <span class="muted-inline">(das bist du)</span></span>
|
||||
{% else %}
|
||||
<a href="{% url 'suff:staff_user' u.username %}">{{ u.username }}</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">Keine Benutzer.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<div class="logout-form">
|
||||
<a href="{% url 'suff:me' %}" class="link-btn link-btn-secondary">Zurück</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,81 @@
|
||||
{% extends "suff/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<p class="muted">Crew-Ansicht</p>
|
||||
<h2>Zahlung für <span class="staff-target">{{ tab_user.username }}</span></h2>
|
||||
|
||||
<section class="total-box {% if paid and open_balance <= 0 %}total-box-settled{% endif %}">
|
||||
{% if paid and open_balance <= 0 %}
|
||||
<span class="total-settled">Bezahlt ✓</span>
|
||||
<div class="total-breakdown">
|
||||
<span>Drinks {{ total|floatformat:2 }} €</span>
|
||||
<span>·</span>
|
||||
<span>Bezahlt {{ paid|floatformat:2 }} €</span>
|
||||
<span>·</span>
|
||||
{% if open_balance < 0 %}
|
||||
<span>Spende {{ open_balance|floatformat:2|slice:"1:" }} €</span>
|
||||
{% else %}
|
||||
<span>Genau bezahlt</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="total-label">Rechnung</span>
|
||||
<span class="total-value">{{ total|floatformat:2 }} €</span>
|
||||
{% if paid %}
|
||||
<span class="total-label">Bezahlt</span>
|
||||
<span class="total-value">{{ paid|floatformat:2 }} €</span>
|
||||
{% endif %}
|
||||
<span class="total-label">Offen</span>
|
||||
<span class="total-value">{{ open_balance|floatformat:2 }} €</span>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'suff:staff_pay' tab_user.username %}" class="pay-form">
|
||||
{% csrf_token %}
|
||||
<label>
|
||||
Betrag (€)
|
||||
<input type="number" name="amount" step="0.01" min="0.01"
|
||||
value="{% if open_balance > 0 %}{{ open_balance|floatformat:2 }}{% else %}0.00{% endif %}" required />
|
||||
</label>
|
||||
<label>
|
||||
Methode
|
||||
<select name="method" required>
|
||||
<option value="">— wählen —</option>
|
||||
{% for value, label in payment_methods %}
|
||||
<option value="{{ value }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Notiz (optional)
|
||||
<input type="text" name="note" maxlength="64" />
|
||||
</label>
|
||||
<button type="submit" class="btn-primary">Zahlung eintragen</button>
|
||||
</form>
|
||||
|
||||
{% if user_payments %}
|
||||
<section>
|
||||
<h3>Bisherige Zahlungen</h3>
|
||||
<ul class="history">
|
||||
{% for p in user_payments %}
|
||||
<li>
|
||||
<span class="hist-when">{{ p.created_at|date:"d.m. H:i" }}</span>
|
||||
<span class="hist-what">{{ p.get_method_display }}{% if p.note %} – {{ p.note }}{% endif %}</span>
|
||||
<span class="hist-price">{{ p.amount|floatformat:2 }} €</span>
|
||||
{% if phase == "booking" %}
|
||||
<a href="{% url 'suff:staff_delete_payment' tab_user.username p.id %}" class="hist-delete" aria-label="Zahlung löschen">🗑</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="logout-form">
|
||||
<a href="{% url 'suff:staff_user' tab_user.username %}" class="link-btn link-btn-secondary">Zurück</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "suff/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<p class="muted">Crew-Ansicht</p>
|
||||
<h2>PIN setzen für <span class="staff-target">{{ tab_user.username }}</span></h2>
|
||||
|
||||
<p>Neue 3-stellige PIN eingeben. Eine bestehende PIN wird überschrieben.</p>
|
||||
|
||||
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'suff:staff_pin_reset' tab_user.username %}">
|
||||
{% csrf_token %}
|
||||
<label>
|
||||
Neue PIN
|
||||
<input type="text" name="pin" inputmode="numeric" pattern="[0-9]{3}"
|
||||
maxlength="3" minlength="3" required autofocus autocomplete="off" />
|
||||
</label>
|
||||
<button type="submit" class="btn-primary">PIN speichern</button>
|
||||
</form>
|
||||
|
||||
<div class="logout-form">
|
||||
<a href="{% url 'suff:staff_user' tab_user.username %}" class="link-btn link-btn-secondary">Zurück</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,29 @@
|
||||
{% extends "suff/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<p class="muted">Crew-Ansicht</p>
|
||||
<h2>Neuen Benutzer anlegen</h2>
|
||||
|
||||
<p>Name eingeben. PIN ist optional — kann später bei Bedarf gesetzt werden.</p>
|
||||
|
||||
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'suff:staff_register' %}">
|
||||
{% csrf_token %}
|
||||
<label>
|
||||
Name
|
||||
<input type="text" name="name" maxlength="150" required autofocus
|
||||
value="{{ prefill_name }}" />
|
||||
</label>
|
||||
<label>
|
||||
PIN (optional, 3 Ziffern)
|
||||
<input type="text" name="pin" inputmode="numeric" pattern="[0-9]{3}"
|
||||
maxlength="3" autocomplete="off" value="{{ prefill_pin }}" />
|
||||
</label>
|
||||
<button type="submit" class="btn-primary">Anlegen</button>
|
||||
</form>
|
||||
|
||||
<div class="logout-form">
|
||||
<a href="{% url 'suff:staff_index' %}" class="link-btn link-btn-secondary">Zurück</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,132 @@
|
||||
{% extends "suff/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<p class="muted">Crew-Ansicht</p>
|
||||
<h2>Buchen für <span class="staff-target">{{ tab_user.username }}</span></h2>
|
||||
|
||||
{% if booked_drink %}
|
||||
<div class="toast" role="status">
|
||||
Gebucht: +1 {{ booked_drink.name }}{% if booked_free %} (gratis){% elif is_anonymous_target %} (bar bezahlt){% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if paid_toast %}
|
||||
<div class="toast" role="status">
|
||||
Zahlung gespeichert.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_anonymous_target %}
|
||||
<p class="muted">Anonyme Buchungen werden automatisch als bar bezahlt eingetragen.</p>
|
||||
{% else %}
|
||||
<section class="total-box {% if paid and open_balance <= 0 %}total-box-settled{% endif %}">
|
||||
{% if paid and open_balance <= 0 %}
|
||||
<span class="total-settled">Bezahlt ✓</span>
|
||||
<div class="total-breakdown">
|
||||
<span>Drinks {{ total|floatformat:2 }} €</span>
|
||||
<span>·</span>
|
||||
<span>Bezahlt {{ paid|floatformat:2 }} €</span>
|
||||
<span>·</span>
|
||||
{% if open_balance < 0 %}
|
||||
<span>Spende {{ open_balance|floatformat:2|slice:"1:" }} €</span>
|
||||
{% else %}
|
||||
<span>Genau bezahlt</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="total-label">Rechnung</span>
|
||||
<span class="total-value">{{ total|floatformat:2 }} €</span>
|
||||
{% if paid %}
|
||||
<span class="total-label">Bezahlt</span>
|
||||
<span class="total-value">{{ paid|floatformat:2 }} €</span>
|
||||
<span class="total-label">Offen</span>
|
||||
<span class="total-value">{{ open_balance|floatformat:2 }} €</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<p><a href="{% url 'suff:staff_pay' tab_user.username %}" class="btn-primary">Zahlung eintragen</a></p>
|
||||
{% endif %}
|
||||
|
||||
{% if phase == "booking" %}
|
||||
<section>
|
||||
<h3>Getränk buchen</h3>
|
||||
<form method="post" action="{% url 'suff:staff_book' tab_user.username %}">
|
||||
{% csrf_token %}
|
||||
<label class="for-free-toggle">
|
||||
<input type="checkbox" name="booking_mode" value="for_free" />
|
||||
<span>Gratis (z.B. Artists am Spieltag)</span>
|
||||
</label>
|
||||
{% if not is_anonymous_target %}
|
||||
<label class="for-free-toggle">
|
||||
<input type="checkbox" name="booking_mode" value="cash_paid" />
|
||||
<span>Direkt bar bezahlt</span>
|
||||
</label>
|
||||
{% endif %}
|
||||
<script>
|
||||
document.querySelectorAll('input[name="booking_mode"]').forEach(function(cb) {
|
||||
cb.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
document.querySelectorAll('input[name="booking_mode"]').forEach(function(other) {
|
||||
if (other !== cb) other.checked = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% regroup drinks by alcohol_label as drink_groups %}
|
||||
{% for group in drink_groups %}
|
||||
<h4 class="drink-group-heading">{{ group.grouper }}</h4>
|
||||
<div class="drink-grid">
|
||||
{% for drink in group.list %}
|
||||
<button type="submit" name="drink_id" value="{{ drink.id }}" class="drink-btn drink-btn-{{ drink.category }}">
|
||||
<span class="drink-plus">+1</span>
|
||||
<span class="drink-name">{{ drink.name }}</span>
|
||||
<span class="drink-price">{{ drink.sale_price_per_bottle|floatformat:2 }} €</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section>
|
||||
<h3>Bisher gebucht</h3>
|
||||
{% if consumption_list %}
|
||||
{% regroup consumption_list by get_day_display as day_groups %}
|
||||
{% for group in day_groups %}
|
||||
<div class="day-group">
|
||||
<h4 class="day-heading">{{ group.grouper }}</h4>
|
||||
<ul class="history">
|
||||
{% for c in group.list %}
|
||||
<li>
|
||||
<span class="hist-when">{% if c.created_at %}{{ c.created_at|date:"H:i" }}{% else %}—{% endif %}</span>
|
||||
<span class="hist-what">{{ c.drink.name }}</span>
|
||||
<span class="hist-price">
|
||||
{% if c.for_free %}gratis{% else %}{{ c.drink.sale_price_per_bottle|floatformat:2 }} €{% endif %}
|
||||
</span>
|
||||
{% if phase == "booking" %}
|
||||
<a href="{% url 'suff:staff_delete_consumption' tab_user.username c.id %}" class="hist-delete" aria-label="Buchung löschen">🗑</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p class="empty-emoji">🍺</p>
|
||||
<p>Noch nichts gebucht.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<div class="link-row">
|
||||
{% if not is_anonymous_target %}
|
||||
<a href="{% url 'suff:staff_pin_reset' tab_user.username %}" class="link-btn link-btn-secondary">PIN setzen / zurücksetzen</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'suff:staff_index' %}" class="link-btn link-btn-secondary">Anderen Benutzer wählen</a>
|
||||
<a href="{% url 'suff:me' %}" class="link-btn link-btn-secondary">Zurück zu mir</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
+2
-2
@@ -3,14 +3,14 @@ name = "gaehsnitz"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"django==6.0.5",
|
||||
"django==6.0.6",
|
||||
"gunicorn==26.0.0",
|
||||
"psycopg[binary]==3.3.4",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"ruff==0.15.14",
|
||||
"ruff==0.15.16",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
|
||||
@@ -1,64 +1,105 @@
|
||||
# Suff – drink booking tool
|
||||
|
||||
Self-service drink tab for festival attendees. Lives at `/suff/`. Plain Django, no JS, no CSS yet.
|
||||
Self-service drink tab for festival attendees. Lives at `/suff/`. Plain Django, no JS.
|
||||
|
||||
## Auth
|
||||
|
||||
- `User.pin` field (hashed CharField) stores 3-digit PIN, separate from `password`. Lets staff keep a strong password for `/admin/` and use the same username on `/suff/` with just a PIN.
|
||||
- `PinBackend` (`gaehsnitz/auth_backends.py`) authenticates by `username` + `pin` via `user.check_pin()`. Default `ModelBackend` stays first in `AUTHENTICATION_BACKENDS` so `/admin/` keeps requiring the strong password.
|
||||
- Staff PINs cannot be self-set on `/suff/`. If a name matches an existing user with no PIN, the user lands on `suff/no_pin.html` explaining that an admin must set the PIN via the admin panel — otherwise anyone could claim a staff name and lock out the real owner.
|
||||
- Admin convenience: User change page shows PIN status ("gesetzt"/"nicht gesetzt") + "PIN setzen" link → custom admin view `<id>/pin/` with a 3-digit form, calls `user.set_pin()`.
|
||||
- `User.pin` (hashed CharField, 3 digits), separate from `password`. Strong password stays for `/admin/`.
|
||||
- `PinBackend` authenticates by `username` + `pin`. `ModelBackend` first in `AUTHENTICATION_BACKENDS` so admin still needs strong password.
|
||||
- PIN reset is **crew-only**. No self-reset, no random PINs, PINs never displayed.
|
||||
|
||||
## Name flow
|
||||
|
||||
- Username = `slugify(input)` (e.g. "Flo Hä!" → "flo-ha"). Slug shown back so user can memorize it.
|
||||
- POST name → check existence:
|
||||
- not found → set new PIN → create user → login
|
||||
- found, has PIN → enter PIN → login
|
||||
- found, no PIN → `no_pin.html` (ask admin)
|
||||
Username = `slugify(input)`. POST name:
|
||||
|
||||
- not found → `create` mode → mandatory 3-digit PIN → create user → login
|
||||
- found, has PIN → `login` mode → enter PIN
|
||||
- found, no PIN, **no activity** (no Consumption + no UserPayment) → `claim` mode → set PIN → login
|
||||
- found, no PIN, **has activity** → `no_pin.html` ("ask someone at the bar")
|
||||
|
||||
## Booking
|
||||
|
||||
- `/suff/me/` shows: greeting (slug), running paid total, full consumption history with timestamps, drink buttons.
|
||||
- Each drink = `+1` POST form. Server creates `Consumption(amount=1, day=current_weekday, for_free=False, created_at=auto)`.
|
||||
- No undo, no delete, no edit. No special bartender role.
|
||||
- History sorted newest-first, `created_at` shown as `Do 18:42` etc.
|
||||
- `/suff/me/` shows: greeting, total/paid/open balance, drink grid, day-grouped history.
|
||||
- Single form wraps two radio buttons (`booking_mode`) + all drink buttons (`name="drink_id"`).
|
||||
- `booking_mode` values: `normal` (default, no radio selected), `for_free`, `cash_paid`.
|
||||
- `+1` POST creates `Consumption(amount=1, day=current_weekday, for_free=...)`.
|
||||
- `cash_paid` booking auto-creates matching `UserPayment(method=cash)` — same as anonymous walk-ins.
|
||||
- Trash icon per row → `confirm_delete.html` → POST deletes own consumption (booking phase only).
|
||||
- History grouped by festival day, newest-first per day.
|
||||
|
||||
## Payments
|
||||
|
||||
- `/suff/pay/` — user enters amount + method (cash/paypal/bank/other) + optional note. Creates `UserPayment`. Pre-fills with current `open_balance`.
|
||||
- Method choices: `UserPayment.Method`.
|
||||
- Open balance = sum(Consumption.price where !for_free) − sum(UserPayment.amount).
|
||||
- Balance panel shows settled state (green "Bezahlt ✓" + breakdown) when `open_balance <= 0`; amber with "Offen X €" otherwise. Applied on `/suff/me/`, `/suff/pay/`, `/suff/staff/u/<name>/`, `/suff/staff/u/<name>/pay/`.
|
||||
|
||||
## Crew (`is_staff`)
|
||||
|
||||
Separate page tree under `/suff/staff/`:
|
||||
|
||||
- `/suff/staff/` — alphabetical user list, anonymous gast on top, "Neuen Benutzer anlegen" link.
|
||||
- `/suff/staff/new/` — register user. Name required, PIN optional 3 digits.
|
||||
- `/suff/staff/u/<name>/` — book/pay/delete for that user. Mirrors `me.html`. "Zahlung eintragen" link always visible.
|
||||
- `/suff/staff/u/<name>/pin/` — overwrite PIN (3 digits required, no clear).
|
||||
- `/suff/staff/u/<name>/pay/` — record payment for that user. Amount pre-fills with `max(open_balance, 0)`.
|
||||
- `/suff/staff/u/<name>/book/<id>/delete/` — delete consumption (and matching auto-payment if anon).
|
||||
|
||||
Staff booking form has same `booking_mode` radios (normal / for_free / cash_paid) as user view.
|
||||
|
||||
Target username highlighted via `.staff-target` (cyan pill) on every crew page.
|
||||
|
||||
## Anonymous walk-ins
|
||||
|
||||
- Seeded user `anonym` (migration 0009). No PIN, never logs in.
|
||||
- Crew books for anonymous via staff_user page → drink booking auto-creates matching `UserPayment(method=cash, note="Auto: <drink>")` so balance always 0.
|
||||
- Deleting an anonymous consumption removes one matching auto-payment.
|
||||
- Anonymous has no pay page (404). PayPal walk-ins → register a real user instead.
|
||||
|
||||
## Time gating (Berlin tz)
|
||||
|
||||
- Phases: `before` / `booking` / `readonly` / `closed`.
|
||||
- Booking allowed Thu 2026-06-11 00:00 → Sun 2026-06-14 23:59.
|
||||
- Read-only until Sun 2026-06-21 23:59.
|
||||
- After: every `/suff/` URL returns 404.
|
||||
- Local dev: `settings.PRODUCTION=False` forces `booking` phase always.
|
||||
- Phases: `before` / `booking` / `closed`.
|
||||
- Test window: 2026-05-15 → 2026-05-31. Original festival: 2026-06-11 → 2026-06-14.
|
||||
- `closed` shows static page; outside booking, all action endpoints redirect or 404.
|
||||
- `settings.PRODUCTION=False` forces `booking`.
|
||||
|
||||
## Dashboard
|
||||
|
||||
- `/suff/dashboard/` (staff only). Donations vs. expenses with progress bar, drink inventory rows, refinance %, per-user open balances, top spender, top drink, busiest day, top drinker per day.
|
||||
- Finance section includes "Kasse (bar)" — sum of all `UserPayment(method=cash)` — for cross-checking real cash in the box.
|
||||
|
||||
## Drink categories
|
||||
|
||||
- `Drink.category`: beer / alc_free_beer / radler / alc_free_radler / soft / water.
|
||||
- Buttons gradient-colored per category. Sorted by category in grid.
|
||||
|
||||
## Files
|
||||
|
||||
- `gaehsnitz/auth_backends.py` — `PinBackend`
|
||||
- `gaehsnitz/suff.py` — views + phase logic
|
||||
- `gaehsnitz/suff.py` — all suff views + phase + crew helpers
|
||||
- `gaehsnitz/suff_urls.py` — routes
|
||||
- `gaehsnitz/admin.py` — `SetPinForm` + `set_pin_view`
|
||||
- `gaehsnitz/templates/suff/{base,name,pin,no_pin,me}.html`
|
||||
- `gaehsnitz/templates/admin/gaehsnitz/user/set_pin.html`
|
||||
- `gaehsnitz/templates/suff/{base,name,pin,no_pin,me,pay,dashboard,closed,confirm_delete,staff_index,staff_user,staff_pay,staff_register,staff_pin_reset,staff_confirm_delete}.html`
|
||||
- `gaehsnitz/static/suff/{style.css,favicon.svg}`
|
||||
- `gaehsnitz/migrations/0003_consumption_created_at_user_pin.py`
|
||||
- Edits: `gaehsnitzproject/settings.py`, `gaehsnitzproject/urls.py`, `gaehsnitz/models.py`
|
||||
- `gaehsnitz/migrations/0009_anonymous_user.py` — seeds `anonym`
|
||||
|
||||
## Frontend
|
||||
|
||||
Mobile-first styled. Dark theme matching GOA palette (`#161616` bg, `#EE9933`/`#FFCC77` amber accents, `#885522` brown borders). Standalone microsite — no nav to main GOA page.
|
||||
Mobile-first dark theme. `#161616` bg, `#EE9933`/`#FFCC77` amber, `#885522` brown borders, `#66ddee` cyan for crew target. Drink buttons gradient-colored per category. Toast banner for booking confirmation. `:active` scale feedback. SVG favicon.
|
||||
|
||||
- Landing/login: GOA subhead + big "Suff" wordmark, `name` and `pin` forms with stacked label/input, large tap targets
|
||||
- `me` page: 2-col drink button grid (4:3 aspect), stacked +1 / name / price; bordered total box; day-grouped history with zebra rows; emoji empty-state
|
||||
- Booking confirmation: amber toast, 5s display, then 800ms collapse animation (pure CSS, no JS)
|
||||
- `:active` scale(0.96) feedback on buttons + link-buttons
|
||||
- `no_pin.html` link-buttons styled (primary + secondary)
|
||||
- SVG favicon (🍺)
|
||||
## Open ideas / next session
|
||||
|
||||
## Further ideas
|
||||
### Pay-on-the-spot / quick-pay-cash
|
||||
|
||||
Single button on `me` (and crew_user) page: "Offenen Betrag bar bezahlen" → creates `UserPayment(method=cash, amount=open_balance)`. Lets bar crew clear tab in one tap when guest pays cash directly. (Skipped for now, keep in mind.)
|
||||
|
||||
### Prepay vs. pay-at-end
|
||||
|
||||
Currently a single open balance. Could surface "Prepay 50 €" as a flow vs. "Pay at the end" — same data model, different framing. Maybe a "Vorkasse" preset on pay page.
|
||||
|
||||
### Misc
|
||||
|
||||
- Color-code drink buttons (per-drink accent border or bg — Bier amber, Wasser blue, etc.) for fast visual recognition in dim light
|
||||
- Drink icons/emoji per type
|
||||
- Style phase pages (`before` / `closed` if non-404)
|
||||
- PWA manifest for add-to-homescreen
|
||||
- Donation/free-drink flow if needed (currently admin-only via `for_free`)
|
||||
- Drink icons/emoji per type
|
||||
- Style phase pages (`before` / `closed`)
|
||||
- Per-user QR for fast crew lookup at the bar
|
||||
|
||||
Reference in New Issue
Block a user