Coverage for website/sales/models/order.py: 80.49%
124 statements
« prev ^ index » next coverage.py v7.6.12, created at 2026-06-21 23:59 +0000
« prev ^ index » next coverage.py v7.6.12, created at 2026-06-21 23:59 +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).only("pk").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 def user_can_modify(self, user):
187 if not self.shift.user_orders_allowed:
188 return False
189 if self.created_by.pk != user.pk:
190 return False
191 if self.payment:
192 return False
193 return True
195 @property
196 def payment_url(self):
197 return (
198 settings.BASE_URL + reverse("sales:order-pay", kwargs={"pk": self.pk})
199 if not self.payment and self.pk
200 else None
201 )
203 def __str__(self):
204 return f"Order {self.id} ({self.shift})"
207class OrderItem(models.Model):
208 class Meta:
209 verbose_name = "item"
210 verbose_name_plural = "items"
211 ordering = ["pk"]
212 indexes = [
213 models.Index(fields=["order"]),
214 ]
216 product = models.ForeignKey(
217 ProductListItem,
218 verbose_name=_("product"),
219 null=True,
220 blank=False,
221 on_delete=models.SET_NULL,
222 )
223 order = models.ForeignKey(
224 Order,
225 verbose_name=_("order"),
226 related_name="order_items",
227 null=False,
228 blank=False,
229 on_delete=models.CASCADE,
230 )
231 total = PaymentAmountField(
232 verbose_name=_("total"),
233 allow_zero=True,
234 null=False,
235 blank=True,
236 validators=[MinValueValidator(Decimal("0.00"))],
237 help_text="Only when overriding the default",
238 )
239 amount = models.PositiveSmallIntegerField(
240 verbose_name=_("amount"), null=False, blank=False
241 )
242 product_name = models.CharField(
243 verbose_name=_("product name"),
244 max_length=50,
245 null=False,
246 blank=True,
247 )
249 def save(
250 self, force_insert=False, force_update=False, using=None, update_fields=None
251 ):
252 if self.order.shift.locked: 252 ↛ 253line 252 didn't jump to line 253 because the condition on line 252 was never true
253 raise ValueError("The shift this order belongs to is locked.")
254 if self.order.payment:
255 raise ValueError("This order has already been paid for.")
257 if self.amount == 0: 257 ↛ 258line 257 didn't jump to line 258 because the condition on line 257 was never true
258 if self.pk:
259 self.delete()
260 else:
261 return
263 if not self.total:
264 self.total = self.product.price * self.amount
266 if self.product: 266 ↛ 269line 266 didn't jump to line 269 because the condition on line 266 was always true
267 self.product_name = self.product.product_name
269 super().save(force_insert, force_update, using, update_fields)
271 self.order.save()
273 def clean(self):
274 super().clean()
275 errors = {}
277 if self.order.shift.locked: 277 ↛ 278line 277 didn't jump to line 278 because the condition on line 277 was never true
278 errors.update({"order": _("The shift is locked.")})
280 if self.product not in self.order.shift.product_list.product_items.all(): 280 ↛ 283line 280 didn't jump to line 283 because the condition on line 280 was always true
281 errors.update({"product": _("This product is not available.")})
283 if errors: 283 ↛ exitline 283 didn't return from function 'clean' because the condition on line 283 was always true
284 raise ValidationError(errors)
286 def __str__(self):
287 return f"{self.amount}x {self.product_name}"
289 def delete(self, using=None, keep_parents=False):
290 super().delete(using, keep_parents)
291 self.order.save()