• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

furthemore / APIS / 13190211135

07 Feb 2025 12:00AM UTC coverage: 74.706% (-0.5%) from 75.225%
13190211135

push

github

web-flow
Rework onsite admin interface (#337)

45 of 69 new or added lines in 1 file covered. (65.22%)

14 existing lines in 1 file now uncovered.

3435 of 4598 relevant lines covered (74.71%)

0.75 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

68.18
/registration/views/onsite_admin.py
1
import base64
1✔
2
import json
1✔
3
import logging
1✔
4
import re
1✔
5
import time
1✔
6
import uuid
1✔
7
from dataclasses import dataclass
1✔
8
from datetime import datetime
1✔
9
from decimal import Decimal
1✔
10
from typing import List, Optional
1✔
11

12
from django.conf import settings
1✔
13
from django.contrib.admin.views.decorators import staff_member_required
1✔
14
from django.contrib.auth.decorators import permission_required
1✔
15
from django.contrib.messages import get_messages
1✔
16
from django.contrib.postgres.search import TrigramSimilarity
1✔
17
from django.core.signing import TimestampSigner
1✔
18
from django.db.models import F, Func, Q, Sum, Value
1✔
19
from django.http import JsonResponse
1✔
20
from django.shortcuts import redirect, render
1✔
21
from django.template.loader import render_to_string
1✔
22
from django.urls import reverse
1✔
23
from django.utils import timezone
1✔
24
from django.utils.http import urlencode
1✔
25
from django.views.decorators.csrf import csrf_exempt
1✔
26

27
from registration import admin, mqtt, payments
1✔
28
from registration.admin import TWOPLACES
1✔
29
from registration.models import (
1✔
30
    Badge,
31
    Cashdrawer,
32
    Discount,
33
    Event,
34
    Firebase,
35
    Order,
36
    OrderItem,
37
)
38
from registration.mqtt import send_mqtt_message
1✔
39
from registration.pushy import PushyAPI, PushyError
1✔
40
from registration.views.attendee import get_attendee_age
1✔
41
from registration.views.common import logger
1✔
42
from registration.views.ordering import (
1✔
43
    get_discount_total,
44
    get_order_item_option_total,
45
)
46

47

48
def flatten(l):
1✔
49
    return [item for sublist in l for item in sublist]
×
50

51

52
logger = logging.getLogger(__name__)
1✔
53

54

55
def get_active_terminal(request):
1✔
56
    term_id = request.session.get("terminal")
1✔
57
    if term_id:
1✔
58
        try:
1✔
59
            return Firebase.objects.get(pk=int(term_id))
1✔
60
        except Firebase.DoesNotExist:
1✔
61
            return None
1✔
62
    return None
1✔
63

64

65
@staff_member_required
1✔
66
def onsite_admin(request):
1✔
67
    # Modify a dummy session variable to keep it alive
68
    request.session["heartbeat"] = time.time()
1✔
69

70
    terminals = list(Firebase.objects.all())
1✔
71
    term = request.session.get("terminal", None)
1✔
72

73
    errors = []
1✔
74

75
    # Set default payment terminal to use:
76
    if term is None and len(terminals) > 0:
1✔
77
        request.session["terminal"] = terminals[0].id
1✔
78

79
    if len(terminals) == 0:
1✔
80
        errors.append(
1✔
81
            {
82
                "type": "danger",
83
                "code": "ERROR_NO_TERMINAL",
84
                "text": "It looks like no payment terminals have been configured "
85
                "for this server yet. Check that the APIS Terminal app is "
86
                "running, and has been configured for the correct URL and API key.",
87
            }
88
        )
89

90
    # No terminal selection saved in session - see if one's
91
    # on the URL (that way it'll survive session timeouts)
92
    url_terminal = request.GET.get("terminal", None)
1✔
93
    logger.info("Terminal from GET parameter: {0}".format(url_terminal))
1✔
94
    if url_terminal is not None:
1✔
95
        try:
1✔
96
            terminal_obj = Firebase.objects.get(id=int(url_terminal))
1✔
UNCOV
97
            request.session["terminal"] = terminal_obj.id
×
98
        except Firebase.DoesNotExist:
1✔
99
            del request.session["terminal"]
1✔
100
            errors.append(
1✔
101
                {
102
                    "type": "warning",
103
                    "text": "The payment terminal specified has not registered with the server",
104
                }
105
            )
106
        except ValueError:
1✔
107
            # weren't passed an integer
108
            errors.append({"type": "danger", "text": "Invalid terminal specified"})
1✔
109

110
    terminal = get_active_terminal(request)
1✔
111
    mqtt_auth = None
1✔
112
    if terminal:
1✔
113
        mqtt_auth = mqtt.get_onsite_admin_token(terminal)
1✔
114

115
    context = {
1✔
116
        "settings": json.dumps({
117
            "debug": getattr(settings, "DEBUG", False),
118
            "sentry": {
119
                "enabled": getattr(settings, "SENTRY_ENABLED", False),
120
                "user_reports": getattr(settings, "SENTRY_USER_REPORTS", False),
121
                "frontend_dsn": getattr(settings, "SENTRY_FRONTEND_DSN", None),
122
                "environment": getattr(settings, "SENTRY_ENVIRONMENT", None),
123
                "release": getattr(settings, "SENTRY_RELEASE", None),
124
            },
125
            "errors": errors,
126
            "printer_uri": settings.REGISTER_PRINTER_URI,
127
            "mqtt": {
128
                "broker": getattr(settings, "MQTT_EXTERNAL_BROKER", None),
129
                "auth": mqtt_auth,
130
                "supports_printing": getattr(settings, "PRINT_VIA_MQTT", False),
131
            },
132
            "urls": {
133
                "assign_badge_number": reverse("registration:assign_badge_number"),
134
                "cash_deposit": reverse("registration:cash_deposit"),
135
                "cash_pickup": reverse("registration:cash_pickup"),
136
                "close_drawer": reverse("registration:close_drawer"),
137
                "close_terminal": reverse("registration:close_terminal"),
138
                "complete_cash_transaction": reverse("registration:complete_cash_transaction"),
139
                "enable_payment": reverse("registration:enable_payment"),
140
                "logout": reverse("registration:logout"),
141
                "no_sale": reverse("registration:no_sale"),
142
                "onsite_add_to_cart": reverse("registration:onsite_add_to_cart"),
143
                "onsite_admin_cart": reverse("registration:onsite_admin_cart"),
144
                "onsite_admin_clear_cart": reverse("registration:onsite_admin_clear_cart"),
145
                "onsite_admin_search": reverse("registration:onsite_admin_search"),
146
                "onsite_admin": reverse("registration:onsite_admin"),
147
                "onsite_print_badges": reverse("registration:onsite_print_badges"),
148
                "onsite_print_clear": reverse("registration:onsite_print_clear"),
149
                "onsite_remove_from_cart": reverse("registration:onsite_remove_from_cart"),
150
                "onsite": reverse("registration:onsite"),
151
                "open_drawer": reverse("registration:open_drawer"),
152
                "open_terminal": reverse("registration:open_terminal"),
153
                "pdf": reverse("registration:pdf"),
154
                "ready_terminal": reverse("registration:ready_terminal"),
155
                "registration_badge_change": reverse("admin:registration_badge_change", args=(0,)),
156
                "safe_drop": reverse("registration:safe_drop"),
157
            },
158
            "permissions": {
159
                "cash": request.user.has_perm("registration.cash"),
160
                "cash_admin": request.user.has_perm("registration.cash_admin"),
161
                "discount": request.user.has_perm("registration.discount"),
162
            },
163
            "terminals": {
164
                "selected": terminal.id if terminal else None,
165
                "available": [{"id": terminal.id, "name": terminal.name} for terminal in terminals],
166
            },
167
        }),
168
    }
169

170
    return render(request, "registration/onsite-admin.html", context)
1✔
171

172

173
@dataclass
1✔
174
class SearchFields:
1✔
175
    query: str
1✔
176
    birthday: Optional[str] = None
1✔
177
    badge_ids: Optional[List[int]] = None
1✔
178

179
    @classmethod
1✔
180
    def parse(cls, query: str) -> "SearchFields":
1✔
181
        badge_nums = re.search(r"num:([0-9,]+)", query)
1✔
182
        if badge_nums:
1✔
183
            try:
1✔
184
                badge_ids = [int(num) for num in badge_nums.group(1).split(",")]
1✔
185
                return SearchFields(badge_ids=badge_ids, query="")
1✔
NEW
186
            except ValueError:
×
NEW
187
                query = query.replace(badge_nums.group(0), "")
×
NEW
188
                pass
×
189

190
        birthday = re.search(r"birthday:([0-9-]{10}) ?", query)
1✔
191
        if birthday:
1✔
192
            query = query.replace(birthday.group(0), "")
1✔
193
            birthday = birthday.group(1)
1✔
194

195
        query = query.strip()
1✔
196

197
        return SearchFields(query=query, birthday=birthday)
1✔
198

199

200
@staff_member_required
1✔
201
def onsite_admin_search(request):
1✔
202
    event = Event.objects.get(default=True)
1✔
203
    query = request.GET.get("search", None)
1✔
204
    if query is None:
1✔
205
        return redirect("registration:onsite_admin")
1✔
206

207
    data = []
1✔
208

209
    def collectBadges(badges):
1✔
210
        for badge in badges:
1✔
211
            data.append({
1✔
212
                "id": badge.id,
213
                "edit_url": reverse("admin:registration_badge_change", args=(badge.id,)),
214
                "attendee": {
215
                    "firstName": badge.attendee.firstName,
216
                    "lastName": badge.attendee.lastName,
217
                    "preferredName": badge.attendee.preferredName,
218
                },
219
                "badgeName": badge.badgeName,
220
                "badgeNumber": badge.badgeNumber,
221
                "abandoned": badge.abandoned,
222
            })
223

224
    query = query.strip()
1✔
225

226
    fields = SearchFields.parse(query)
1✔
227

228
    if fields.badge_ids:
1✔
NEW
229
        badges = Badge.objects.filter(event=event, badgeNumber__in=fields.badge_ids)
×
NEW
230
        collectBadges(badges)
×
231

232
    fullName = Func(F("attendee__firstName"), Value(" "), F("attendee__lastName"), function="CONCAT")
1✔
233
    greaterSimilarity = Func("name_similarity", "badge_similarity", function="GREATEST")
1✔
234

235
    filters = (Q(name_similarity__gte=0.4) | Q(badge_similarity__gte=0.6) | Q(attendee__lastName__iexact=fields.query))
1✔
236

237
    if fields.birthday:
1✔
NEW
238
        filters = filters & Q(attendee__birthdate=fields.birthday)
×
239

240
    results = Badge.objects.annotate(
1✔
241
        name_similarity=TrigramSimilarity(fullName, fields.query),
242
        badge_similarity=TrigramSimilarity("badgeName", fields.query),
243
    ).filter(
244
        Q(event=event) & filters
245
    ).order_by(greaterSimilarity).reverse().prefetch_related("attendee")[:50]
246

247
    collectBadges(results)
1✔
248

249
    return JsonResponse({"success": True, "results": data})
1✔
250

251

252
@staff_member_required
1✔
253
def close_terminal(request):
1✔
254
    data = {"command": "close"}
1✔
255
    return send_message_to_terminal(request, data)
1✔
256

257

258
@staff_member_required
1✔
259
def open_terminal(request):
1✔
260
    data = {"command": "open"}
1✔
261
    return send_message_to_terminal(request, data)
1✔
262

263

264
@staff_member_required
1✔
265
def ready_terminal(request):
1✔
266
    data = {"command": "ready"}
×
267
    return send_message_to_terminal(request, data)
×
268

269

270
def send_message_to_terminal(request, data):
1✔
271
    request.session["heartbeat"] = time.time()  # Keep session alive
1✔
272
    url_terminal = request.GET.get("terminal", None)
1✔
273
    logger.info("Terminal from GET parameter: {0}".format(url_terminal))
1✔
274
    session_terminal = request.session.get("terminal", None)
1✔
275

276
    if url_terminal is not None:
1✔
277
        try:
1✔
278
            active = Firebase.objects.get(id=int(url_terminal))
1✔
279
            request.session["terminal"] = active.id
1✔
280
            session_terminal = active.id
1✔
281
        except Firebase.DoesNotExist:
1✔
282
            return JsonResponse(
1✔
283
                {
284
                    "success": False,
285
                    "message": "The payment terminal specified has not registered with the server",
286
                },
287
                status=404,
288
            )
289
        except ValueError:
1✔
290
            # weren't passed an integer
291
            return JsonResponse(
1✔
292
                {"success": False, "message": "Invalid terminal specified"}, status=400
293
            )
294

295
    try:
1✔
296
        active = Firebase.objects.get(id=session_terminal)
1✔
297
    except Firebase.DoesNotExist:
1✔
298
        return JsonResponse(
1✔
299
            {"success": False, "message": "No terminal specified and none in session"},
300
            status=400,
301
        )
302

303
    logger.info("Terminal from session: {0}".format(request.session["terminal"]))
1✔
304

305
    to = [
1✔
306
        active.token,
307
    ]
308

309
    command = data.get("command")
1✔
310
    if command in ("open", "close", "ready", "gay"):
1✔
311
        if command == "close":
1✔
312
            command = "closed"
1✔
313
        topic = f"{mqtt.get_topic('admin', active.name)}/terminal/state"
1✔
314
        send_mqtt_message(topic, command, True)
1✔
315

316
    try:
1✔
317
        PushyAPI.send_push_notification(data, to, None)
1✔
318
    except PushyError as e:
×
319
        return JsonResponse({"success": False, "message": e.message})
×
320
    return JsonResponse({"success": True})
1✔
321

322

323
@staff_member_required
1✔
324
def enable_payment(request):
1✔
325
    cart = request.session.get("cart", None)
1✔
326
    terminal = get_active_terminal(request)
1✔
327
    if cart is None:
1✔
328
        request.session["cart"] = []
1✔
329
        return JsonResponse(
1✔
330
            {"success": False, "message": "Cart not initialized"}, status=200
331
        )
332

333
    badges = []
×
334
    first_order = None
×
335

336
    for pk in cart:
×
337
        try:
×
338
            badge = Badge.objects.get(id=pk)
×
339
            badges.append(badge)
×
340

341
            order = badge.getOrder()
×
342
            if first_order is None:
×
343
                first_order = order
×
344
            else:
345
                # FIXME: use order.onsite_reference instead.
346
                # FIXME: Put this in cash handling, too
347
                # Reassign order references of items in cart to match first:
348
                order = badge.getOrder()
×
349
                order.reference = first_order.reference
×
350
                order.save()
×
351
        except Badge.DoesNotExist:
×
352
            cart.remove(pk)
×
353
            logger.error(
×
354
                "ID {0} was in cart but doesn't exist in the database".format(pk)
355
            )
356

357
    # Force a cart refresh to get the latest order reference to the terminal
358
    onsite_admin_cart(request)
×
359

360
    data = {"command": "process_payment"}
×
361
    if terminal:
×
362
        data["terminal"] = terminal.pk
×
363
    return send_message_to_terminal(request, data)
×
364

365

366
def notify_terminal(request, data):
1✔
367
    # Generates preview layout based on cart items and sends the result
368
    # to the apropriate payment terminal for display to the customer
369
    term = request.session.get("terminal", None)
1✔
370
    if term is None:
1✔
371
        return
1✔
UNCOV
372
    try:
×
UNCOV
373
        active = Firebase.objects.get(id=term)
×
374
    except Firebase.DoesNotExist:
×
375
        return
×
376

UNCOV
377
    html = render_to_string("registration/customer-display.html", data)
×
UNCOV
378
    note = render_to_string("registration/customer-note.txt", data)
×
379

UNCOV
380
    logger.info(note)
×
381

UNCOV
382
    if len(data["result"]) == 0:
×
383
        display = {"command": "clear"}
×
384
    else:
UNCOV
385
        display = {
×
386
            "command": "display",
387
            "html": html,
388
            "note": note,
389
            "total": int(data["total"] * 100),
390
            "reference": data["reference"],
391
        }
392

UNCOV
393
    logger.info(display)
×
394

395
    # Send cloud push message
UNCOV
396
    logger.debug(note)
×
UNCOV
397
    to = [
×
398
        active.token,
399
    ]
400

UNCOV
401
    try:
×
UNCOV
402
        PushyAPI.send_push_notification(display, to, None)
×
403
    except PushyError as e:
×
404
        logger.error("Problem while sending push notification:")
×
405
        logger.error(e)
×
406
        return False
×
UNCOV
407
    return True
×
408

409

410
@staff_member_required
1✔
411
def assign_badge_number(request):
1✔
412
    request_badges = json.loads(request.body)
×
413

414
    badge_payload = {badge["id"]: badge for badge in request_badges}
×
415

416
    badge_set = Badge.objects.filter(id__in=list(badge_payload.keys()))
×
417

418
    admin.assign_badge_numbers(None, request, badge_set)
×
419
    errors = get_messages_list(request)
×
420
    if errors:
×
421
        return JsonResponse(
×
422
            {"success": False, "errors": errors, "message": "\n".join(errors)},
423
            status=400,
424
        )
425
    return JsonResponse({"success": True})
×
426

427

428
def get_messages_list(request):
1✔
429
    storage = get_messages(request)
×
430
    return [message.message for message in storage]
×
431

432

433
@staff_member_required
1✔
434
def onsite_print_badges(request):
1✔
435
    badge_list = request.GET.getlist("id")
1✔
436

437
    if getattr(settings, "PRINT_RENDERER", "wkhtmltopdf") == "gotenberg":
1✔
438
        terminal = get_active_terminal(request)
1✔
439

440
        signer = TimestampSigner()
1✔
441
        data = signer.sign_object({
1✔
442
            "badge_ids": [int(badge_id) for badge_id in badge_list],
443
            "terminal": terminal.name if terminal else None,
444
        })
445

446
        pdf_path = reverse("registration:pdf") + f"?data={data}"
1✔
447
    else:
448
        queryset = Badge.objects.filter(id__in=badge_list)
1✔
449
        pdf_name = admin.generate_badge_labels(queryset, request)
1✔
450

451
        pdf_path = reverse("registration:pdf") + f"?file={pdf_name}"
1✔
452

453
        # Async notify the frontend to refresh the cart
454
        logger.info("Refreshing admin cart")
1✔
455
        admin_push_cart_refresh(request)
1✔
456

457
    print_url = reverse("registration:print") + "?" + urlencode({"file": pdf_path})
1✔
458

459
    return JsonResponse(
1✔
460
        {
461
            "success": True,
462
            "next": request.get_full_path(),
463
            "file": pdf_path,
464
            "url": print_url,
465
        }
466
    )
467

468

469
def admin_push_cart_refresh(request):
1✔
470
    terminal = get_active_terminal(request)
1✔
471
    if terminal:
1✔
472
        topic = f"{mqtt.get_topic('admin', terminal.name)}/refresh"
×
473
        send_mqtt_message(topic, None)
×
474

475

476
# TODO: update for square SDK data type (fetch txn from square API and store in order.apiData)
477
@csrf_exempt
1✔
478
def complete_square_transaction(request):
1✔
479
    key = request.GET.get("key", "")
1✔
480
    reference = request.GET.get("reference")
1✔
481
    terminal_name = request.GET.get("terminal")
1✔
482
    clientTransactionId = request.GET.get("clientTransactionId")
1✔
483
    serverTransactionId = request.GET.get("serverTransactionId")
1✔
484

485
    if key != settings.REGISTER_KEY:
1✔
486
        return JsonResponse(
×
487
            {"success": False, "reason": "Incorrect API key"}, status=401
488
        )
489

490
    if reference is None or clientTransactionId is None:
1✔
491
        return JsonResponse(
×
492
            {
493
                "success": False,
494
                "reason": "Reference and clientTransactionId are required parameters",
495
            },
496
            status=400,
497
        )
498

499
    try:
1✔
500
        terminal = Firebase.objects.get(name=terminal_name)
1✔
501
        request.session["terminal"] = terminal.id
×
502
    except Firebase.DoesNotExist:
1✔
503
        request.session["terminal"] = None
1✔
504

505
    # Things we need:
506
    #   orderID or reference (passed to square by metadata)
507
    # Square returns:
508
    #   clientTransactionId (offline payments)
509
    #   serverTransactionId (online payments)
510

511
    try:
1✔
512
        orders = Order.objects.filter(reference=reference).prefetch_related()
1✔
513
    except Order.DoesNotExist:
×
514
        logger.error("No order matching reference {0}".format(reference))
×
515
        return JsonResponse(
×
516
            {
517
                "success": False,
518
                "reason": "No order matching the reference specified exists",
519
            },
520
            status=404,
521
        )
522

523
    combine_orders(orders)
1✔
524

525
    store_api_data = {
1✔
526
        "onsite": {
527
            "client_transaction_id": clientTransactionId,
528
            "server_transaction_id": serverTransactionId,
529
        },
530
    }
531

532
    order = orders[0]
1✔
533
    order.billingType = Order.CREDIT
1✔
534

535
    # Lookup the payment(s?) associated with this order:
536
    if serverTransactionId:
1✔
537
        for retry in range(4):
×
538
            payment_ids = payments.get_payments_from_order_id(serverTransactionId)
×
539
            if payment_ids:
×
540
                break
×
541
            time.sleep(0.5)
×
542
        if payment_ids:
×
543
            store_api_data["payment"] = {"id": payment_ids[0]}
×
544
            order.status = Order.COMPLETED
×
545
            order.settledDate = timezone.now()
×
546
            order.apiData = json.dumps(store_api_data)
×
547
        else:
548
            order.status = Order.CAPTURED
×
549
            order.notes = "Need to refresh payment."
×
550
    else:
551
        order.status = Order.CAPTURED
1✔
552
        order.notes = "No serverTransactionId."
1✔
553

554
    order.status = Order.COMPLETED
1✔
555
    order.settledDate = timezone.now()
1✔
556

557
    order.apiData = json.dumps(store_api_data)
1✔
558
    order.save()
1✔
559

560
    admin_push_cart_refresh(request)
1✔
561

562
    if serverTransactionId:
1✔
563
        status, errors = payments.refresh_payment(order, store_api_data)
×
564
        if not status:
×
565
            return JsonResponse({"success": False, "error": errors}, status=210)
×
566

567
    return JsonResponse({"success": True})
1✔
568

569

570
def combine_orders(orders):
1✔
571
    # If there is more than one order, we should flatten them into one by reassigning all these
572
    # orderItems to the first order, and delete the rest.
573
    first_order = orders[0]
1✔
574
    if len(orders) > 1:
1✔
575

576
        order_items = []
×
577
        for order in orders[1:]:
×
578
            order_items += order.orderitem_set.all()
×
579
            first_order.notes += (
×
580
                f"\n[Combined from order reference {order.reference}]\n{order.notes}\n"
581
            )
582

583
        for order_item in order_items:
×
584
            old_order = order_item.order
×
585
            order_item.order = first_order
×
586
            logger.warning("Deleting old order id={0}".format(old_order.id))
×
587
            old_order.delete()
×
588
            order_item.save()
×
589

590
        first_order.save()
×
591

592

593
@staff_member_required
1✔
594
@permission_required("order.cash_admin")
1✔
595
def drawer_status(request):
1✔
596
    if Cashdrawer.objects.count() == 0:
1✔
597
        return JsonResponse({"success": False})
1✔
598
    total = Cashdrawer.objects.all().aggregate(Sum("total"))
1✔
599
    drawer_total = Decimal(total["total__sum"])
1✔
600
    if drawer_total == 0:
1✔
601
        status = "CLOSED"
1✔
602
    elif drawer_total < 0:
1✔
603
        status = "SHORT"
1✔
604
    elif drawer_total > 0:
1✔
605
        status = "OPEN"
1✔
606
    return JsonResponse({"success": True, "total": drawer_total, "status": status})
1✔
607

608

609
@staff_member_required
1✔
610
@permission_required("order.cash_admin")
1✔
611
def no_sale(request):
1✔
612
    position = get_active_terminal(request)
1✔
613
    topic = f"{mqtt.get_topic('receipts', position.name)}/no_sale"
1✔
614
    send_mqtt_message(topic)
1✔
615

616
    return JsonResponse({"success": True})
1✔
617

618

619
@staff_member_required
1✔
620
@permission_required("order.cash_admin")
1✔
621
def print_audit_receipt(request, audit_type, cash_ledger, cashdraw=True):
1✔
622
    position = get_active_terminal(request)
1✔
623
    event = Event.objects.get(default=True)
1✔
624
    payload = {
1✔
625
        "v": 1,
626
        "event": event.name,
627
        "terminal": position.name,
628
        "type": audit_type,
629
        "amount": abs(cash_ledger.total),
630
        "user": request.user.username,
631
        "timestamp": cash_ledger.timestamp.isoformat(),
632
        "cashdraw": cashdraw,
633
    }
634

635
    topic = f"{mqtt.get_topic('receipts', position.name)}/audit_slip"
1✔
636

637
    send_mqtt_message(topic, payload)
1✔
638

639

640
def cash_audit_action(request, action):
1✔
641
    cashdraw = True
1✔
642
    amount = Decimal(request.POST.get("amount", None))
1✔
643
    position = get_active_terminal(request)
1✔
644
    if action in (Cashdrawer.DROP, Cashdrawer.PICKUP, Cashdrawer.CLOSE):
1✔
645
        amount = -abs(amount)
1✔
646
        cashdraw = False
1✔
647
    cash_ledger = Cashdrawer(
1✔
648
        action=action, total=amount, user=request.user, position=position
649
    )
650
    cash_ledger.save()
1✔
651
    cash_ledger.refresh_from_db()
1✔
652
    print_audit_receipt(request, action, cash_ledger, cashdraw)
1✔
653

654
    return JsonResponse({"success": True})
1✔
655

656

657
@staff_member_required
1✔
658
@permission_required("order.cash_admin")
1✔
659
def open_drawer(request):
1✔
660
    return cash_audit_action(request, Cashdrawer.OPEN)
1✔
661

662

663
@staff_member_required
1✔
664
@permission_required("order.cash_admin")
1✔
665
def cash_deposit(request):
1✔
666
    return cash_audit_action(request, Cashdrawer.DEPOSIT)
1✔
667

668

669
@staff_member_required
1✔
670
@permission_required("order.cash_admin")
1✔
671
def safe_drop(request):
1✔
672
    return cash_audit_action(request, Cashdrawer.DROP)
1✔
673

674

675
@staff_member_required
1✔
676
@permission_required("order.cash_admin")
1✔
677
def cash_pickup(request):
1✔
678
    return cash_audit_action(request, Cashdrawer.PICKUP)
1✔
679

680

681
@staff_member_required
1✔
682
@permission_required("order.cash_admin")
1✔
683
def close_drawer(request):
1✔
684
    return cash_audit_action(request, Cashdrawer.CLOSE)
1✔
685

686

687
@staff_member_required
1✔
688
@permission_required("order.cash")
1✔
689
def complete_cash_transaction(request):
1✔
690
    reference = request.GET.get("reference", None)
1✔
691
    total = request.GET.get("total", None)
1✔
692
    tendered = request.GET.get("tendered", None)
1✔
693

694
    if reference is None or tendered is None or total is None:
1✔
695
        return JsonResponse(
×
696
            {
697
                "success": False,
698
                "reason": "Reference, tendered, and total are required parameters",
699
            },
700
            status=400,
701
        )
702

703
    try:
1✔
704
        orders = Order.objects.filter(reference=reference).prefetch_related()
1✔
705
    except Order.DoesNotExist:
×
706
        return JsonResponse(
×
707
            {
708
                "success": False,
709
                "reason": "No order matching the reference specified exists",
710
            },
711
            status=404,
712
        )
713

714
    combine_orders(orders)
1✔
715

716
    order = orders[0]
1✔
717
    order.billingType = Order.CASH
1✔
718
    order.status = Order.COMPLETED
1✔
719
    order.settledDate = timezone.now()
1✔
720
    order.notes = json.dumps({"type": "cash", "tendered": tendered})
1✔
721
    order.save()
1✔
722

723
    txn = Cashdrawer(
1✔
724
        action=Cashdrawer.TRANSACTION, total=total, tendered=tendered, user=request.user
725
    )
726
    txn.save()
1✔
727

728
    order_items = OrderItem.objects.filter(order=order)
1✔
729
    attendee_options = []
1✔
730
    for item in order_items:
1✔
731
        attendee_options.extend(get_line_items(item.attendeeoptions_set.all()))
1✔
732

733
    # discounts
734
    if order.discount:
1✔
735
        if order.discount.amountOff:
×
736
            attendee_options.append(
×
737
                {"item": "Discount", "price": "-${0}".format(order.discount.amountOff)}
738
            )
739
        elif order.discount.percentOff:
×
740
            attendee_options.append(
×
741
                {"item": "Discount", "price": "-%{0}".format(order.discount.percentOff)}
742
            )
743

744
    event = Event.objects.get(default=True)
1✔
745
    payload = {
1✔
746
        "v": 1,
747
        "event": event.name,
748
        "line_items": attendee_options,
749
        "donations": {"org": {"name": event.name, "price": str(order.orgDonation)}},
750
        "total": order.total,
751
        "payment": {
752
            "type": order.billingType,
753
            "tendered": Decimal(tendered),
754
            "change": Decimal(tendered) - Decimal(total),
755
            "details": "Ref: {0}".format(order.reference),
756
        },
757
        "reference": order.reference,
758
    }
759

760
    if event.charity:
1✔
761
        payload["donations"]["charity"] = (
×
762
            {"name": event.charity.name, "price": str(order.charityDonation)},
763
        )
764

765
    term = request.session.get("terminal", None)
1✔
766
    active = Firebase.objects.get(id=term)
1✔
767
    topic = f"{mqtt.get_topic('receipts', active.name)}/print_cash"
1✔
768

769
    send_mqtt_message(topic, payload)
1✔
770

771
    return JsonResponse({"success": True})
1✔
772

773

774
@csrf_exempt
1✔
775
def firebase_register(request):
1✔
776
    key = request.GET.get("key", "")
1✔
777
    if key != settings.REGISTER_KEY:
1✔
778
        return JsonResponse(
1✔
779
            {"success": False, "reason": "Incorrect API key"}, status=401
780
        )
781

782
    token = request.GET.get("token", None)
1✔
783
    name = request.GET.get("name", None)
1✔
784
    if token is None or name is None:
1✔
785
        return JsonResponse(
1✔
786
            {"success": False, "reason": "Must specify token and name parameter"},
787
            status=400,
788
        )
789

790
    # Upsert if a new token with an existing name tries to register
791
    try:
1✔
792
        old_terminal = Firebase.objects.get(name=name)
1✔
793
        old_terminal.token = token
1✔
794
        old_terminal.save()
1✔
795
        return JsonResponse({"success": True, "updated": True})
1✔
796
    except Firebase.DoesNotExist:
1✔
797
        pass
1✔
798
    except Exception as e:
×
799
        return JsonResponse(
×
800
            {
801
                "success": False,
802
                "reason": "Failed while attempting to update existing name entry",
803
            },
804
            status=500,
805
        )
806

807
    try:
1✔
808
        terminal = Firebase(token=token, name=name)
1✔
809
        terminal.save()
1✔
810
    except Exception as e:
×
811
        logger.exception(e)
×
812
        logger.error("Error while saving Firebase token to database")
×
813
        return JsonResponse(
×
814
            {"success": False, "reason": "Error while saving to database"}, status=500
815
        )
816

817
    return JsonResponse({"success": True, "updated": False})
1✔
818

819

820
@csrf_exempt
1✔
821
def firebase_lookup(request):
1✔
822
    # Returns the common name stored for a given firebase token
823
    # (So client can notify server if either changes)
824
    token = request.GET.get("token", None)
×
825
    if token is None:
×
826
        return JsonResponse(
×
827
            {"success": False, "reason": "Must specify token parameter"}, status=400
828
        )
829

830
    try:
×
831
        terminal = Firebase.objects.get(token=token)
×
832
        return JsonResponse(
×
833
            {"success": True, "name": terminal.name, "closed": terminal.closed}
834
        )
835
    except Firebase.DoesNotExist:
×
836
        return JsonResponse(
×
837
            {"success": False, "reason": "No such token registered"}, status=404
838
        )
839

840

841
def get_discount_dict(discount):
1✔
842
    if discount:
1✔
843
        return {
×
844
            "name": discount.codeName,
845
            "percent_off": discount.percentOff,
846
            "amount_off": discount.amountOff,
847
            "id": discount.id,
848
            "valid": discount.isValid(),
849
            "status": discount.status,
850
        }
851
    return None
1✔
852

853

854
def get_line_items(attendee_options):
1✔
855
    out = []
1✔
856
    for option in attendee_options:
1✔
857
        if option.option.optionExtraType == "int":
1✔
858
            if option.optionValue:
×
859
                option_dict = {
×
860
                    "item": option.option.optionName,
861
                    "price": option.option.optionPrice,
862
                    "quantity": option.optionValue,
863
                    "total": option.option.optionPrice * Decimal(option.optionValue),
864
                }
865
        else:
866
            option_dict = {
1✔
867
                "item": option.option.optionName,
868
                "price": option.option.optionPrice,
869
                "quantity": 1,
870
                "total": option.option.optionPrice,
871
            }
872
        out.append(option_dict)
1✔
873
    return out
1✔
874

875

876
@staff_member_required
1✔
877
def onsite_admin_cart(request):
1✔
878
    # Returns dataset to render onsite cart preview
879
    request.session["heartbeat"] = time.time()  # Keep session alive
1✔
880
    cart = request.session.get("cart", [])
1✔
881

882
    badges = []
1✔
883
    for pk in cart:
1✔
884
        try:
1✔
885
            badge = Badge.objects.get(id=pk)
1✔
886
            badges.append(badge)
1✔
887
        except Badge.DoesNotExist:
×
888
            cart.remove(pk)
×
889
            logger.error(
×
890
                "ID {0} was in cart but doesn't exist in the database".format(pk)
891
            )
892

893
    order = None
1✔
894
    subtotal = 0
1✔
895
    total_discount = 0
1✔
896
    result = []
1✔
897
    first_order = None
1✔
898
    for badge in badges:
1✔
899
        oi = badge.getOrderItems()
1✔
900
        level = None
1✔
901
        level_subtotal = 0
1✔
902
        attendee_options = []
1✔
903
        for item in oi:
1✔
904
            level = item.priceLevel
1✔
905
            attendee_options.append(get_line_items(item.getOptions()))
1✔
906
            level_subtotal += get_order_item_option_total(item.getOptions())
1✔
907

908
            if level is None:
1✔
909
                effectiveLevel = None
×
910
            else:
911
                effectiveLevel = {"name": level.name, "price": level.basePrice}
1✔
912
                level_subtotal += level.basePrice
1✔
913

914
        subtotal += level_subtotal
1✔
915

916
        order = badge.getOrder()
1✔
917
        if first_order is None:
1✔
918
            first_order = order
1✔
919

920
        holdType = None
1✔
921
        if badge.attendee.holdType:
1✔
922
            holdType = badge.attendee.holdType.name
1✔
923

924
        level_discount = (
1✔
925
            Decimal(get_discount_total(order.discount, level_subtotal) * 100)
926
            * TWOPLACES
927
        )
928
        total_discount += level_discount
1✔
929

930
        item = {
1✔
931
            "id": badge.id,
932
            "firstName": badge.attendee.firstName,
933
            "lastName": badge.attendee.lastName,
934
            "badgeName": badge.badgeName,
935
            "badgeNumber": badge.badgeNumber,
936
            "abandoned": badge.abandoned,
937
            "effectiveLevel": effectiveLevel,
938
            "discount": get_discount_dict(order.discount),
939
            "age": get_attendee_age(badge.attendee),
940
            "holdType": holdType,
941
            "level_subtotal": level_subtotal,
942
            "level_discount": level_discount,
943
            "level_total": level_subtotal - level_discount,
944
            "attendee_options": attendee_options,
945
            "printed": badge.printed,
946
        }
947
        result.append(item)
1✔
948

949
    total = subtotal
1✔
950
    charityDonation = "?"
1✔
951
    orgDonation = "?"
1✔
952
    if order is not None:
1✔
953
        total += order.orgDonation + order.charityDonation
1✔
954
        charityDonation = order.charityDonation
1✔
955
        orgDonation = order.orgDonation
1✔
956

957
    data = {
1✔
958
        "success": True,
959
        "result": result,
960
        "subtotal": subtotal,
961
        "total": total - total_discount,
962
        "total_discount": total_discount,
963
        "charityDonation": charityDonation,
964
        "orgDonation": orgDonation,
965
    }
966

967
    if order is not None:
1✔
968
        data["order_id"] = order.id
1✔
969
        data["reference"] = order.reference
1✔
970
    else:
971
        data["order_id"] = None
×
972
        data["reference"] = None
×
973

974
    notify_terminal(request, data)
1✔
975

976
    return JsonResponse(data)
1✔
977

978

979
@staff_member_required
1✔
980
def onsite_add_to_cart(request):
1✔
981
    id = request.GET.get("id", None)
1✔
982
    return onsite_add_id_to_cart(request, id)
1✔
983

984

985
def onsite_add_id_to_cart(request, id):
1✔
986
    if id is None or id == "":
1✔
987
        return JsonResponse(
×
988
            {"success": False, "reason": "Need ID parameter"}, status=400
989
        )
990

991
    try:
1✔
992
        id = int(id)
1✔
NEW
993
    except ValueError:
×
NEW
994
        return JsonResponse(
×
995
            {"success": False, "reason": "ID parameter must be integer"}, status=400
996
        )
997

998
    cart = request.session.get("cart", None)
1✔
999
    if cart is None:
1✔
1000
        request.session["cart"] = [
1✔
1001
            id,
1002
        ]
1003
        return JsonResponse({"success": True, "cart": [id]})
1✔
1004

1005
    if id in cart:
×
1006
        return JsonResponse({"success": True, "cart": cart})
×
1007

1008
    cart.append(id)
×
1009
    request.session["cart"] = cart
×
1010

1011
    return JsonResponse({"success": True, "cart": cart})
×
1012

1013

1014
@staff_member_required
1✔
1015
def onsite_remove_from_cart(request):
1✔
1016
    id = request.GET.get("id", None)
×
1017
    if id is None or id == "":
×
1018
        return JsonResponse(
×
1019
            {"success": False, "reason": "Need ID parameter"}, status=400
1020
        )
1021

NEW
1022
    try:
×
NEW
1023
        id = int(id)
×
NEW
1024
    except ValueError:
×
NEW
1025
        return JsonResponse(
×
1026
            {"success": False, "reason": "ID parameter must be integer"}, status=400
1027
        )
1028

1029
    cart = request.session.get("cart", None)
×
1030
    if cart is None:
×
1031
        return JsonResponse({"success": False, "reason": "Cart is empty"})
×
1032

1033
    try:
×
1034
        cart.remove(id)
×
1035
        request.session["cart"] = cart
×
1036
    except ValueError:
×
1037
        return JsonResponse({"success": False, "cart": cart, "reason": "Not in cart"})
×
1038

1039
    return JsonResponse({"success": True, "cart": cart})
×
1040

1041

1042
@staff_member_required
1✔
1043
def onsite_admin_clear_cart(request):
1✔
1044
    request.session["cart"] = []
×
1045
    send_message_to_terminal(request, {"command": "clear"})
×
NEW
1046
    return JsonResponse({"success": True, "cart": []})
×
1047

1048

1049
def get_b32_uuid():
1✔
1050
    uid = base64.b32encode(uuid.uuid4().bytes)
×
1051
    return uid[:26]
×
1052

1053

1054
@staff_member_required
1✔
1055
@permission_required("order.discount")
1✔
1056
def create_discount(request):
1✔
1057
    # e.g '$10.00' or '10%'
1058
    amount = request.POST.get("amount")
×
1059
    amount_off = Decimal("0")
×
1060
    percent_off = 0
×
1061

1062
    try:
×
1063
        if amount.startswith("$"):
×
1064
            amount_off = Decimal(amount[1:])
×
1065
        elif amount.startswith("%"):
×
1066
            percent_off = int(amount[1:])
×
1067
    except ValueError as e:
×
1068
        return JsonResponse({"success": False, "reason": str(e)}, status=400)
×
1069

1070
    cart = request.session.get("cart", None)
×
1071
    if cart is None:
×
1072
        request.session["cart"] = []
×
1073
        return JsonResponse(
×
1074
            {"success": False, "message": "Cart not initialized"}, status=400
1075
        )
1076

1077
    discount = Discount(
×
1078
        codeName=get_b32_uuid(),
1079
        percentOff=percent_off,
1080
        amountOff=amount_off,
1081
        startDate=timezone.now(),
1082
        endDate=timezone.now() + datetime.timedelta(hours=1),
1083
        notes=f"Applied by [{request.user}]",
1084
        oneTime=True,
1085
        used=1,
1086
        reason="Onsite admin discount",
1087
    )
1088
    discount.save()
×
1089

1090
    # Combine cart orders and apply discount to combined order
1091
    badges = Badge.objects.filter(pk__in=cart)
×
1092
    orders = [badge.getOrder() for badge in badges]
×
1093
    combine_orders(orders)
×
1094

1095
    orders[0].discount = discount
×
1096
    orders[0].save()
×
1097

1098
    JsonResponse({"success": True, "order": orders[0].pk})
×
1099

1100

1101
@staff_member_required
1✔
1102
def onsite_print_clear(request):
1✔
NEW
1103
    id = request.GET.get("id", None)
×
NEW
1104
    if id is None or id == "":
×
NEW
1105
        return JsonResponse(
×
1106
            {"success": False, "reason": "Need ID parameter"}, status=400
1107
        )
1108

NEW
1109
    try:
×
NEW
1110
        id = int(id)
×
NEW
1111
    except ValueError:
×
NEW
1112
        return JsonResponse(
×
1113
            {"success": False, "reason": "ID parameter must be integer"}, status=400
1114
        )
1115

NEW
1116
    badge = Badge.objects.get(id=id)
×
NEW
1117
    badge.printed = False
×
NEW
1118
    badge.save()
×
1119

NEW
1120
    return JsonResponse({"success": True})
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc