From c9ae3220258125475ecf3a6d4656c0a7e17ddd82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Sat, 21 Apr 2018 23:06:41 +0200 Subject: [PATCH 1/2] Added the first tests to the project. Using factory_boy for great success. --- src/requirements/test.txt | 3 +++ src/shop/factories.py | 51 +++++++++++++++++++++++++++++++++++++++ src/shop/models.py | 2 +- src/shop/tests.py | 30 +++++++++++++++++++++++ src/utils/factories.py | 11 +++++++++ 5 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 src/requirements/test.txt create mode 100644 src/shop/factories.py create mode 100644 src/shop/tests.py create mode 100644 src/utils/factories.py diff --git a/src/requirements/test.txt b/src/requirements/test.txt new file mode 100644 index 00000000..a0232901 --- /dev/null +++ b/src/requirements/test.txt @@ -0,0 +1,3 @@ +-r dev.txt + +factory_boy==2.10.0 diff --git a/src/shop/factories.py b/src/shop/factories.py new file mode 100644 index 00000000..9b0c5e0c --- /dev/null +++ b/src/shop/factories.py @@ -0,0 +1,51 @@ +import factory + +from factory.django import DjangoModelFactory + +from django.utils import timezone + +from psycopg2.extras import DateTimeTZRange + +from utils.factories import UserFactory + + +class ProductCategoryFactory(DjangoModelFactory): + class Meta: + model = 'shop.ProductCategory' + + name = factory.Faker('word') + + +class ProductFactory(DjangoModelFactory): + class Meta: + model = 'shop.Product' + + name = factory.Faker('word') + slug = factory.Faker('word') + category = factory.SubFactory(ProductCategoryFactory) + description = factory.Faker('paragraph') + price = factory.Faker('pyint') + available_in = factory.LazyFunction( + lambda: + DateTimeTZRange( + lower=timezone.now(), + upper=timezone.now() + timezone.timedelta(31) + ) + ) + + +class OrderFactory(DjangoModelFactory): + class Meta: + model = 'shop.Order' + + user = factory.SubFactory(UserFactory) + + +class OrderProductRelationFactory(DjangoModelFactory): + class Meta: + model = 'shop.OrderProductRelation' + + product = factory.SubFactory(ProductFactory) + order = factory.SubFactory(OrderFactory) + quantity = 1 + handed_out = False diff --git a/src/shop/models.py b/src/shop/models.py index cca68232..a3ab1329 100644 --- a/src/shop/models.py +++ b/src/shop/models.py @@ -351,7 +351,7 @@ class Product(CreatedUpdatedModel, UUIDModel): """ now = timezone.now() time_available = now in self.available_in - stock_available = (self.stock_amount - self.left_in_stock()) > 0 + stock_available = self.left_in_stock() > 0 return time_available and stock_available def is_old(self): diff --git a/src/shop/tests.py b/src/shop/tests.py new file mode 100644 index 00000000..daca2dd5 --- /dev/null +++ b/src/shop/tests.py @@ -0,0 +1,30 @@ +from django.test import TestCase + +from .factories import ( + ProductFactory, + OrderFactory, + OrderProductRelationFactory, +) + + +class ProductAvailabilityTest(TestCase): + """ Test logic about availability of products. """ + + def test_product_available_by_stock(self): + """ If no orders have been made, the product is still available. """ + product = ProductFactory(stock_amount=10) + self.assertEqual(product.left_in_stock(), 10) + self.assertTrue(product.is_available) + + def test_product_not_available_by_stock(self): + """ If max orders have been made, the product is NOT available. """ + product = ProductFactory(stock_amount=2) + + for i in range(2): + opr = OrderProductRelationFactory(product=product) + order = opr.order + order.paid = True + order.save() + + self.assertEqual(product.left_in_stock(), 0) + self.assertFalse(product.is_available()) diff --git a/src/utils/factories.py b/src/utils/factories.py new file mode 100644 index 00000000..6cdf524e --- /dev/null +++ b/src/utils/factories.py @@ -0,0 +1,11 @@ +import factory +from factory.django import DjangoModelFactory + + +class UserFactory(DjangoModelFactory): + class Meta: + model = 'auth.User' + + username = factory.Faker('word') + email = factory.Faker('ascii_email') + From ac68daf0b68c96a2c574c2b3827a34ebaace03c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Tue, 24 Apr 2018 18:06:19 +0200 Subject: [PATCH 2/2] Update admin. Fix some more tests. Add stock info to template. --- src/shop/admin.py | 10 +++++ src/shop/models.py | 32 ++++++++++++---- src/shop/templates/product_detail.html | 10 ++++- src/shop/templates/shop_index.html | 6 ++- src/shop/tests.py | 52 ++++++++++++++++++++++++-- 5 files changed, 97 insertions(+), 13 deletions(-) diff --git a/src/shop/admin.py b/src/shop/admin.py index 426ec96b..b4550f4c 100644 --- a/src/shop/admin.py +++ b/src/shop/admin.py @@ -90,6 +90,15 @@ def available_to(product): available_to.short_description = 'Available to' +def stock_info(product): + if product.stock_amount: + return "{} / {}".format( + product.left_in_stock, + product.stock_amount + ) + return "N/A" + + @admin.register(Product) class ProductAdmin(admin.ModelAdmin): list_display = [ @@ -98,6 +107,7 @@ class ProductAdmin(admin.ModelAdmin): 'ticket_type', 'price', 'description', + stock_info, available_from, available_to ] diff --git a/src/shop/models.py b/src/shop/models.py index a3ab1329..adf97027 100644 --- a/src/shop/models.py +++ b/src/shop/models.py @@ -349,10 +349,16 @@ class Product(CreatedUpdatedModel, UUIDModel): - Whether now is in the self.available_in - If a stock is defined, that there are items left """ + predicates = [self.is_time_available] + if self.stock_amount: + predicates.append(self.is_stock_available) + return all(predicates) + + @property + def is_time_available(self): now = timezone.now() time_available = now in self.available_in - stock_available = self.left_in_stock() > 0 - return time_available and stock_available + return time_available def is_old(self): now = timezone.now() @@ -364,16 +370,26 @@ class Product(CreatedUpdatedModel, UUIDModel): now = timezone.now() return self.available_in.lower > now + @property def left_in_stock(self): - sold = OrderProductRelation.objects.filter( - product=self, - order__paid=True, - ).aggregate(Sum('quantity'))['quantity__sum'] + if self.stock_amount: + sold = OrderProductRelation.objects.filter( + product=self, + order__paid=True, + ).aggregate(Sum('quantity'))['quantity__sum'] - total_left = self.stock_amount - (sold or 0) + total_left = self.stock_amount - (sold or 0) - return total_left + return total_left + return None + @property + def is_stock_available(self): + if self.stock_amount: + stock_available = self.left_in_stock > 0 + return stock_available + # If there is no stock defined the product is generally available. + return True class OrderProductRelation(CreatedUpdatedModel): order = models.ForeignKey('shop.Order', on_delete=models.PROTECT) diff --git a/src/shop/templates/product_detail.html b/src/shop/templates/product_detail.html index 25799e63..e73081c4 100644 --- a/src/shop/templates/product_detail.html +++ b/src/shop/templates/product_detail.html @@ -25,15 +25,21 @@
- {% if product.stock_amount %} + {% if product.stock_amount and product.is_time_available %}

Availability
+ {% if product.left_in_stock > 0 %} {{ product.left_in_stock }} available
+ {% else %} + Sold out. + {% endif %}


{% endif %} + {% if product.is_stock_available %} +

Add to order

{% if user.is_authenticated %} @@ -67,6 +73,8 @@ to order this product {% endif %} + {% endif %} + diff --git a/src/shop/templates/shop_index.html b/src/shop/templates/shop_index.html index b9f805ba..c0915e5d 100644 --- a/src/shop/templates/shop_index.html +++ b/src/shop/templates/shop_index.html @@ -47,7 +47,6 @@ Shop | {{ block.super }} - {% endifchanged %} @@ -56,6 +55,11 @@ Shop | {{ block.super }} {{ product.name }} + {% if product.stock_amount and product.left_in_stock <= 10 %} +
+ Only {{ product.left_in_stock }} left! +
+ {% endif %}
diff --git a/src/shop/tests.py b/src/shop/tests.py index daca2dd5..809f2a5e 100644 --- a/src/shop/tests.py +++ b/src/shop/tests.py @@ -1,4 +1,7 @@ from django.test import TestCase +from django.utils import timezone + +from psycopg2.extras import DateTimeTZRange from .factories import ( ProductFactory, @@ -13,8 +16,8 @@ class ProductAvailabilityTest(TestCase): def test_product_available_by_stock(self): """ If no orders have been made, the product is still available. """ product = ProductFactory(stock_amount=10) - self.assertEqual(product.left_in_stock(), 10) - self.assertTrue(product.is_available) + self.assertEqual(product.left_in_stock, 10) + self.assertTrue(product.is_available()) def test_product_not_available_by_stock(self): """ If max orders have been made, the product is NOT available. """ @@ -26,5 +29,48 @@ class ProductAvailabilityTest(TestCase): order.paid = True order.save() - self.assertEqual(product.left_in_stock(), 0) + self.assertEqual(product.left_in_stock, 0) + self.assertFalse(product.is_stock_available) self.assertFalse(product.is_available()) + + def test_product_available_by_time(self): + """ The product is available if now is in the right timeframe. """ + product = ProductFactory() + # The factory defines the timeframe as now and 31 days forward. + self.assertTrue(product.is_time_available) + self.assertTrue(product.is_available()) + + def test_product_not_available_by_time(self): + """ The product is not available if now is outside the timeframe. """ + available_in = DateTimeTZRange( + lower=timezone.now() - timezone.timedelta(5), + upper=timezone.now() - timezone.timedelta(1) + ) + product = ProductFactory(available_in=available_in) + # The factory defines the timeframe as now and 31 days forward. + self.assertFalse(product.is_time_available) + self.assertFalse(product.is_available()) + + def test_product_is_not_available_yet(self): + """ The product is not available because we are before lower bound. """ + available_in = DateTimeTZRange( + lower=timezone.now() + timezone.timedelta(5) + ) + product = ProductFactory(available_in=available_in) + # Make sure there is no upper - just in case. + self.assertEqual(product.available_in.upper, None) + # The factory defines the timeframe as now and 31 days forward. + self.assertFalse(product.is_time_available) + self.assertFalse(product.is_available()) + + def test_product_is_available_from_now_on(self): + """ The product is available because we are after lower bound. """ + available_in = DateTimeTZRange( + lower=timezone.now() - timezone.timedelta(1) + ) + product = ProductFactory(available_in=available_in) + # Make sure there is no upper - just in case. + self.assertEqual(product.available_in.upper, None) + # The factory defines the timeframe as now and 31 days forward. + self.assertTrue(product.is_time_available) + self.assertTrue(product.is_available())