diff --git a/src/backoffice/static/audio/error.mp3 b/src/backoffice/static/audio/error.mp3
new file mode 100644
index 000000000..b5f71f03c
Binary files /dev/null and b/src/backoffice/static/audio/error.mp3 differ
diff --git a/src/backoffice/static/audio/success.mp3 b/src/backoffice/static/audio/success.mp3
new file mode 100644
index 000000000..cfc69383a
Binary files /dev/null and b/src/backoffice/static/audio/success.mp3 differ
diff --git a/src/backoffice/static/audio/tada.mp3 b/src/backoffice/static/audio/tada.mp3
new file mode 100644
index 000000000..70a9d27a3
Binary files /dev/null and b/src/backoffice/static/audio/tada.mp3 differ
diff --git a/src/backoffice/static/js/inventory_scan.js b/src/backoffice/static/js/inventory_scan.js
new file mode 100644
index 000000000..b97436373
--- /dev/null
+++ b/src/backoffice/static/js/inventory_scan.js
@@ -0,0 +1,56 @@
+document.addEventListener("DOMContentLoaded", () => {
+ "use strict";
+
+ const search_form = document.getElementById("search_form");
+ const token_input = document.getElementById("token_input");
+
+ search_form.onsubmit = submit;
+
+ if (document.getElementById("opr_id") && document.getElementById("opr_id")) {
+ new Audio('/static/audio/success.mp3').play()
+ } else if (document.getElementById("opr_id")) {
+ new Audio('/static/audio/tada.mp3').play();
+ } else if (!document.getElementById("scan_again").hidden) {
+ new Audio('/static/audio/error.mp3').play()
+ }
+
+ function submit(e) {
+ var product_quantity = document.getElementById("product_quantity");
+ e.preventDefault();
+ if (token_input.value === "#clear") {
+ window.location.replace(window.location.pathname);
+ } else if (product_quantity && token_input.value.split("/").length == 4) {
+ var quantity = Number(product_quantity.innerHTML)
+ var opr = document.getElementById("opr_id").innerHTML
+ var scanned_opr = token_input.value.split("/")[3];
+ token_input.value = "";
+ if (opr === scanned_opr) {
+ if (quantity > 1) {
+ product_quantity.innerHTML = quantity - 1;
+ new Audio('/static/audio/success.mp3').play()
+ } else {
+ if (quantity === 1) {
+ product_quantity.innerHTML = "0";
+ document.getElementById("checkin_qr").removeAttribute("hidden");;
+ new Audio('/static/audio/tada.mp3').play()
+ } else {
+ new Audio('/static/audio/error.mp3').play()
+ token_input.value = "TO MANY ITEMS SCANNED!!!!!";
+ }
+ }
+ } else {
+ new Audio('/static/audio/error.mp3').play()
+ token_input.value = "WRONG ITEM!!!!!";
+ }
+ } else {
+ search_form.submit();
+ }
+ }
+
+ document.addEventListener("keydown", event => {
+ if (event.key === "#") {
+ token_input.value = "";
+ token_input.focus();
+ }
+ });
+});
diff --git a/src/backoffice/static/js/ticket_scan.js b/src/backoffice/static/js/ticket_scan.js
index 05163e1d7..7da207316 100644
--- a/src/backoffice/static/js/ticket_scan.js
+++ b/src/backoffice/static/js/ticket_scan.js
@@ -24,6 +24,26 @@ document.addEventListener("DOMContentLoaded", () => {
check_in_form.submit();
} else if (ticket_token_input.value.length === 65) {
search_form.submit();
+ } else if (ticket_token_input.value.startsWith("#bornhack://opr/")) {
+ var oprelement = document.getElementById("opr");
+ if (oprelement) {
+ var opr = oprelement.innerHTML
+ var quantity = document.getElementById("product_quantity");
+ var opr_scanned = ticket_token_input.value.split("/")[3]
+ if (opr == opr_scanned) {
+ if (quantity && quantity.innerHTML=="1") {
+ quantity.innerHTML = Number(quantity.innerHTML) - 1;
+ } else {
+ document.getElementById("checkin_qr").removeAttribute("hidden");
+ }
+ ticket_token_input.value = "";
+ } else {
+ ticket_token_input.value = "WRONG ITEM";
+ }
+ } else {
+ scan_again.removeAttribute("hidden");
+ ticket_token_input.value = ""
+ }
} else {
scan_again.removeAttribute("hidden");
}
diff --git a/src/backoffice/templates/includes/index_info.html b/src/backoffice/templates/includes/index_info.html
index 304a2eea6..0f1f9df8e 100644
--- a/src/backoffice/templates/includes/index_info.html
+++ b/src/backoffice/templates/includes/index_info.html
@@ -51,4 +51,15 @@
-
- {% qr_code "clear" %}
-
+
+
+ {% qr_code "clear" %}
+
- {% if not ticket.used_at %}
-
- {% qr_code "checkin" %}
-
- {% endif %}
+ {% if not ticket.used_at %}
+
+ {% qr_code "checkin" %}
+
+ {% endif %}
- {% if ticket.ticket_type.includes_badge and not ticket.badge_handed_out %}
-
- {% qr_code "handoutbadge" %}
-
- {% endif %}
-
+ {% if ticket.ticket_type.includes_badge and not ticket.badge_handed_out %}
+
+ {% qr_code "handoutbadge" %}
+
+ {% endif %}
+
{% endif %}
This table shows all different merchandise that needs to be ordered
+
diff --git a/src/backoffice/templates/orders_merchandise.html b/src/backoffice/templates/orders_merchandise.html
index 2f29fc7fc..99314e738 100644
--- a/src/backoffice/templates/orders_merchandise.html
+++ b/src/backoffice/templates/orders_merchandise.html
@@ -27,6 +27,8 @@
Merchandise Orders
Product |
Quantity |
Paid |
+
Label |
+
Ready |
Ticket Generated |
@@ -39,6 +41,8 @@
Merchandise Orders
{{ productrel.product.name }} |
{{ productrel.non_refunded_quantity }} |
{{ productrel.order.paid|yesno }} |
+
{{ productrel.label_printed|yesno }} |
+
{{ productrel.ready_for_pickup|yesno }} |
{{ productrel.order.ticket_generated|yesno }} |
{% endfor %}
diff --git a/src/backoffice/templates/orders_merchandise_labels.html b/src/backoffice/templates/orders_merchandise_labels.html
new file mode 100644
index 000000000..71a9cc195
--- /dev/null
+++ b/src/backoffice/templates/orders_merchandise_labels.html
@@ -0,0 +1,65 @@
+{% extends 'base.html' %}
+{% load commonmark %}
+{% load static %}
+{% load imageutils %}
+
+{% block title %}
+ Merchandise Orders | {{ block.super }}
+{% endblock %}
+
+{% block content %}
+
+
Print labels
+
+ Use this view to print labels for merchandise orders.
+
+ This table shows all OrderProductRelations which are Merchandise (not including cancelled orders or refunded OrderProductRelations). The table is initially sorted by order ID and product name but the sorting can be changed by clicking the column headlines (if javascript is enabled).
+
+
+
+
+ NOTE: Clicking print will set label_printed=True on the selected OPRs
+
+
+
+{% endblock content %}
diff --git a/src/backoffice/templates/orders_merchandise_labels.zpl b/src/backoffice/templates/orders_merchandise_labels.zpl
new file mode 100644
index 000000000..e376f5292
--- /dev/null
+++ b/src/backoffice/templates/orders_merchandise_labels.zpl
@@ -0,0 +1,32 @@
+{% for productrel in orderproductrelation_list %}
+{% with ''|ljust:productrel.non_refunded_quantity as range %}
+{% for i in range %}
+^XA
+^PW600
+^LL200
+
+^FXQR Code
+^FO20,20
+^BQN,2,6,N,7
+^FDLA,bornhack://opr/{{ productrel.id }}^FS
+
+^FXOPR
+^FO180,30
+^A0N,90,90
+^FD{{ productrel.id }}^FS
+
+^FXProduct name
+^FO190,110
+^A0N,25,25
+^FD{{ productrel.product.name }}^FS
+
+{% if productrel.non_refunded_quantity > 1 %}
+^FXProduct x/z
+^FO500,160
+^A0N,25,25
+^FD{{ forloop.counter }}/{{ productrel.non_refunded_quantity }}^FS
+{% endif %}
+^XZ
+{% endfor %}
+{% endwith %}
+{% endfor %}
diff --git a/src/backoffice/urls.py b/src/backoffice/urls.py
index 5bb80a822..edab35ff1 100644
--- a/src/backoffice/urls.py
+++ b/src/backoffice/urls.py
@@ -107,6 +107,7 @@
from .views import MapUserLocationTypeDeleteView
from .views import MapUserLocationTypeListView
from .views import MapUserLocationTypeUpdateView
+from .views import MerchandiseOrdersLabelsView
from .views import MerchandiseOrdersView
from .views import MerchandiseToOrderView
from .views import MobilePayCSVImportView
@@ -151,6 +152,8 @@
from .views import RevenueDetailView
from .views import RevenueListView
from .views import RevenueUpdateView
+from .views import ScanInventoryIndexView
+from .views import ScanInventoryView
from .views import ScanTicketsPosSelectView
from .views import ScanTicketsView
from .views import ShopTicketOverview
@@ -437,6 +440,23 @@
],
),
),
+ path(
+ "scan_inventory/",
+ include(
+ [
+ path(
+ "",
+ ScanInventoryIndexView.as_view(),
+ name="scan_inventory_index",
+ ),
+ path(
+ "
/",
+ ScanInventoryView.as_view(),
+ name="scan_inventory",
+ ),
+ ],
+ ),
+ ),
path(
"orders/",
include(
@@ -571,6 +591,11 @@
MerchandiseOrdersView.as_view(),
name="merchandise_orders",
),
+ path(
+ "merchandise_orders_labels/",
+ MerchandiseOrdersLabelsView.as_view(),
+ name="merchandise_orders_labels",
+ ),
path(
"merchandise_to_order/",
MerchandiseToOrderView.as_view(),
diff --git a/src/backoffice/views/infodesk.py b/src/backoffice/views/infodesk.py
index a6d80ea22..02babef18 100644
--- a/src/backoffice/views/infodesk.py
+++ b/src/backoffice/views/infodesk.py
@@ -67,10 +67,13 @@ class ScanTicketsPosSelectView(
CampViewMixin,
ListView,
):
+ """Class for the ticket scan index view."""
+
model = Pos
template_name = "scan_ticket_pos_select.html"
def dispatch(self, *args, **kwargs):
+ """Method for preventing changes to read-only camps."""
if self.camp.read_only:
return HttpResponseForbidden("Camp is read-only")
return super().dispatch(*args, **kwargs)
@@ -89,6 +92,7 @@ class ScanTicketsView(
order_search = False
def dispatch(self, *args, **kwargs):
+ """Method for preventing changes to read-only camps."""
if self.camp.read_only:
return HttpResponseForbidden("Camp is read-only")
return super().dispatch(*args, **kwargs)
@@ -98,6 +102,7 @@ def setup(self, *args, **kwargs) -> None:
self.pos = Pos.objects.get(team__camp=self.camp, slug=kwargs["pos_slug"])
def get_context_data(self, **kwargs):
+ """Method for loading the page data."""
context = super().get_context_data(**kwargs)
context["pos"] = self.pos
@@ -179,10 +184,103 @@ class ShopTicketOverview(
context_object_name = "shop_tickets"
def get_context_data(self, *, object_list=None, **kwargs):
+ """Method for loading the page data."""
kwargs["ticket_types"] = TicketType.objects.filter(camp=self.camp)
return super().get_context_data(object_list=object_list, **kwargs)
+class ScanInventoryView(
+ LoginRequiredMixin,
+ InfoTeamPermissionMixin,
+ CampViewMixin,
+ TemplateView,
+):
+ """Class for checking in inventory."""
+
+ template_name = "info_desk/inventory.html"
+
+ opr: None | OrderProductRelation = None
+
+ def dispatch(self, *args, **kwargs):
+ """Method for preventing changes to read-only camps."""
+ if self.camp.read_only:
+ return HttpResponseForbidden("Camp is read-only")
+ return super().dispatch(*args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ """Method for loading the page data."""
+ context = super().get_context_data(**kwargs)
+
+ context["pos"] = Pos.objects.get(team__camp=self.camp, slug=kwargs["pos_slug"])
+
+ if "token" in self.request.POST:
+ context["failed"] = False
+ # Slice to get rid of the first character which is a '#'
+ try:
+ search_token = self.request.POST.get("token")[1:].split("/")
+ token_type = search_token[2]
+ token_id = int(search_token[3])
+ except IndexError:
+ self.opr = None
+ context["failed"] = True
+ return context
+ token_action = None
+ if len(search_token) == 5:
+ token_action = search_token[4]
+ if token_type == "opr" and not token_action:
+ try:
+ self.opr = OrderProductRelation.objects.get(id=token_id)
+ except OrderProductRelation.DoesNotExist:
+ self.opr = None
+ context["failed"] = True
+ context["opr"] = self.opr
+
+ return context
+
+ def post(self, request, **kwargs):
+ """Method for saving the checkin."""
+ try:
+ search_token = self.request.POST.get("token")[1:].split("/")
+ token_type = search_token[2]
+ token_id = int(search_token[3])
+ except IndexError:
+ messages.error(
+ self.request,
+ "Not found.",
+ )
+ return super().get(request, **kwargs)
+ token_action = None
+ if len(search_token) == 5:
+ token_action = search_token[4]
+ if token_type == "opr" and token_action == "checkin":
+ self.opr = OrderProductRelation.objects.get(id=token_id)
+ self.opr.ready_for_pickup = True
+ self.opr.save()
+ messages.success(
+ self.request,
+ "Item checked in.",
+ )
+ return super().get(request, **kwargs)
+
+
+class ScanInventoryIndexView(
+ LoginRequiredMixin,
+ InfoTeamPermissionMixin,
+ CampViewMixin,
+ ListView,
+):
+ """View for selecting the POS location for check-in of items."""
+
+ model = Pos
+ template_name = "info_desk/inventory_index.html"
+
+ def dispatch(self, *args, **kwargs):
+ """Method for preventing changes on read-only camp."""
+ if self.camp.read_only:
+ return HttpResponseForbidden("Camp is read-only")
+ return super().dispatch(*args, **kwargs)
+
+
################################
# ORDERS & INVOICES
diff --git a/src/backoffice/views/orga.py b/src/backoffice/views/orga.py
index 8e4b7f6b5..ade297a98 100644
--- a/src/backoffice/views/orga.py
+++ b/src/backoffice/views/orga.py
@@ -1,14 +1,18 @@
from __future__ import annotations
import logging
+from datetime import datetime
+from typing import TYPE_CHECKING
from django.contrib import messages
from django.forms import modelformset_factory
from django.shortcuts import redirect
+from django.shortcuts import render
from django.urls import reverse
from django.utils import timezone
from django.views.generic import ListView
from django.views.generic import TemplateView
+from django.views.generic import View
from django.views.generic.edit import FormView
from backoffice.mixins import OrgaTeamPermissionMixin
@@ -20,14 +24,22 @@
from tickets.models import TicketType
from utils.models import OutgoingEmail
+if TYPE_CHECKING:
+ from django.db.models import QuerySet
+ from django.http import HttpRequest
+ from django.http import HttpResponse
+
logger = logging.getLogger(f"bornhack.{__name__}")
class ApproveNamesView(CampViewMixin, OrgaTeamPermissionMixin, ListView):
+ """View for showing the approved public credit names."""
+
template_name = "approve_public_credit_names.html"
context_object_name = "profiles"
- def get_queryset(self, **kwargs):
+ def get_queryset(self, **kwargs) -> QuerySet:
+ """Method for showing the approved public credit names."""
return Profile.objects.filter(public_credit_name_approved=False).exclude(
public_credit_name="",
)
@@ -38,9 +50,12 @@ def get_queryset(self, **kwargs):
class MerchandiseOrdersView(CampViewMixin, OrgaTeamPermissionMixin, ListView):
+ """View for listing all merchandise orders."""
+
template_name = "orders_merchandise.html"
- def get_queryset(self, **kwargs):
+ def get_queryset(self, **kwargs) -> QuerySet:
+ """Method for listing all merchandise orders."""
return (
OrderProductRelation.objects.not_fully_refunded()
.not_cancelled()
@@ -52,12 +67,57 @@ def get_queryset(self, **kwargs):
)
+class MerchandiseOrdersLabelsView(CampViewMixin, OrgaTeamPermissionMixin, View):
+ """Class for printing merch labels."""
+
+ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ """Method for selecting labels to be printed."""
+ template_name = "orders_merchandise_labels.html"
+ oprs = (
+ OrderProductRelation.objects.not_fully_refunded()
+ .not_cancelled()
+ .filter(
+ product__category__name="Merchandise",
+ product__name__startswith=self.camp.title,
+ order__paid=True,
+ label_printed=False,
+ )
+ .order_by("product__name", "order")
+ )
+ return render(request, template_name, {"orderproductrelation_list": oprs})
+
+ def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ """Method for returning the labels in zpl format."""
+ ids: list[int] = []
+ for key, value in request.POST.dict().items():
+ if "opr" in key and value == "on":
+ item_id = int(key.split("_")[1])
+ ids.append(item_id)
+ template_name = "orders_merchandise_labels.zpl"
+ oprs = (
+ OrderProductRelation.objects.not_fully_refunded()
+ .not_cancelled()
+ .filter(
+ product__category__name="Merchandise",
+ product__name__startswith=self.camp.title,
+ order__paid=True,
+ id__in=ids,
+ )
+ .order_by("product__name", "order")
+ )
+ for opr in oprs:
+ opr.label_printed = True
+ opr.save()
+ date_time = datetime.now().strftime("%Y-%m-%d-%H_%M")
+ response = render(request, template_name, {"orderproductrelation_list": oprs})
+ response["Content-Disposition"] = f"""attachment; filename="{self.camp.slug}_merch_labels_{date_time}.zpl""" ""
+ return response
+
+
class MerchandiseToOrderView(CampViewMixin, OrgaTeamPermissionMixin, TemplateView):
template_name = "merchandise_to_order.html"
def get_context_data(self, **kwargs):
- camp_prefix = f"BornHack {timezone.now().year}"
-
order_relations = (
OrderProductRelation.objects.not_fully_refunded()
.not_cancelled()
@@ -77,6 +137,7 @@ def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["merchandise"] = merchandise_orders
+ context["total_items"] = sum(merchandise_orders.values())
return context
diff --git a/src/shop/migrations/0089_orderproductrelation_label_printed_and_more.py b/src/shop/migrations/0089_orderproductrelation_label_printed_and_more.py
new file mode 100644
index 000000000..15901a593
--- /dev/null
+++ b/src/shop/migrations/0089_orderproductrelation_label_printed_and_more.py
@@ -0,0 +1,24 @@
+# Generated by Django 4.2.21 on 2025-06-15 06:32
+from __future__ import annotations
+
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("shop", "0088_alter_coinifyapicallback_order"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="orderproductrelation",
+ name="label_printed",
+ field=models.BooleanField(default=False, help_text="Is the label for this OPR printed"),
+ ),
+ migrations.AddField(
+ model_name="orderproductrelation",
+ name="ready_for_pickup",
+ field=models.BooleanField(default=False, help_text="Is this OPR ready for pickup"),
+ ),
+ ]
diff --git a/src/shop/models.py b/src/shop/models.py
index 7640f968e..b528320b5 100644
--- a/src/shop/models.py
+++ b/src/shop/models.py
@@ -689,6 +689,16 @@ def __str__(self) -> str:
),
)
+ label_printed = models.BooleanField(
+ default=False,
+ help_text="Is the label for this OPR printed",
+ )
+
+ ready_for_pickup = models.BooleanField(
+ default=False,
+ help_text="Is this OPR ready for pickup",
+ )
+
@property
def total(self):
"""Returns the total price for this OPR considering quantity."""