Coverage for website/sales/models/shift.py: 91.18%
58 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
1from django.core.exceptions import ValidationError
2from django.db import models
3from django.db.models import Count, Q, Sum
4from django.db.models.expressions import Value
5from django.db.models.functions import Coalesce
6from django.utils import timezone
7from django.utils.translation import gettext_lazy as _
9from queryable_properties.managers import QueryablePropertiesManager
10from queryable_properties.properties import AggregateProperty, RangeCheckProperty
12from activemembers.models import MemberGroup
13from events.models import Event
14from payments.models import PaymentAmountField
15from sales.models.product import ProductList
18class Shift(models.Model):
19 class Meta:
20 permissions = [
21 ("override_manager", _("Can access all shifts as manager")),
22 ]
24 objects = QueryablePropertiesManager()
26 start = models.DateTimeField(
27 verbose_name=_("start"),
28 blank=False,
29 null=False,
30 )
31 end = models.DateTimeField(
32 verbose_name=_("end"),
33 blank=False,
34 null=False,
35 help_text=_(
36 "The end time is only indicative and does not prevent orders being created after the shift has ended. This only happens after locking the shift."
37 ),
38 )
40 event = models.ForeignKey(
41 Event,
42 verbose_name=_("linked event"),
43 blank=True,
44 null=True,
45 on_delete=models.SET_NULL,
46 )
48 title = models.CharField(
49 verbose_name=_("title"), blank=True, null=True, max_length=100
50 )
52 product_list = models.ForeignKey(
53 ProductList,
54 verbose_name=_("product list"),
55 blank=False,
56 null=False,
57 on_delete=models.PROTECT,
58 )
60 managers = models.ManyToManyField(
61 MemberGroup, verbose_name=_("managers"), related_name="manager_shifts"
62 )
64 locked = models.BooleanField(
65 verbose_name=_("locked"),
66 blank=False,
67 null=False,
68 default=False,
69 help_text=_(
70 "Prevent orders being changed or created for this shift. This will also clean up all unpaid orders in this shift."
71 ),
72 )
74 selforder = models.BooleanField(
75 verbose_name=_("self-order"),
76 blank=False,
77 null=False,
78 default=False,
79 help_text=_("Allow users to order products for themselves for this shift."),
80 )
82 def clean(self):
83 super().clean()
84 errors = {}
86 if self.pk is not None and self.orders.filter(created_at__lt=self.start): 86 ↛ 87line 86 didn't jump to line 87 because the condition on line 86 was never true
87 errors.update(
88 {
89 "start": _(
90 "There are already orders created in this shift before this start time."
91 )
92 }
93 )
95 if self.end and self.start and self.end <= self.start: 95 ↛ 98line 95 didn't jump to line 98 because the condition on line 95 was always true
96 errors.update({"end": _("End cannot be before start.")})
98 if errors: 98 ↛ exitline 98 didn't return from function 'clean' because the condition on line 98 was always true
99 raise ValidationError(errors)
101 def save(
102 self, force_insert=False, force_update=False, using=None, update_fields=None
103 ):
104 if self.locked:
105 self.orders.filter(
106 (Q(payment__isnull=True) & Q(total_amount__gt=0))
107 | Q(order_items__isnull=True)
108 ).delete()
110 return super().save(force_insert, force_update, using, update_fields)
112 active = RangeCheckProperty("start", "end", timezone.now)
114 total_revenue = AggregateProperty(
115 Sum(
116 Coalesce("orders___total_amount", Value(0.00)),
117 output_field=PaymentAmountField(allow_zero=True),
118 )
119 )
121 total_revenue_paid = AggregateProperty(
122 Sum(
123 Coalesce("orders__payment__amount", Value(0.00)),
124 output_field=PaymentAmountField(allow_zero=True),
125 )
126 )
128 num_orders = AggregateProperty(
129 Count(
130 "orders",
131 )
132 )
134 num_orders_paid = AggregateProperty(
135 Count(
136 "orders",
137 filter=Q(orders___is_free=True)
138 | Q(
139 orders__payment__isnull=False, # or the order is free
140 ),
141 )
142 )
144 @property
145 def product_sales(self):
146 qs = (
147 self.orders.exclude(order_items__isnull=True)
148 .values("order_items__product")
149 .annotate(sold=Sum("order_items__amount"))
150 .order_by()
151 )
152 return {
153 item[0]: item[1]
154 for item in qs.values_list("order_items__product__product__name", "sold")
155 }
157 @property
158 def payment_method_sales(self):
159 qs = (
160 self.orders.values("payment__type")
161 .annotate(sold=Sum("order_items__total"))
162 .order_by()
163 )
164 return {item[0]: item[1] for item in qs.values_list("payment__type", "sold")}
166 @property
167 def user_orders_allowed(self):
168 return (
169 self.start <= timezone.now()
170 and self.end > timezone.now()
171 and self.selforder
172 and not self.locked # should be redundant, since locking should only happen after end
173 )
175 def __str__(self):
176 if self.title and self.title != "": 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true
177 return f"Shift {self.pk} - {self.title}"
178 return f"Shift {self.pk}"