-
Notifications
You must be signed in to change notification settings - Fork 35
Expand file tree
/
Copy pathmodels.py
More file actions
1252 lines (1012 loc) · 40.7 KB
/
models.py
File metadata and controls
1252 lines (1012 loc) · 40.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
from __future__ import annotations
import logging
import uuid
from datetime import timedelta
from decimal import Decimal
from enum import Enum
from itertools import chain
from typing import TYPE_CHECKING
from django.contrib import messages
from django.contrib.postgres.fields import DateTimeRangeField
from django.core.exceptions import ValidationError
from django.db import models
from django.db import transaction
from django.db.models import Case
from django.db.models import Count
from django.db.models import F
from django.db.models import Sum
from django.db.models import Value
from django.db.models import When
from django.db.models.functions import Coalesce
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from django.utils.translation import gettext_lazy as _
from django_prometheus.models import ExportModelOperationsMixin
from unidecode import unidecode
from tickets.models import ShopTicket
from tickets.models import TicketGroup
from utils.models import CreatedUpdatedModel
from utils.models import UUIDModel
from utils.slugs import unique_slugify
from .managers import OrderQuerySet
from .managers import ProductQuerySet
if TYPE_CHECKING:
from django.contrib.auth.models import User
from django.http import HttpRequest
logger = logging.getLogger(f"bornhack.{__name__}")
class CustomOrder(ExportModelOperationsMixin("custom_order"), CreatedUpdatedModel):
text = models.TextField(help_text=_("The invoice text"))
customer = models.TextField(help_text=_("The customer info for this order"))
amount = models.IntegerField(
help_text=_("Amount of this custom order (in DKK, including VAT)."),
)
paid = models.BooleanField(
verbose_name=_("Paid?"),
help_text=_(
"Check when this custom order has been paid (or if it gets cancelled out by a Credit Note)",
),
default=False,
)
danish_vat = models.BooleanField(help_text="Danish VAT?", default=True)
def __str__(self) -> str:
return f"custom order id #{self.pk}"
@property
def vat(self):
if self.danish_vat:
return Decimal(round(self.amount * Decimal("0.2"), 2))
return 0
class Order(ExportModelOperationsMixin("order"), CreatedUpdatedModel):
class Meta:
unique_together = ("user", "open")
ordering = ["-created"]
products = models.ManyToManyField(
"shop.Product",
through="shop.OrderProductRelation",
)
user = models.ForeignKey(
"auth.User",
verbose_name=_("User"),
help_text=_("The user this shop order belongs to."),
related_name="orders",
on_delete=models.PROTECT,
)
paid = models.BooleanField(
verbose_name=_("Paid?"),
help_text=_("Whether this shop order has been paid."),
default=False,
)
# We are using a nullable BooleanField here to ensure that we only have one open order per user at a time.
# This "hack" is possible since postgres treats null values as different, and thus we have database level integrity.
open = models.BooleanField(
blank=True,
null=True,
verbose_name=_("Open?"),
help_text=_('Whether this shop order is open or not. "None" means closed.'),
default=True,
)
class PaymentMethods(models.TextChoices):
CREDIT_CARD = "credit_card", "Credit card"
BLOCKCHAIN = "blockchain", "Blockchain"
BANK_TRANSFER = "bank_transfer", "Bank transfer"
IN_PERSON = "in_person", "In Person"
payment_method = models.CharField(
max_length=50,
choices=PaymentMethods.choices,
default="",
blank=True,
)
cancelled = models.BooleanField(default=False)
customer_comment = models.TextField(
verbose_name=_("Customer comment"),
help_text=_("If you have any comments about the order please enter them here."),
default="",
blank=True,
)
invoice_address = models.TextField(
help_text=_(
"The invoice address for this order. Leave blank to use the email associated with the logged in user.",
),
blank=True,
)
notes = models.TextField(
help_text="Any internal notes about this order can be entered here. They will not be printed on the invoice or shown to the customer in any way.",
default="",
blank=True,
)
pdf = models.FileField(null=True, blank=True, upload_to="proforma_invoices/")
objects = OrderQuerySet.as_manager()
def __str__(self) -> str:
return f"shop order id #{self.pk}"
def get_number_of_items(self):
return self.products.aggregate(sum=Sum("orderproductrelation__quantity"))["sum"]
@property
def vat(self):
return Decimal(self.total * Decimal("0.2"))
@property
def total(self):
if self.products.all():
return Decimal(
self.products.aggregate(
sum=Sum(
models.F("orderproductrelation__product__price") * models.F("orderproductrelation__quantity"),
output_field=models.IntegerField(),
),
)["sum"],
)
return False
def get_coinify_thanks_url(self, request):
return "https://" + request.get_host() + str(reverse_lazy("shop:coinify_thanks", kwargs={"pk": self.pk}))
def get_epay_accept_url(self, request):
return "https://" + request.get_host() + str(reverse_lazy("shop:epay_thanks", kwargs={"pk": self.pk}))
def get_cancel_url(self, request):
return "https://" + request.get_host() + str(reverse_lazy("shop:order_detail", kwargs={"pk": self.pk}))
def get_quickpay_accept_url(self, request):
return "https://" + request.get_host() + str(reverse_lazy("shop:quickpay_thanks", kwargs={"pk": self.pk}))
def get_quickpay_callback_url(self, request):
return "https://" + request.get_host() + str(reverse_lazy("shop:quickpay_callback", kwargs={"pk": self.pk}))
@property
def description(self) -> str:
return f"Order #{self.pk}"
def get_absolute_url(self):
return str(reverse_lazy("shop:order_detail", kwargs={"pk": self.pk}))
def create_tickets(self, request=None):
"""Calls create_tickets() on each OPR and returns a list of all created tickets."""
tickets = []
for opr in self.oprs.all():
tickets += opr.create_tickets(request)
return tickets
def get_tickets(self):
return chain(*[opr.shoptickets.all() for opr in self.oprs.all()])
def mark_as_paid(self, request=None) -> None:
self.paid = True
self.open = None
self.create_tickets(request)
self.save()
def mark_as_cancelled(self, request=None) -> None:
if self.paid:
msg = f"Order {self.pk} is paid, cannot cancel a paid order!"
if request:
messages.error(request, msg)
else:
pass
else:
self.cancelled = True
self.open = None
self.save()
@property
def coinify_api_payment_intent(self):
if not self.coinify_api_payment_intents.exists():
return False
for tempinvoice in self.coinify_api_payment_intents.all():
# we already have a coinifypaymentintent for this order
if "paymentWindowUrl" in tempinvoice.paymentintentjson:
# this invoice is still active, we are good to go
return tempinvoice
# nope
return False
@property
def filename(self) -> str:
return f"bornhack_proforma_invoice_order_{self.pk}.pdf"
def get_invoice_address(self):
if self.invoice_address:
return self.invoice_address
return self.user.email
@property
def used_status(self) -> str:
used = len(self.get_used_shoptickets())
unused = len(self.get_unused_shoptickets())
return f"{used} / {used + unused}"
def get_used_shoptickets(self):
tickets = []
for opr in self.oprs.all():
tickets += opr.used_shoptickets
return tickets
def get_unused_shoptickets(self):
tickets = []
for opr in self.oprs.all():
tickets += opr.unused_shoptickets
return tickets
@property
def refunded(self) -> str:
aggregate = self.oprs.aggregate(
# We want to sum the quantity of distinct OPRs
total_quantity=Sum("quantity", distinct=True),
# We want all RPRs per OPR, so therefore no distinct (distinct would give us one RPR per OPR)
total_refunded=Sum("rprs__quantity"),
)
total_refunded = aggregate["total_refunded"]
total_quantity = aggregate["total_quantity"]
if total_refunded:
if total_refunded == total_quantity:
return RefundEnum.FULLY_REFUNDED.value
if total_refunded > 0:
return RefundEnum.PARTIALLY_REFUNDED.value
return RefundEnum.NOT_REFUNDED.value
def create_refund(self, *, created_by: User, notes: str = "") -> Refund:
return Refund.objects.create(order=self, notes=notes, created_by=created_by)
# ########## REFUNDS #################################################
class RefundEnum(Enum):
NOT_REFUNDED = "NOT_REFUNDED"
PARTIALLY_REFUNDED = "PARTIALLY_REFUNDED"
FULLY_REFUNDED = "FULLY_REFUNDED"
class Refund(CreatedUpdatedModel):
"""A refund is created whenever we have to refund a webshop Order (partially or fully).
The Refund model does not have a products m2m like the Order model, instead self.rprs
returns a QS of the RefundProductRelations for this Refund, which in turn each have an FK
to the OrderProductRelation they are refunding (partially or fully).
A Refund is always related to a webshop Order. TODO add a CustomRefund model for when we
need to create a CreditNote without a related webshop Order.
"""
class Meta:
ordering = ["-created"]
order = models.ForeignKey(
"shop.Order",
on_delete=models.PROTECT,
related_name="refunds",
help_text="The Order this Refund is refunding (partially or fully)",
)
paid = models.BooleanField(
verbose_name=_("Paid?"),
help_text=_("Whether this shop refund has been paid."),
default=False,
)
customer_comment = models.TextField(
verbose_name=_("Customer comment"),
help_text=_(
"If you (the customer) have any comments about the refund please enter them here. This field is not currently being used.",
),
default="",
blank=True,
)
invoice_address = models.TextField(
help_text=_(
"The invoice address for this refund. Leave blank to use the invoice address from the Order object.",
),
blank=True,
)
notes = models.TextField(
help_text="Any internal notes about this Refund can be entered here. They will not be printed on the creditnote or shown to the customer in any way.",
default="",
blank=True,
)
created_by = models.ForeignKey(
"auth.User",
help_text="The user who created this refund",
on_delete=models.PROTECT,
null=True, # TODO: Null to support old refunds. Maybe we should have a system user?
blank=True, # TODO: Blank to support old refunds. Maybe we should have a system user?
)
def save(self, **kwargs):
"""Take the invoice_address for the CreditNote from the Order object if we don't have one."""
if not self.invoice_address:
self.invoice_address = self.order.invoice_address
return super().save(**kwargs)
def __str__(self) -> str:
return f"Refund #{self.id}"
@property
def amount(self):
return self.rprs.aggregate(
amount=Sum(
F("opr__product__price") * F("quantity"),
),
)["amount"]
class RefundProductRelation(CreatedUpdatedModel):
"""The RPR model has an FK to the Refund it belongs to, as well as an FK to the OPR
it is refunding (partially or fully).
TODO: Make sure quantity is possible with a constraint
"""
refund = models.ForeignKey(
"shop.Refund",
related_name="rprs",
on_delete=models.PROTECT,
)
opr = models.ForeignKey(
"shop.OrderProductRelation",
related_name="rprs",
help_text="The OPR which this RPR is refunding",
on_delete=models.PROTECT,
)
quantity = models.PositiveIntegerField(
help_text="The number of times this product is being refunded in this Refund",
)
ticket_deleted = models.DateTimeField(
null=True,
blank=True,
help_text="The time when ticket(s) related to this RefundProductRelation were deleted",
)
@property
def total(self):
"""Returns the total price for this RPR considering quantity."""
return Decimal(self.opr.price * self.quantity)
def clean(self) -> None:
"""Make sure the quantity is not greater than the quantity in the opr."""
if self.quantity > self.opr.quantity:
raise ValidationError(
"The quantity of this RPR cannot be greater than the quantity in the OPR",
)
# ########## PRODUCTS ################################################
class ProductCategory(
ExportModelOperationsMixin("product_category"),
CreatedUpdatedModel,
UUIDModel,
):
class Meta:
verbose_name = "Product category"
verbose_name_plural = "Product categories"
name = models.CharField(max_length=150)
slug = models.SlugField()
public = models.BooleanField(default=True)
weight = models.IntegerField(
default=100,
help_text="Sorting weight. Heavier items sink to the bottom.",
)
def __str__(self) -> str:
return self.name
def save(self, **kwargs) -> None:
self.slug = unique_slugify(
self.name,
slugs_in_use=self.__class__.objects.all().values_list("slug", flat=True),
)
super().save(**kwargs)
class ProductStatsManager(models.Manager):
"""Manager used by ShopTicketStatsDetailView showing stats for a single tickettype."""
def with_ticket_stats(self):
return (
self.filter(
orderproductrelation__order__paid=True,
)
.annotate(
total_units_refunded=Coalesce(
Sum("orderproductrelation__rprs__quantity"),
0,
),
)
.annotate(
total_units_sold=Sum("orderproductrelation__quantity") - F("total_units_refunded"),
)
.exclude(total_units_sold=0)
# calculate the profit for this product
.annotate(profit=F("price") - F("cost"))
# calculate the total income for the units sold of this product
.annotate(total_income=F("price") * F("total_units_sold"))
# calculate the total cost for the units sold of this product
.annotate(total_cost=F("cost") * F("total_units_sold"))
# calculate the total profit for this product
.annotate(total_profit=F("profit") * F("total_units_sold"))
# calculate the total number of orders with this product
.annotate(paid_order_count=Count("orderproductrelation__order"))
)
class Product(ExportModelOperationsMixin("product"), CreatedUpdatedModel, UUIDModel):
class Meta:
verbose_name = "Product"
verbose_name_plural = "Products"
ordering = ["available_in", "price", "name"]
category = models.ForeignKey(
"shop.ProductCategory",
related_name="products",
on_delete=models.PROTECT,
)
name = models.CharField(max_length=150)
slug = models.SlugField(unique=True, max_length=100)
price = models.IntegerField(
help_text=_(
"Price of the product (in DKK, including VAT). The price can not be changed on an existing Product if the Product has been added to one or more orders/OPRs.",
),
)
description = models.TextField()
available_in = DateTimeRangeField(
help_text=_(
"Which period is this product available for purchase? | "
"(Format: YYYY-MM-DD HH:MM) | Only one of start/end is required",
),
)
ticket_type = models.ForeignKey(
"tickets.TicketType",
on_delete=models.PROTECT,
null=True,
blank=True,
)
sub_products = models.ManyToManyField(
"self",
through="shop.SubProductRelation",
through_fields=("bundle_product", "sub_product"),
symmetrical=False,
related_name="+",
)
stock_amount = models.IntegerField(
help_text=("Initial amount available in stock if there is a limited supply, e.g. fridge space"),
null=True,
blank=True,
)
cost = models.IntegerField(
default=0,
help_text="The cost for this product, including VAT. Used for profit calculations in the economy system.",
)
comment = models.TextField(
blank=True,
help_text="Internal comments for this product.",
)
objects = ProductQuerySet.as_manager()
statsobjects = ProductStatsManager()
def __str__(self) -> str:
return f"{self.name} ({self.price} DKK)"
def clean(self) -> None:
if self.category.name == "Tickets" and not self.ticket_type:
raise ValidationError("Products with category Tickets need a ticket_type")
def is_available(self):
"""Is the product available or not?
Checks for the following:
- Whether now is in the self.available_in
- If a stock is defined, that there are items left
"""
predicates = [self.is_time_available]
if self.stock_amount:
predicates.append(self.is_stock_available)
return all(predicates)
@property
def is_time_available(self):
now = timezone.now()
return now in self.available_in
def is_old(self):
now = timezone.now()
if hasattr(self.available_in, "upper") and self.available_in.upper:
return self.available_in.upper < now
return False
def is_upcoming(self):
now = timezone.now()
return self.available_in.lower > now
@property
def available_for_days(self):
if self.available_in.upper is not None:
now = timezone.now()
return (self.available_in.upper - now).days
return None
@property
def left_in_stock(self):
if self.stock_amount is not None:
# All orders that are not open and not cancelled count towards what has
# been "reserved" from stock.
#
# This means that an order has either been paid (by card or blockchain)
# or is marked to be paid with cash or bank transfer, meaning it is a
# "reservation" of the product in question.
sold = OrderProductRelation.objects.filter(
product=self,
order__open=None,
order__cancelled=False,
).aggregate(Sum("quantity"))["quantity__sum"]
return self.stock_amount - (sold or 0)
return None
@property
def is_stock_available(self):
if self.stock_amount:
return self.left_in_stock > 0
# If there is no stock defined the product is generally available.
return True
@property
def labels(self) -> list:
"""Return list of label objects for this product."""
labels = []
if self.sub_products.all().exists():
labels.append({
"type": "bundle",
"text": "Bundle",
})
if self.stock_amount is not None:
if self.left_in_stock < 1 or not self.is_time_available:
labels.insert(0, {
"type": "sold_out",
"text": "Sold out!",
})
# Sold out is an exclusive state - no further labels apply
return labels
elif self.left_in_stock <= 10:
labels.append({
"type": "low_stock",
"text": f"Only {self.left_in_stock} left!",
})
if self.available_for_days < 20:
labels.append({
"type": "ending_soon",
"text": f"Sales end in {self.available_for_days} days!",
})
return labels
class SubProductRelation(
ExportModelOperationsMixin("sub_product_relation"),
CreatedUpdatedModel,
):
bundle_product = models.ForeignKey(
"shop.Product",
related_name="sub_product_relations",
on_delete=models.PROTECT,
)
sub_product = models.ForeignKey(
"shop.Product",
on_delete=models.PROTECT,
related_name="bundle_product_relations",
)
number_of_tickets = models.IntegerField(default=1)
class OrderProductRelationQuerySet(models.QuerySet):
def paid(self):
return self.filter(order__paid=True)
def with_refunded(self):
return self.annotate(
total_refunded=Sum("rprs__quantity"),
refunded=Case(
When(
total_refunded=F("quantity"),
then=Value(RefundEnum.FULLY_REFUNDED.value),
),
When(
total_refunded__gt=0,
then=Value(RefundEnum.PARTIALLY_REFUNDED.value),
),
When(
total_refunded__isnull=True,
then=Value(RefundEnum.NOT_REFUNDED.value),
),
),
)
def not_fully_refunded(self):
return self.with_refunded().exclude(refunded=RefundEnum.FULLY_REFUNDED.value)
def not_cancelled(self):
return self.filter(order__cancelled=False)
class OrderProductRelation(
ExportModelOperationsMixin("order_product_relation"),
CreatedUpdatedModel,
):
def __str__(self) -> str:
return f"#{self.order}: {self.quantity} {self.product}"
objects = OrderProductRelationQuerySet.as_manager()
order = models.ForeignKey(
"shop.Order",
related_name="oprs",
on_delete=models.PROTECT,
)
product = models.ForeignKey("shop.Product", on_delete=models.PROTECT)
quantity = models.PositiveIntegerField(
help_text="The number of times this product has been bought on this order",
)
ticket_generated = models.DateTimeField(
null=True,
blank=True,
help_text="Generation time of the ticket(s) for this OPR. Blank if ticket(s) have not been generated yet.",
)
price = models.IntegerField(
null=True,
blank=True,
help_text=_(
"The price (per product, at the time of purchase, in DKK, including VAT).",
),
)
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."""
return Decimal(self.product.price * self.quantity)
def _create_tickets_helper(
self,
*,
product: Product,
number_of_tickets: int = 1,
bundle_product: Product | None = None,
ticket_group: TicketGroup | None = None,
request: HttpRequest | None = None,
) -> list[ShopTicket]:
if not product.ticket_type:
return []
# put reusable kwargs together
query_kwargs = {
"product": product,
"ticket_type": product.ticket_type,
}
if bundle_product:
query_kwargs["bundle_product"] = bundle_product
if ticket_group:
query_kwargs["ticket_group"] = ticket_group
if product.ticket_type.single_ticket_per_product:
# For this ticket type we create one ticket regardless of quantity,
# so 20 chairs don't result in 20 tickets
new_tickets = []
ticket, created = self.shoptickets.get_or_create(**query_kwargs)
if created:
new_tickets.append(ticket)
if request:
if created:
msg = f"Created ticket for product {self.product} on order {self.order} (quantity: {self.quantity})"
else:
msg = (
f"Ticket already exists for product {self.product} on "
f"order {self.order} (quantity: {number_of_tickets})"
)
messages.success(request, msg)
else:
# For this ticket type we create a ticket per item,
# find out if any have already been created
already_created_tickets = self.shoptickets.filter(
**query_kwargs,
).count()
# find out how many we need to create
tickets_to_create = max(
0,
number_of_tickets
- already_created_tickets
- (self.rprs.aggregate(models.Sum("quantity"))["quantity__sum"] or 0),
)
if not tickets_to_create:
return []
query_kwargs["opr"] = self
# create the number of tickets required
new_tickets = [ShopTicket(**query_kwargs) for _i in range(tickets_to_create)]
self.shoptickets.bulk_create(new_tickets)
if request:
msg = f"Created {number_of_tickets} tickets of type: {product.ticket_type.name}"
messages.success(request, msg)
return new_tickets
def create_tickets(self, request: HttpRequest | None = None):
"""This method generates the needed tickets for this OPR.
We run this in a transaction so everything is undone in case something fails,
this is better than using djangos autocommit mode, which is only active
during a request, while transaction.atomic() will also protect against problems
when calling create_tickets() in manage.py shell or from a worker.
Calling this method multiple times will not result in duplicate tickets being created,
and the number of tickets created takes the number of refunded into consideration too.
"""
tickets = []
with transaction.atomic():
# do we even generate tickets for this type of product?
sub_product_relations = self.product.sub_product_relations.all()
if not self.product.ticket_type and not sub_product_relations:
return tickets
if sub_product_relations:
# If there are sub products, we need to create a ticket group to match
# the quantity of the OPR.
# Get the pre-existing ticket groups - cast to list, so we can append to it
ticket_groups = list(self.ticketgroups.all())
# If we have less ticket groups than the quantity of the OPR, we need to create more
if len(ticket_groups) < self.quantity:
# We want to create the difference between the quantity of the OPR and the number of ticket groups
difference = self.quantity - len(ticket_groups)
for _i in range(difference):
ticket_group = TicketGroup.objects.create(opr=self)
ticket_groups.append(ticket_group)
# For each bought product we create a ticket for each sub product
for ticket_group in ticket_groups:
for sub_product_relation in sub_product_relations:
new_tickets = self._create_tickets_helper(
product=sub_product_relation.sub_product,
bundle_product=self.product,
number_of_tickets=sub_product_relation.number_of_tickets,
ticket_group=ticket_group,
request=request,
)
else:
# If there are no sub products, we just create a ticket for the product
new_tickets = self._create_tickets_helper(
product=self.product,
number_of_tickets=self.quantity,
request=request,
)
tickets.extend(new_tickets)
# and mark the OPR as ticket_generated=True
self.ticket_generated = timezone.now()
self.save()
# At last save all tickets to persist the token to the database.
for ticket in tickets:
ticket.save()
return tickets
@property
def used_shoptickets(self):
if self.product.sub_products.exists():
return self.shoptickets.filter(
bundle_product=self.product,
used_at__isnull=False,
)
return self.shoptickets.filter(used_at__isnull=False)
@property
def unused_shoptickets(self):
if self.product.sub_products.exists():
return self.shoptickets.filter(
bundle_product=self.product,
used_at__isnull=True,
)
return self.shoptickets.filter(used_at__isnull=True)
@property
def used_tickets_count(self):
return self.used_shoptickets.count()
@property
def refunded_quantity(self):
return self.rprs.aggregate(refunded=Sum("quantity"))["refunded"] or 0
@property
def possible_refund(self) -> int:
"""Returns the number of tickets that can be refunded for this OPR."""
# If the product has no ticket type, we should be able to refund everything
if not self.product.ticket_type:
return self.quantity - self.refunded_quantity
# If the product has sub products, we can only refund the entire product
if self.product.sub_products.exists():
# We can only refund the entire product, so no tickets should be used
has_used_tickets = self.used_tickets_count > 0
is_refunded = self.refunded_quantity == self.quantity
return 0 if has_used_tickets or is_refunded else 1
quantity = 1 if self.product.ticket_type.single_ticket_per_product else self.quantity
return quantity - self.used_tickets_count - self.refunded_quantity
@property
def non_refunded_quantity(self):
return self.quantity - self.refunded_quantity
def save(self, **kwargs) -> None:
"""Make sure we save the current price in the OPR."""
if not self.price:
self.price = self.product.price
super().save(**kwargs)
def create_rpr(self, *, refund: Refund, quantity: int):
return RefundProductRelation.objects.create(
refund=refund,
opr=self,
quantity=quantity,
)
# ########## EPAY ####################################################
class EpayCallback(
ExportModelOperationsMixin("epay_callback"),
CreatedUpdatedModel,
UUIDModel,
):
class Meta:
verbose_name = "Epay Callback"
verbose_name_plural = "Epay Callbacks"
ordering = ["-created"]
payload = models.JSONField()
md5valid = models.BooleanField(default=False)
def __str__(self) -> str:
return f"callback at {self.created} (md5 valid: {self.md5valid})"
class EpayPayment(
ExportModelOperationsMixin("epay_payment"),
CreatedUpdatedModel,
UUIDModel,
):
class Meta:
verbose_name = "Epay Payment"
verbose_name_plural = "Epay Payments"
ordering = ["created"]
order = models.OneToOneField("shop.Order", on_delete=models.PROTECT)
callback = models.ForeignKey("shop.EpayCallback", on_delete=models.PROTECT)
txnid = models.IntegerField()
class CreditNote(ExportModelOperationsMixin("credit_note"), CreatedUpdatedModel):
class Meta:
ordering = ["-created"]
# TODO add a check constraint to ensure that a if a CreditNote has
# a reference to both an Invoice and a Refund object that the
# Refund object is related to an Order which related to the same
# Invoice object
amount = models.DecimalField(max_digits=10, decimal_places=2)
text = models.TextField(help_text="Description of what this credit note covers")
pdf = models.FileField(null=True, blank=True, upload_to="creditnotes/")
user = models.ForeignKey(
"auth.User",
verbose_name=_("User"),
help_text=_("The user this credit note belongs to, if any."),
related_name="creditnotes",
on_delete=models.PROTECT,
null=True,
blank=True,
)
customer = models.TextField(