Coverage for website/sales/models/order.py: 87.33%
116 statements
« prev ^ index » next coverage.py v7.6.7, created at 2025-08-14 10:31 +0000
« prev ^ index » next coverage.py v7.6.7, created at 2025-08-14 10:31 +0000
1import uuid
2from decimal import Decimal
4from django.conf import settings
5from django.core.exceptions import ValidationError
6from django.core.validators import MinValueValidator
7from django.db import models
8from django.db.models import BooleanField, Count, F, IntegerField, Q, Sum, Value
9from django.db.models.functions import Coalesce
10from django.urls import reverse
11from django.utils import timezone
12from django.utils.translation import gettext_lazy as _
14from queryable_properties.managers import QueryablePropertiesManager
15from queryable_properties.properties import AnnotationProperty
17from members.models import Member
18from payments.models import Payment, PaymentAmountField
19from sales.models.product import ProductListItem
20from sales.models.shift import Shift
23def default_order_shift():
24 return Shift.objects.filter(active=True).first()
27class Order(models.Model):
28 objects = QueryablePropertiesManager()
30 class Meta:
31 verbose_name = _("order")
32 verbose_name_plural = _("orders")
33 permissions = [
34 ("custom_prices", _("Can use custom prices and discounts in orders")),
35 ]
36 ordering = ["created_at"]
38 id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
40 created_at = models.DateTimeField(
41 verbose_name=_("created at"), default=timezone.now
42 )
44 created_by = models.ForeignKey(
45 Member,
46 models.SET_NULL,
47 verbose_name=_("created by"),
48 related_name="sales_orders_created",
49 blank=False,
50 null=True,
51 )
53 shift = models.ForeignKey(
54 Shift,
55 verbose_name=_("shift"),
56 related_name="orders",
57 default=default_order_shift,
58 null=False,
59 blank=False,
60 on_delete=models.PROTECT,
61 )
63 items = models.ManyToManyField(
64 ProductListItem,
65 through="OrderItem",
66 verbose_name=_("items"),
67 )
69 payment = models.OneToOneField(
70 Payment,
71 verbose_name=_("payment"),
72 related_name="sales_order",
73 on_delete=models.PROTECT,
74 blank=True,
75 null=True,
76 )
78 discount = PaymentAmountField(
79 verbose_name=_("discount"),
80 null=True,
81 blank=True,
82 validators=[MinValueValidator(Decimal("0.00"))],
83 )
85 payer = models.ForeignKey(
86 Member,
87 models.SET_NULL,
88 verbose_name=_("payer"),
89 related_name="sales_order",
90 blank=True,
91 null=True,
92 )
94 age_restricted = AnnotationProperty(
95 Count(
96 "order_items__pk",
97 filter=Q(order_items__product__product__age_restricted=True),
98 output_field=BooleanField(),
99 )
100 )
102 subtotal = AnnotationProperty(
103 Coalesce(
104 Sum("order_items__total"),
105 Value(0.00),
106 output_field=PaymentAmountField(allow_zero=True),
107 )
108 )
110 total_amount = AnnotationProperty(
111 Coalesce(
112 Sum("order_items__total"),
113 Value(0.00),
114 output_field=PaymentAmountField(allow_zero=True),
115 )
116 - Coalesce(
117 F("discount"), Value(0.00), output_field=PaymentAmountField(allow_zero=True)
118 )
119 )
121 num_items = AnnotationProperty(
122 Coalesce(Sum("order_items__amount"), Value(0), output_field=IntegerField())
123 )
125 _is_free = models.BooleanField(
126 verbose_name=_("is free"),
127 default=False,
128 )
129 _total_amount = PaymentAmountField(
130 allow_zero=True,
131 verbose_name=_("total amount"),
132 )
134 def save(
135 self, force_insert=False, force_update=False, using=None, update_fields=None
136 ):
137 if self.shift.locked and (
138 self._total_amount != 0 or (self._total_amount == 0 and not self._is_free)
139 ): # Fallback for initializing _total_amount during migration
140 raise ValueError("The shift this order belongs to is locked.")
141 if self.shift.start > timezone.now():
142 raise ValueError("The shift hasn't started yet.")
144 try:
145 if hasattr( 145 ↛ 152line 145 didn't jump to line 152 because the condition on line 145 was always true
146 self, "total_amount"
147 ): # Fallback if the annotation is not available during migrations
148 self._total_amount = self.total_amount
149 except self.DoesNotExist:
150 self._total_amount = 0
152 self._is_free = bool(self._total_amount == 0)
154 if self.payment and self._total_amount != self.payment.amount: 154 ↛ 155line 154 didn't jump to line 155 because the condition on line 154 was never true
155 raise ValueError(
156 "The payment amount does not match the order total amount."
157 )
158 if self.payment and not self.payer:
159 self.payer = self.payment.paid_by
161 return super().save(force_insert, force_update, using, update_fields)
163 def clean(self):
164 super().clean()
165 errors = {}
167 if self.shift.start > timezone.now(): 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true
168 errors.update({"shift": _("The shift hasn't started yet.")})
170 if self.discount and self.discount > self.total_amount:
171 errors.update(
172 {"discount": _("Discount cannot be higher than total amount.")}
173 )
175 if errors:
176 raise ValidationError(errors)
178 @property
179 def order_description(self):
180 return ", ".join(str(x) for x in self.order_items.all())
182 @property
183 def accept_payment_from_any_user(self):
184 return True
186 @property
187 def payment_url(self):
188 return (
189 settings.BASE_URL + reverse("sales:order-pay", kwargs={"pk": self.pk})
190 if not self.payment and self.pk
191 else None
192 )
194 def __str__(self):
195 return f"Order {self.id} ({self.shift})"
198class OrderItem(models.Model):
199 class Meta:
200 verbose_name = "item"
201 verbose_name_plural = "items"
202 ordering = ["pk"]
203 indexes = [
204 models.Index(fields=["order"]),
205 ]
207 product = models.ForeignKey(
208 ProductListItem,
209 verbose_name=_("product"),
210 null=True,
211 blank=False,
212 on_delete=models.SET_NULL,
213 )
214 order = models.ForeignKey(
215 Order,
216 verbose_name=_("order"),
217 related_name="order_items",
218 null=False,
219 blank=False,
220 on_delete=models.CASCADE,
221 )
222 total = PaymentAmountField(
223 verbose_name=_("total"),
224 allow_zero=True,
225 null=False,
226 blank=True,
227 validators=[MinValueValidator(Decimal("0.00"))],
228 help_text="Only when overriding the default",
229 )
230 amount = models.PositiveSmallIntegerField(
231 verbose_name=_("amount"), null=False, blank=False
232 )
233 product_name = models.CharField(
234 verbose_name=_("product name"),
235 max_length=50,
236 null=False,
237 blank=True,
238 )
240 def save(
241 self, force_insert=False, force_update=False, using=None, update_fields=None
242 ):
243 if self.order.shift.locked: 243 ↛ 244line 243 didn't jump to line 244 because the condition on line 243 was never true
244 raise ValueError("The shift this order belongs to is locked.")
245 if self.order.payment:
246 raise ValueError("This order has already been paid for.")
248 if self.amount == 0: 248 ↛ 249line 248 didn't jump to line 249 because the condition on line 248 was never true
249 if self.pk:
250 self.delete()
251 else:
252 return
254 if not self.total:
255 self.total = self.product.price * self.amount
257 if self.product: 257 ↛ 260line 257 didn't jump to line 260 because the condition on line 257 was always true
258 self.product_name = self.product.product_name
260 super().save(force_insert, force_update, using, update_fields)
262 self.order.save()
264 def clean(self):
265 super().clean()
266 errors = {}
268 if self.order.shift.locked: 268 ↛ 269line 268 didn't jump to line 269 because the condition on line 268 was never true
269 errors.update({"order": _("The shift is locked.")})
271 if self.product not in self.order.shift.product_list.product_items.all(): 271 ↛ 274line 271 didn't jump to line 274 because the condition on line 271 was always true
272 errors.update({"product": _("This product is not available.")})
274 if errors: 274 ↛ exitline 274 didn't return from function 'clean' because the condition on line 274 was always true
275 raise ValidationError(errors)
277 def __str__(self):
278 return f"{self.amount}x {self.product_name}"
280 def delete(self, using=None, keep_parents=False):
281 super().delete(using, keep_parents)
282 self.order.save()