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

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).only("pk").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 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 

194 

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 ) 

202 

203 def __str__(self): 

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

205 

206 

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 ] 

215 

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 ) 

248 

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

256 

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 

262 

263 if not self.total: 

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

265 

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 

268 

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

270 

271 self.order.save() 

272 

273 def clean(self): 

274 super().clean() 

275 errors = {} 

276 

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

279 

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

282 

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) 

285 

286 def __str__(self): 

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

288 

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

290 super().delete(using, keep_parents) 

291 self.order.save()