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}})

+ +

Tilbage til produktmenu

+ +

Billetter relateret til dig

+ + + + + + + + + + {% autoescape off %} + {% for ticket_purchase in purchase_page %} + + + + + + + + {% empty %} + + + + {% endfor %} + {% endautoescape %} +
EventBillet NavnBeskrivelsePå Stand ByRefunderet
{{ 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 }}
Ingen billetter releateret til dig.
+ + + + + +{% 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()