diff --git a/db.sqlite3.bak b/db.sqlite3.bak
new file mode 100644
index 00000000..5d91ee38
Binary files /dev/null and b/db.sqlite3.bak differ
diff --git a/stregsystem/admin.py b/stregsystem/admin.py
index 2123ef70..f4bafdfb 100644
--- a/stregsystem/admin.py
+++ b/stregsystem/admin.py
@@ -18,6 +18,10 @@
PendingSignup,
Theme,
ProductNote,
+ Event,
+ EventInstance,
+ Ticket,
+ TicketRecord,
)
from stregsystem.templatetags.stregsystem_extras import money
from stregsystem.utils import (
@@ -376,6 +380,22 @@ class ProductNoteAdmin(admin.ModelAdmin):
actions = [toggle_active_selected_products]
+class EventAdmin(admin.ModelAdmin):
+ pass
+
+
+class EventInstanceAdmin(admin.ModelAdmin):
+ pass
+
+
+class TicketAdmin(admin.ModelAdmin):
+ pass
+
+
+class TicketRecordAdmin(admin.ModelAdmin):
+ pass
+
+
admin.site.register(LogEntry, LogEntryAdmin)
admin.site.register(Sale, SaleAdmin)
admin.site.register(Member, MemberAdmin)
@@ -389,3 +409,7 @@ class ProductNoteAdmin(admin.ModelAdmin):
admin.site.register(PendingSignup)
admin.site.register(Theme, ThemeAdmin)
admin.site.register(ProductNote, ProductNoteAdmin)
+admin.site.register(Event, EventAdmin)
+admin.site.register(EventInstance, EventInstanceAdmin)
+admin.site.register(Ticket, TicketAdmin)
+admin.site.register(TicketRecord, TicketRecordAdmin)
diff --git a/stregsystem/migrations/0023_event_eventinstance_ticket_ticketrecord.py b/stregsystem/migrations/0023_event_eventinstance_ticket_ticketrecord.py
new file mode 100644
index 00000000..341c0ec8
--- /dev/null
+++ b/stregsystem/migrations/0023_event_eventinstance_ticket_ticketrecord.py
@@ -0,0 +1,168 @@
+# Generated by Django 4.1.13 on 2026-01-10 23:03
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("stregsystem", "0022_productnote"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Event",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("name", models.CharField(max_length=50)),
+ ("description", models.TextField()),
+ (
+ "image",
+ models.ImageField(blank=True, null=True, upload_to="event_images/"),
+ ),
+ ],
+ ),
+ migrations.CreateModel(
+ name="EventInstance",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("name_overwrite", models.CharField(blank=True, max_length=50)),
+ ("description_overwrite", models.TextField(blank=True)),
+ (
+ "image_overwrite",
+ models.ImageField(
+ blank=True, null=True, upload_to="event_instance_images/"
+ ),
+ ),
+ ("capacity", models.IntegerField()),
+ ("start_time", models.DateTimeField()),
+ ("end_time", models.DateTimeField()),
+ ("location", models.CharField(max_length=100)),
+ (
+ "event",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="instances",
+ to="stregsystem.event",
+ ),
+ ),
+ ],
+ ),
+ migrations.CreateModel(
+ name="Ticket",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("name", models.CharField(max_length=50)),
+ ("description", models.TextField()),
+ ("quantity", models.IntegerField()),
+ (
+ "event_instance",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="tickets",
+ to="stregsystem.eventinstance",
+ ),
+ ),
+ (
+ "product",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="tickets",
+ to="stregsystem.product",
+ ),
+ ),
+ ],
+ ),
+ migrations.CreateModel(
+ name="TicketRecord",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "status",
+ models.CharField(
+ choices=[
+ ("ISSUED", "Issued"),
+ ("ADMIN_ISSUED", "Issued by Admin"),
+ ("STAND_BY", "On Stand-by"),
+ ("REFUNDED", "Refunded"),
+ ],
+ default="ISSUED",
+ max_length=20,
+ ),
+ ),
+ ("attended", models.BooleanField(blank=True, null=True)),
+ ("refunded_at", models.DateTimeField(blank=True)),
+ (
+ "issued_by_admin",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="admin_issued_tickets",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "refunded_by_admin",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="admin_refunded_tickets",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "sale",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="ticket_record",
+ to="stregsystem.sale",
+ ),
+ ),
+ (
+ "ticket",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="purchases",
+ to="stregsystem.ticket",
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/stregsystem/models.py b/stregsystem/models.py
index 42275ee4..be9951af 100644
--- a/stregsystem/models.py
+++ b/stregsystem/models.py
@@ -1,8 +1,10 @@
+from __future__ import annotations
import datetime
import urllib.parse
from abc import abstractmethod
from collections import Counter
from email.utils import parseaddr
+from typing import Optional
from django.contrib.admin.models import LogEntry, ADDITION, CHANGE
from django.contrib.auth.models import User
@@ -21,6 +23,7 @@
make_processed_mobilepayment_query,
make_unprocessed_member_filled_mobilepayment_query,
PaymentToolException,
+ get_bool_pretty,
)
@@ -707,6 +710,12 @@ def __str__(self):
def save(self, *args, **kwargs):
if self.id:
raise RuntimeError("Updates of sales are not allowed")
+
+ is_ticket, ticket = Ticket.is_product_a_ticket(self.product)
+ if is_ticket:
+ # Create a ticket record for this sale
+ TicketRecord.objects.create(ticket=ticket, sale=self)
+
super(Sale, self).save(*args, **kwargs)
def delete(self, *args, **kwargs):
@@ -864,3 +873,79 @@ class Meta:
def __str__(self):
return self.name
+
+
+class Event(models.Model):
+ name = models.CharField(max_length=50)
+ description = models.TextField()
+ image = models.ImageField(upload_to="event_images/", blank=True, null=True)
+
+ def __str__(self):
+ return self.name
+
+
+class EventInstance(models.Model):
+ event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name="instances", null=False, blank=False)
+ name_overwrite = models.CharField(max_length=50, blank=True)
+ description_overwrite = models.TextField(blank=True)
+ image_overwrite = models.ImageField(upload_to="event_instance_images/", blank=True, null=True)
+ capacity = models.IntegerField(null=False, blank=False)
+ start_time = models.DateTimeField(null=False, blank=False)
+ end_time = models.DateTimeField(null=False, blank=False)
+ location = models.CharField(max_length=100, null=False, blank=False)
+
+ def __str__(self):
+ return f"{self.name_overwrite} ({self.start_time} - {self.end_time})"
+
+ def from_start_to_end_time_str(self):
+ return f"Fra {self.start_time.strftime('%d/%m/%Y %H:%M')} - til {self.end_time.strftime('%d/%m/%Y %H:%M')}"
+
+
+class Ticket(models.Model):
+ event_instance = models.ForeignKey(EventInstance, on_delete=models.CASCADE, related_name="tickets")
+ name = models.CharField(max_length=50)
+ description = models.TextField()
+ quantity = models.IntegerField()
+ product = models.OneToOneField(Product, on_delete=models.CASCADE, related_name="tickets")
+
+ @staticmethod
+ def is_product_a_ticket(product: Product) -> tuple[bool, Optional[Ticket]]:
+ ticket = Ticket.objects.filter(product=product)
+ if ticket.exists():
+ return True, ticket.first()
+ else:
+ return False, None
+
+ def __str__(self):
+ return f"{self.name} for {self.event_instance.name_overwrite}"
+
+
+class TicketPurchaseStatus(models.TextChoices):
+ ISSUED = "ISSUED", "Issued"
+ ADMIN_ISSUED = "ADMIN_ISSUED", "Issued by Admin"
+ STAND_BY = "STAND_BY", "On Stand-by"
+ REFUNDED = "REFUNDED", "Refunded"
+
+
+class TicketRecord(models.Model):
+ ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="purchases")
+ sale = models.OneToOneField(Sale, on_delete=models.CASCADE, related_name="ticket_record")
+ status = models.CharField(max_length=20, choices=TicketPurchaseStatus.choices, default=TicketPurchaseStatus.ISSUED)
+
+ attended = models.BooleanField(null=True, blank=True)
+
+ refunded_at = models.DateTimeField(blank=True)
+ refunded_by_admin = models.ForeignKey(
+ User, on_delete=models.CASCADE, related_name="admin_refunded_tickets", null=True, blank=True
+ )
+
+ issued_by_admin = models.ForeignKey(
+ User, on_delete=models.CASCADE, related_name="admin_issued_tickets", null=True, blank=True
+ )
+
+ @staticmethod
+ def get_member_purchases(member: Member):
+ return TicketRecord.objects.filter(sale__member=member)
+
+ def __str__(self):
+ return f"{self.sale.member.username}'s billet: {self.ticket.name}, Status: {self.status}"
diff --git a/stregsystem/templates/stregsystem/menu.html b/stregsystem/templates/stregsystem/menu.html
index df93ca56..07c3a0b8 100644
--- a/stregsystem/templates/stregsystem/menu.html
+++ b/stregsystem/templates/stregsystem/menu.html
@@ -35,6 +35,8 @@
Du
Bruger Info
Indsæt penge
Rangliste
+ Billetter
+
{% comment %}
Fortryd køb
{% endcomment %}
diff --git a/stregsystem/templates/stregsystem/menu_user_tickets.html b/stregsystem/templates/stregsystem/menu_user_tickets.html
new file mode 100644
index 00000000..87b5fb3b
--- /dev/null
+++ b/stregsystem/templates/stregsystem/menu_user_tickets.html
@@ -0,0 +1,63 @@
+{% extends "stregsystem/base.html" %}
+
+{% load stregsystem_extras %}
+
+{% block title %}Treoens stregsystem : User Tickets {% endblock %}
+
+{% block content %}
+
+
+ {{member.firstname}} {{member.lastname}} ({{member.email}})
+
+
+
+ Billetter relateret til dig
+
+
+
+ | Event |
+ Billet Navn |
+ Beskrivelse |
+ På Stand By |
+ Refunderet |
+
+ {% autoescape off %}
+ {% for ticket_purchase in purchase_page %}
+
+ | {{ ticket_purchase.ticket.event_instance.name }} |
+ {{ ticket_purchase.ticket.name }} |
+ {{ ticket_purchase.ticket.description }} |
+ {{ ticket_purchase.get_stand_by_pretty }} |
+ {{ ticket_purchase.get_refunded_pretty }} |
+
+ {% empty %}
+
+ | Ingen billetter releateret til dig. |
+
+ {% endfor %}
+ {% endautoescape %}
+
+
+
+
+
+
+{% endblock %}
+
diff --git a/stregsystem/urls.py b/stregsystem/urls.py
index 9fbbe9bb..035a3fae 100644
--- a/stregsystem/urls.py
+++ b/stregsystem/urls.py
@@ -32,6 +32,7 @@
re_path(r'^(?P\d+)/user/(?P\d+)/$', views.menu_userinfo, name="userinfo"),
re_path(r'^(?P\d+)/user/(?P\d+)/pay$', views.menu_userpay, name="userpay"),
re_path(r'^(?P\d+)/user/(?P\d+)/rank$', views.menu_userrank, name="userrank"),
+ re_path(r'^(?P\d+)/user/(?P\d+)/tickets$', views.menu_user_tickets, name="user_tickets"),
re_path(r'^(?P\d+)/send_csv_mail/(?P\d+)/$', views.send_userdata, name="send_userdata"),
re_path(r'^api/member/payment/qr$', views.get_payment_qr, name="api_payment_qr"),
re_path(r'^api/member/active$', views.get_member_active, name="api_member_active"),
diff --git a/stregsystem/utils.py b/stregsystem/utils.py
index baafca0e..0dd96596 100644
--- a/stregsystem/utils.py
+++ b/stregsystem/utils.py
@@ -210,3 +210,7 @@ def rows_to_csv(rows) -> str:
# Converting elements in rows to strings to ensure it can be written to the file object
csv.writer(file).writerows([[str(item) for item in row] for row in rows])
return file.data
+
+
+def get_bool_pretty(value: bool) -> str:
+ return "Ja" if value else "Nej"
diff --git a/stregsystem/views.py b/stregsystem/views.py
index ebac6898..5417c7a6 100644
--- a/stregsystem/views.py
+++ b/stregsystem/views.py
@@ -44,6 +44,7 @@
NamedProduct,
ApprovalModel,
ProductNote,
+ TicketRecord,
)
from stregsystem.templatetags.stregsystem_extras import money
from stregsystem.utils import (
@@ -425,6 +426,18 @@ def sale_count_for_product(category_ids, from_d, to_d):
return render(request, 'stregsystem/menu_userrank.html', locals())
+def menu_user_tickets(request, room_id, member_id):
+ room = Room.objects.get(pk=room_id)
+ member = Member.objects.get(pk=member_id, active=True)
+
+ all_ticket_purchases_current_member = TicketRecord.get_member_purchases(member).order_by("sale__timestamp")
+ purchase_paginator = Paginator(all_ticket_purchases_current_member, 5)
+ purchase_page_number = request.GET.get('purchase_table_index', 1)
+ purchase_page = purchase_paginator.get_page(purchase_page_number)
+
+ return render(request, "stregsystem/menu_user_tickets.html", locals())
+
+
def menu_sale(request, room_id, member_id, product_id=None):
room = Room.objects.get(pk=room_id)
news = __get_news()