Skip to content

Commit 96756b7

Browse files
authored
Refactor webshop product labels (#2005)
* Implement business logic for webshop labels in model property * Use templatetag for mapping 'label_type' to a css class
1 parent cc38b6d commit 96756b7

File tree

6 files changed

+160
-38
lines changed

6 files changed

+160
-38
lines changed

src/shop/models.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -580,7 +580,7 @@ def available_for_days(self):
580580

581581
@property
582582
def left_in_stock(self):
583-
if self.stock_amount:
583+
if self.stock_amount is not None:
584584
# All orders that are not open and not cancelled count towards what has
585585
# been "reserved" from stock.
586586
#
@@ -604,6 +604,42 @@ def is_stock_available(self):
604604
# If there is no stock defined the product is generally available.
605605
return True
606606

607+
@property
608+
def labels(self) -> list:
609+
"""Return list of label objects for this product."""
610+
labels = []
611+
612+
if self.sub_products.all().exists():
613+
labels.append({
614+
"type": "bundle",
615+
"text": "Bundle",
616+
})
617+
618+
if self.stock_amount is not None:
619+
if self.left_in_stock < 1 or not self.is_time_available:
620+
labels.insert(0, {
621+
"type": "sold_out",
622+
"text": "Sold out!",
623+
})
624+
625+
# Sold out is an exclusive state - no further labels apply
626+
return labels
627+
628+
elif self.left_in_stock <= 10:
629+
labels.append({
630+
"type": "low_stock",
631+
"text": f"Only {self.left_in_stock} left!",
632+
})
633+
634+
if self.available_for_days < 20:
635+
labels.append({
636+
"type": "ending_soon",
637+
"text": f"Sales end in {self.available_for_days} days!",
638+
})
639+
640+
return labels
641+
642+
607643

608644
class SubProductRelation(
609645
ExportModelOperationsMixin("sub_product_relation"),
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{% load label_mapping %}
2+
3+
<span class="badge {{ label.type|css_class }}">{{ label.text }}</span>

src/shop/templates/shop_index.html

Lines changed: 5 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -58,43 +58,11 @@
5858
{{ product.name }}
5959
</a>
6060

61-
62-
{% if product.stock_amount %}
63-
{% if product.left_in_stock <= 0 or not product.is_time_available %}
64-
<div class="label label-danger" style="margin-left: 1em;"><!-- We can replace the style when we upgrade to Bootstrap 5. -->
65-
Sold out!
66-
</div>
67-
{% else %}
68-
69-
{% if product.left_in_stock <= 10 %}
70-
<div class="label label-info" style="margin-left: 1em;">
71-
Only {{ product.left_in_stock }} left!
72-
</div>
73-
{% endif %}
74-
75-
{% endif %}
76-
77-
{% if product.left_in_stock > 0 %}
78-
<div class="label label-info" style="margin-left: 1em;">
79-
Sales end in {{ product.available_for_days }} days!
80-
</dev>
81-
{% endif %}
82-
83-
{% else %}
84-
85-
{% if product.available_for_days < 20 %}
86-
<div class="label label-info" style="margin-left: 1em;">
87-
Sales end in {{ product.available_for_days }} days!
88-
</dev>
89-
{% endif %}
90-
91-
{% endif %}
92-
93-
{% if product.has_subproducts %}
94-
<div class="label label-info" style="margin-left: 1em;"><!-- We can replace the style when we upgrade to Bootstrap 5. -->
95-
Bundle
96-
</div>
97-
{% endif %}
61+
<div class="ms-3">
62+
{% for label in product.labels %}
63+
{% include 'labels/default.html' with label=label %}
64+
{% endfor %}
65+
</div>
9866

9967
</td>
10068
<td>

src/shop/templatetags/__init__.py

Whitespace-only changes.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from django import template
2+
3+
register = template.Library()
4+
5+
@register.filter(name="css_class")
6+
def css_class(value: str) -> str:
7+
"""Templatetag for mapping a 'label_type' to a css class."""
8+
map = {
9+
"sold_out": "text-bg-danger",
10+
"low_stock": "text-bg-warning",
11+
"ending_soon": "text-bg-secondary",
12+
"bundle": "text-bg-info",
13+
}
14+
15+
return map.get(value, "text-bg-primary")

src/shop/tests.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,106 @@ def test_product_is_available_from_now_on(self):
9898
self.assertTrue(product.is_available())
9999

100100

101+
class ProductLabelsTest(TestCase):
102+
"""Test logic about labels for products."""
103+
104+
def test_labels_for_product_not_available_by_stock(self):
105+
"""Test product.labels returns a 'sold_out' label object."""
106+
product = ProductFactory(stock_amount=1)
107+
OrderProductRelationFactory(product=product, order__open=None)
108+
109+
result = product.labels[0]
110+
111+
assert result.get("type") == "sold_out"
112+
assert result.get("text") == "Sold out!"
113+
114+
def test_labels_for_product_avoid_other_labels_when_sold_out(self):
115+
"""Test product.labels not returning other labels when being sold out."""
116+
available_in = DateTimeTZRange(
117+
lower=timezone.now(),
118+
upper=timezone.now() + timezone.timedelta(6),
119+
)
120+
121+
# With available_in
122+
product = ProductFactory(stock_amount=0, available_in=available_in)
123+
124+
result = product.labels[0]
125+
126+
assert len(product.labels) == 1
127+
assert result.get("type") == "sold_out"
128+
assert result.get("text") == "Sold out!"
129+
130+
# Without available_in
131+
product = ProductFactory(stock_amount=0)
132+
133+
result = product.labels[0]
134+
135+
assert len(product.labels) == 1
136+
assert result.get("type") == "sold_out"
137+
assert result.get("text") == "Sold out!"
138+
139+
def test_labels_for_product_with_stock_below_or_equal_to_10(self):
140+
"""Test the product.labels returns a 'low_stock' label object."""
141+
product = ProductFactory(stock_amount=11)
142+
143+
# No label with stock_amount=11
144+
assert len(product.labels) == 0
145+
146+
OrderProductRelationFactory(product=product, order__open=None)
147+
result = product.labels[0]
148+
149+
assert result.get("type") == "low_stock"
150+
assert result.get("text") == "Only 10 left!"
151+
152+
def test_labels_for_product_ending_within_20_days(self):
153+
"""Test the product returns a 'ending_soon' label object."""
154+
available_in = DateTimeTZRange(
155+
lower=timezone.now(),
156+
upper=timezone.now() + timezone.timedelta(6),
157+
)
158+
159+
# With stock
160+
product = ProductFactory(stock_amount=11, available_in=available_in)
161+
162+
result = product.labels[0]
163+
164+
assert result.get("type") == "ending_soon"
165+
assert result.get("text") == "Sales end in 5 days!"
166+
167+
# Without stock
168+
product = ProductFactory(available_in=available_in)
169+
170+
result = product.labels[0]
171+
172+
assert result.get("type") == "ending_soon"
173+
assert result.get("text") == "Sales end in 5 days!"
174+
175+
def test_labels_for_product_is_empty_when_no_label_applies(self):
176+
"""
177+
Test the product.labels returns an empty list when no label applies.
178+
"""
179+
product = ProductFactory()
180+
181+
assert len(product.labels) == 0
182+
183+
def test_labels_for_product_when_being_a_bundle(self):
184+
"""Test the product.labels when product has subproduct (bundle)."""
185+
bundle_product = ProductFactory()
186+
sub_product = ProductFactory(
187+
ticket_type=TicketTypeFactory(single_ticket_per_product=False),
188+
)
189+
bundle_product.sub_products.add(
190+
sub_product,
191+
through_defaults={"number_of_tickets": 5},
192+
)
193+
194+
result = bundle_product.labels[0]
195+
196+
assert len(bundle_product.labels) == 1
197+
assert result.get("type") == "bundle"
198+
assert result.get("text") == "Bundle"
199+
200+
101201
class TestOrderProductRelationForm(TestCase):
102202
def test_clean_quantity_succeeds_when_stock_not_exceeded(self):
103203
product = ProductFactory(stock_amount=2)

0 commit comments

Comments
 (0)