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

1import uuid 

2from decimal import Decimal 

3 

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 _ 

13 

14from queryable_properties.managers import QueryablePropertiesManager 

15from queryable_properties.properties import AnnotationProperty 

16 

17from members.models import Member 

18from payments.models import Payment, PaymentAmountField 

19from sales.models.product import ProductListItem 

20from sales.models.shift import Shift 

21 

22 

23def default_order_shift(): 

24 return Shift.objects.filter(active=True).first() 

25 

26 

27class Order(models.Model): 

28 objects = QueryablePropertiesManager() 

29 

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"] 

37 

38 id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 

39 

40 created_at = models.DateTimeField( 

41 verbose_name=_("created at"), default=timezone.now 

42 ) 

43 

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 ) 

52 

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 ) 

62 

63 items = models.ManyToManyField( 

64 ProductListItem, 

65 through="OrderItem", 

66 verbose_name=_("items"), 

67 ) 

68 

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 ) 

77 

78 discount = PaymentAmountField( 

79 verbose_name=_("discount"), 

80 null=True, 

81 blank=True, 

82 validators=[MinValueValidator(Decimal("0.00"))], 

83 ) 

84 

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 ) 

93 

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 ) 

101 

102 subtotal = AnnotationProperty( 

103 Coalesce( 

104 Sum("order_items__total"), 

105 Value(0.00), 

106 output_field=PaymentAmountField(allow_zero=True), 

107 ) 

108 ) 

109 

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 ) 

120 

121 num_items = AnnotationProperty( 

122 Coalesce(Sum("order_items__amount"), Value(0), output_field=IntegerField()) 

123 ) 

124 

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 ) 

133 

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.") 

143 

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 

151 

152 self._is_free = bool(self._total_amount == 0) 

153 

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 

160 

161 return super().save(force_insert, force_update, using, update_fields) 

162 

163 def clean(self): 

164 super().clean() 

165 errors = {} 

166 

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.")}) 

169 

170 if self.discount and self.discount > self.total_amount: 

171 errors.update( 

172 {"discount": _("Discount cannot be higher than total amount.")} 

173 ) 

174 

175 if errors: 

176 raise ValidationError(errors) 

177 

178 @property 

179 def order_description(self): 

180 return ", ".join(str(x) for x in self.order_items.all()) 

181 

182 @property 

183 def accept_payment_from_any_user(self): 

184 return True 

185 

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 ) 

193 

194 def __str__(self): 

195 return f"Order {self.id} ({self.shift})" 

196 

197 

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 ] 

206 

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 ) 

239 

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.") 

247 

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 

253 

254 if not self.total: 

255 self.total = self.product.price * self.amount 

256 

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 

259 

260 super().save(force_insert, force_update, using, update_fields) 

261 

262 self.order.save() 

263 

264 def clean(self): 

265 super().clean() 

266 errors = {} 

267 

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.")}) 

270 

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.")}) 

273 

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) 

276 

277 def __str__(self): 

278 return f"{self.amount}x {self.product_name}" 

279 

280 def delete(self, using=None, keep_parents=False): 

281 super().delete(using, keep_parents) 

282 self.order.save()