diff --git a/src/shop/models.py b/src/shop/models.py index b528320b5..32dcc81bb 100644 --- a/src/shop/models.py +++ b/src/shop/models.py @@ -580,7 +580,7 @@ def available_for_days(self): @property def left_in_stock(self): - if self.stock_amount: + if self.stock_amount is not None: # All orders that are not open and not cancelled count towards what has # been "reserved" from stock. # @@ -604,6 +604,42 @@ def is_stock_available(self): # If there is no stock defined the product is generally available. return True + @property + def labels(self) -> list: + """Return list of label objects for this product.""" + labels = [] + + if self.sub_products.all().exists(): + labels.append({ + "type": "bundle", + "text": "Bundle", + }) + + if self.stock_amount is not None: + if self.left_in_stock < 1 or not self.is_time_available: + labels.insert(0, { + "type": "sold_out", + "text": "Sold out!", + }) + + # Sold out is an exclusive state - no further labels apply + return labels + + elif self.left_in_stock <= 10: + labels.append({ + "type": "low_stock", + "text": f"Only {self.left_in_stock} left!", + }) + + if self.available_for_days < 20: + labels.append({ + "type": "ending_soon", + "text": f"Sales end in {self.available_for_days} days!", + }) + + return labels + + class SubProductRelation( ExportModelOperationsMixin("sub_product_relation"), diff --git a/src/shop/templates/labels/default.html b/src/shop/templates/labels/default.html new file mode 100644 index 000000000..b31baa8da --- /dev/null +++ b/src/shop/templates/labels/default.html @@ -0,0 +1,3 @@ +{% load label_mapping %} + +{{ label.text }} diff --git a/src/shop/templates/shop_index.html b/src/shop/templates/shop_index.html index 8f505ca72..0e79cf897 100644 --- a/src/shop/templates/shop_index.html +++ b/src/shop/templates/shop_index.html @@ -58,43 +58,11 @@ {{ product.name }} - - {% if product.stock_amount %} - {% if product.left_in_stock <= 0 or not product.is_time_available %} -
- Sold out! -
- {% else %} - - {% if product.left_in_stock <= 10 %} -
- Only {{ product.left_in_stock }} left! -
- {% endif %} - - {% endif %} - - {% if product.left_in_stock > 0 %} -
- Sales end in {{ product.available_for_days }} days! - - {% endif %} - - {% else %} - - {% if product.available_for_days < 20 %} -
- Sales end in {{ product.available_for_days }} days! - - {% endif %} - - {% endif %} - - {% if product.has_subproducts %} -
- Bundle -
- {% endif %} +
+ {% for label in product.labels %} + {% include 'labels/default.html' with label=label %} + {% endfor %} +
diff --git a/src/shop/templatetags/__init__.py b/src/shop/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/shop/templatetags/label_mapping.py b/src/shop/templatetags/label_mapping.py new file mode 100644 index 000000000..de655714b --- /dev/null +++ b/src/shop/templatetags/label_mapping.py @@ -0,0 +1,15 @@ +from django import template + +register = template.Library() + +@register.filter(name="css_class") +def css_class(value: str) -> str: + """Templatetag for mapping a 'label_type' to a css class.""" + map = { + "sold_out": "text-bg-danger", + "low_stock": "text-bg-warning", + "ending_soon": "text-bg-secondary", + "bundle": "text-bg-info", + } + + return map.get(value, "text-bg-primary") diff --git a/src/shop/tests.py b/src/shop/tests.py index b4db4498f..e4f243b7d 100644 --- a/src/shop/tests.py +++ b/src/shop/tests.py @@ -98,6 +98,106 @@ def test_product_is_available_from_now_on(self): self.assertTrue(product.is_available()) +class ProductLabelsTest(TestCase): + """Test logic about labels for products.""" + + def test_labels_for_product_not_available_by_stock(self): + """Test product.labels returns a 'sold_out' label object.""" + product = ProductFactory(stock_amount=1) + OrderProductRelationFactory(product=product, order__open=None) + + result = product.labels[0] + + assert result.get("type") == "sold_out" + assert result.get("text") == "Sold out!" + + def test_labels_for_product_avoid_other_labels_when_sold_out(self): + """Test product.labels not returning other labels when being sold out.""" + available_in = DateTimeTZRange( + lower=timezone.now(), + upper=timezone.now() + timezone.timedelta(6), + ) + + # With available_in + product = ProductFactory(stock_amount=0, available_in=available_in) + + result = product.labels[0] + + assert len(product.labels) == 1 + assert result.get("type") == "sold_out" + assert result.get("text") == "Sold out!" + + # Without available_in + product = ProductFactory(stock_amount=0) + + result = product.labels[0] + + assert len(product.labels) == 1 + assert result.get("type") == "sold_out" + assert result.get("text") == "Sold out!" + + def test_labels_for_product_with_stock_below_or_equal_to_10(self): + """Test the product.labels returns a 'low_stock' label object.""" + product = ProductFactory(stock_amount=11) + + # No label with stock_amount=11 + assert len(product.labels) == 0 + + OrderProductRelationFactory(product=product, order__open=None) + result = product.labels[0] + + assert result.get("type") == "low_stock" + assert result.get("text") == "Only 10 left!" + + def test_labels_for_product_ending_within_20_days(self): + """Test the product returns a 'ending_soon' label object.""" + available_in = DateTimeTZRange( + lower=timezone.now(), + upper=timezone.now() + timezone.timedelta(6), + ) + + # With stock + product = ProductFactory(stock_amount=11, available_in=available_in) + + result = product.labels[0] + + assert result.get("type") == "ending_soon" + assert result.get("text") == "Sales end in 5 days!" + + # Without stock + product = ProductFactory(available_in=available_in) + + result = product.labels[0] + + assert result.get("type") == "ending_soon" + assert result.get("text") == "Sales end in 5 days!" + + def test_labels_for_product_is_empty_when_no_label_applies(self): + """ + Test the product.labels returns an empty list when no label applies. + """ + product = ProductFactory() + + assert len(product.labels) == 0 + + def test_labels_for_product_when_being_a_bundle(self): + """Test the product.labels when product has subproduct (bundle).""" + bundle_product = ProductFactory() + sub_product = ProductFactory( + ticket_type=TicketTypeFactory(single_ticket_per_product=False), + ) + bundle_product.sub_products.add( + sub_product, + through_defaults={"number_of_tickets": 5}, + ) + + result = bundle_product.labels[0] + + assert len(bundle_product.labels) == 1 + assert result.get("type") == "bundle" + assert result.get("text") == "Bundle" + + class TestOrderProductRelationForm(TestCase): def test_clean_quantity_succeeds_when_stock_not_exceeded(self): product = ProductFactory(stock_amount=2)