From 56c2c7045d6c5bba539b737229749bbd45342519 Mon Sep 17 00:00:00 2001 From: Gabriel Augendre Date: Mon, 27 Mar 2023 16:45:00 +0200 Subject: [PATCH] Implement floating price items --- src/conftest.py | 1 - src/purchase/forms.py | 12 ++-- ...item_unique_product_per_basket_and_more.py | 24 +++++++ src/purchase/models.py | 19 ++++-- .../templates/purchase/basket_form.html | 41 +++++++++++- .../snippets/basket_unpriced_item.html | 23 +++++++ src/purchase/tests/test_cashier_flow.py | 55 +++++++++++++++- src/purchase/urls.py | 6 ++ src/purchase/views/basket.py | 63 ++++++++++++++++--- 9 files changed, 218 insertions(+), 26 deletions(-) create mode 100644 src/purchase/migrations/0013_remove_basketitem_unique_product_per_basket_and_more.py create mode 100644 src/purchase/templates/purchase/snippets/basket_unpriced_item.html diff --git a/src/conftest.py b/src/conftest.py index 31fa025..df43152 100644 --- a/src/conftest.py +++ b/src/conftest.py @@ -15,7 +15,6 @@ def live_server(settings, live_server): @pytest.fixture() def firefox_options(firefox_options): - firefox_options.add_argument("-headless") return firefox_options diff --git a/src/purchase/forms.py b/src/purchase/forms.py index fd1c284..8e73fb8 100644 --- a/src/purchase/forms.py +++ b/src/purchase/forms.py @@ -7,7 +7,8 @@ from django.utils.translation import gettext as _ from purchase.layout import BasketItemField from purchase.models import Basket, Product -PREFIX = "product-" +PRICED_PREFIX = "product-" +UNPRICED_PREFIX = "unpriced_product-" class BasketForm(forms.ModelForm): @@ -31,8 +32,8 @@ class BasketForm(forms.ModelForm): for item in basket.items.all(): products[item.product] = item.quantity fields = [] - for product in Product.objects.all(): - field_name = f"{PREFIX}{product.id}" + for product in Product.objects.with_fixed_price(): + field_name = f"{PRICED_PREFIX}{product.id}" self.fields.update( { field_name: forms.IntegerField( @@ -47,6 +48,7 @@ class BasketForm(forms.ModelForm): Div( *fields, css_class="row row-cols-2 row-cols-sm-3 row-cols-md-4 row-cols-lg-5 row-cols-xl-6 g-4", + css_id="products", ), InlineRadios("payment_method"), ) @@ -56,8 +58,8 @@ class BasketForm(forms.ModelForm): name: str products = {product.id: product for product in Product.objects.all()} for name, value in self.cleaned_data.items(): - if name.startswith(PREFIX): - product_id = int(name.removeprefix(PREFIX)) + if name.startswith(PRICED_PREFIX): + product_id = int(name.removeprefix(PRICED_PREFIX)) product = products[product_id] if value > 0: instance.items.update_or_create( diff --git a/src/purchase/migrations/0013_remove_basketitem_unique_product_per_basket_and_more.py b/src/purchase/migrations/0013_remove_basketitem_unique_product_per_basket_and_more.py new file mode 100644 index 0000000..77a0603 --- /dev/null +++ b/src/purchase/migrations/0013_remove_basketitem_unique_product_per_basket_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 4.1.7 on 2023-03-27 13:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("purchase", "0012_rename_value_cache_etag_cache_last_modified"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="basketitem", + name="unique_product_per_basket", + ), + migrations.AlterField( + model_name="product", + name="unit_price_cents", + field=models.PositiveIntegerField( + help_text="Unit price in cents. Use zero to denote that the product has no fixed price.", + verbose_name="unit price (cents)", + ), + ), + ] diff --git a/src/purchase/models.py b/src/purchase/models.py index 80d3902..22e7b26 100644 --- a/src/purchase/models.py +++ b/src/purchase/models.py @@ -4,7 +4,7 @@ import hashlib import uuid from django.db import models -from django.db.models import Avg, Count, F, Sum, UniqueConstraint +from django.db.models import Avg, Count, F, Sum from django.db.models.functions import Coalesce from django.urls import reverse from django.utils.translation import gettext @@ -77,6 +77,12 @@ class ProductQuerySet(models.QuerySet): def with_sold(self): return self.annotate(sold=Coalesce(Sum("basket_items__quantity"), 0)) + def with_fixed_price(self): + return self.exclude(unit_price_cents=0) + + def with_no_fixed_price(self): + return self.filter(unit_price_cents=0) + class ProductManager(models.Manager): def get_by_natural_key(self, name): @@ -88,7 +94,9 @@ class Product(Model): image = models.ImageField(null=True, blank=True, verbose_name=_("image")) unit_price_cents = models.PositiveIntegerField( verbose_name=_("unit price (cents)"), - help_text=_("unit price in cents"), + help_text=_( + "Unit price in cents. Use zero to denote that the product has no fixed price.", + ), ) display_order = models.PositiveIntegerField( default=default_product_display_order, @@ -115,6 +123,10 @@ class Product(Model): base=16, ) + @property + def has_fixed_price(self) -> bool: + return self.unit_price_cents > 0 + def save(self, *args, **kwargs): super().save(*args, **kwargs) if not self.image: @@ -223,9 +235,6 @@ class BasketItem(Model): class Meta: verbose_name = _("basket item") verbose_name_plural = _("basket items") - constraints = [ - UniqueConstraint("product", "basket", name="unique_product_per_basket"), - ] class Cache(SingletonModel): diff --git a/src/purchase/templates/purchase/basket_form.html b/src/purchase/templates/purchase/basket_form.html index 95eb51c..f063e53 100644 --- a/src/purchase/templates/purchase/basket_form.html +++ b/src/purchase/templates/purchase/basket_form.html @@ -1,7 +1,6 @@ {% extends "common/base.html" %} -{% load static %} -{% load i18n %} -{% load crispy_forms_tags purchase %} +{% load i18n static crispy_forms_tags purchase django_htmx %} + {% block extrahead %} {% endblock %} @@ -18,7 +17,43 @@

{% translate "New basket" %}

{% endif %} {% crispy form %} +
+
+ + +
+
+ {% for item in basket.items.all %} + {% if item.product.unit_price_cents == 0 %} + + {% endif %} + {% endfor %} {% if basket %} {% translate "New" %} {% endif %} {% endblock %} + +{% block extrascript %} + + {% django_htmx_script %} +{% endblock %} diff --git a/src/purchase/templates/purchase/snippets/basket_unpriced_item.html b/src/purchase/templates/purchase/snippets/basket_unpriced_item.html new file mode 100644 index 0000000..4cfaeeb --- /dev/null +++ b/src/purchase/templates/purchase/snippets/basket_unpriced_item.html @@ -0,0 +1,23 @@ +{% load crispy_forms_field %} +
+
+ {% if product.image %} + + {% else %} +
+ + {{ product.name|slice:"1" }} + +
+ {% endif %} +
+

{{ product.name }}

+
+ +
+
+
+
diff --git a/src/purchase/tests/test_cashier_flow.py b/src/purchase/tests/test_cashier_flow.py index a4365df..4b44597 100644 --- a/src/purchase/tests/test_cashier_flow.py +++ b/src/purchase/tests/test_cashier_flow.py @@ -6,6 +6,7 @@ from pytest_django.live_server_helper import LiveServer from selenium.webdriver import ActionChains, Keys from selenium.webdriver.common.by import By from selenium.webdriver.remote.webdriver import WebDriver +from selenium.webdriver.support.select import Select from selenium.webdriver.support.wait import WebDriverWait from common.models import User @@ -34,6 +35,11 @@ def test_cashier_create_and_update_basket( # noqa: PLR0915 ProductFactory(), ProductFactory(), ] + unpriced_products = [ + ProductFactory(unit_price_cents=0), + ProductFactory(unit_price_cents=0), + ProductFactory(unit_price_cents=0), + ] payment_methods = [ PaymentMethodFactory(), PaymentMethodFactory(), @@ -85,6 +91,29 @@ def test_cashier_create_and_update_basket( # noqa: PLR0915 chain.double_click(quantity_input).perform() quantity_input.send_keys("4") + # Add non-fixed priced product + select = Select(selenium.find_element(By.ID, "product_to_add")) + unpriced_product = unpriced_products[1] + select.select_by_value(str(unpriced_product.pk)) + selenium.find_element(By.ID, "add_product").click() + selenium.find_element(By.ID, "add_product").click() + elements = selenium.find_elements( + By.CSS_SELECTOR, + f"[data-product-id='{unpriced_product.pk}']", + ) + for elem in elements: + assert ( + elem.find_element(By.CLASS_NAME, "card-title").text == unpriced_product.name + ) + price_input = elements[0].find_element(By.CLASS_NAME, "numberinput") + chain = ActionChains(selenium) + chain.double_click(price_input).perform() + price_input.send_keys("237") + price_input = elements[1].find_element(By.CLASS_NAME, "numberinput") + chain = ActionChains(selenium) + chain.double_click(price_input).perform() + price_input.send_keys("401") + # Don't add payment method # Save selenium.find_element(By.ID, "submit-id-submit").click() @@ -93,7 +122,7 @@ def test_cashier_create_and_update_basket( # noqa: PLR0915 assert Basket.objects.count() == 1 basket = Basket.objects.priced().first() assert basket.payment_method is None - assert basket.items.count() == 2 + assert basket.items.count() == 4 assert basket.items.get(product=products[0]).quantity == 2 assert ( basket.items.get(product=products[0]).unit_price_cents @@ -104,6 +133,14 @@ def test_cashier_create_and_update_basket( # noqa: PLR0915 basket.items.get(product=products[1]).unit_price_cents == products[1].unit_price_cents ) + unpriced_basket_items = basket.items.filter(product=unpriced_product).order_by( + "unit_price_cents", + ) + assert len(unpriced_basket_items) == 2 + assert unpriced_basket_items[0].quantity == 1 + assert unpriced_basket_items[0].unit_price_cents == 237 + assert unpriced_basket_items[1].quantity == 1 + assert unpriced_basket_items[1].unit_price_cents == 401 # Assert redirected to basket update view redirect_url = live_reverse(live_server, "purchase:update", pk=basket.pk) @@ -141,6 +178,12 @@ def test_cashier_create_and_update_basket( # noqa: PLR0915 quantity = int(quantity_input.get_attribute("value")) assert quantity == 0 + elements = selenium.find_elements( + By.CSS_SELECTOR, + f"[data-product-id='{unpriced_product.pk}']", + ) + assert len(elements) == 2, "Unpriced products should be displayed" + # Click on - on product 2 displayed_product = displayed_products[1] displayed_product.find_element(By.CLASS_NAME, "btn-danger").click() @@ -162,7 +205,7 @@ def test_cashier_create_and_update_basket( # noqa: PLR0915 assert Basket.objects.count() == 1 basket = Basket.objects.priced().first() assert basket.payment_method == payment_methods[1] - assert basket.items.count() == 2 + assert basket.items.count() == 4 assert basket.items.get(product=products[0]).quantity == 2 assert ( basket.items.get(product=products[0]).unit_price_cents @@ -173,6 +216,14 @@ def test_cashier_create_and_update_basket( # noqa: PLR0915 basket.items.get(product=products[1]).unit_price_cents == products[1].unit_price_cents ) + unpriced_basket_items = basket.items.filter(product=unpriced_product).order_by( + "unit_price_cents", + ) + assert len(unpriced_basket_items) == 2 + assert unpriced_basket_items[0].quantity == 1 + assert unpriced_basket_items[0].unit_price_cents == 237 + assert unpriced_basket_items[1].quantity == 1 + assert unpriced_basket_items[1].unit_price_cents == 401 # Assert redirected to same view redirect_url = live_reverse(live_server, "purchase:update", pk=basket.pk) diff --git a/src/purchase/urls.py b/src/purchase/urls.py index ef4545b..0f60b6b 100644 --- a/src/purchase/urls.py +++ b/src/purchase/urls.py @@ -1,6 +1,7 @@ from django.urls import path from purchase.views import delete_basket, list_baskets, new_basket, update_basket +from purchase.views.basket import additional_unpriced_product from purchase.views.reports import by_hour_plot_view, products_plots_view, reports app_name = "purchase" @@ -9,6 +10,11 @@ urlpatterns = [ path("new/", new_basket, name="new"), path("/update/", update_basket, name="update"), path("/delete/", delete_basket, name="delete"), + path( + "additional_unpriced_product/", + additional_unpriced_product, + name="additional_unpriced_product", + ), path("reports/", reports, name="reports"), # plots path("reports/products_plots/", products_plots_view, name="products_plots"), diff --git a/src/purchase/views/basket.py b/src/purchase/views/basket.py index f83caaf..f9d7a97 100644 --- a/src/purchase/views/basket.py +++ b/src/purchase/views/basket.py @@ -1,25 +1,33 @@ +import logging + from django.contrib import messages from django.contrib.auth.decorators import permission_required +from django.core.handlers.wsgi import WSGIRequest from django.http import HttpRequest, HttpResponse -from django.shortcuts import get_object_or_404, redirect +from django.shortcuts import get_object_or_404, redirect, render from django.template.response import TemplateResponse from django.urls import reverse +from django.utils.datastructures import MultiValueDict from django.utils.translation import gettext_lazy as _ from django.views.decorators.http import condition, require_http_methods +from django_htmx.http import trigger_client_event -from purchase.forms import BasketForm -from purchase.models import Basket, reports_etag, reports_last_modified +from purchase.forms import UNPRICED_PREFIX, BasketForm +from purchase.models import Basket, Product, reports_etag, reports_last_modified + +logger = logging.getLogger(__name__) @require_http_methods(["GET", "POST"]) @permission_required("purchase.add_basket") -def new_basket(request: HttpRequest) -> HttpResponse: +def new_basket(request: WSGIRequest) -> HttpResponse: if request.method == "POST": form = BasketForm(request.POST) if form.is_valid(): - instance = form.save() + basket = form.save() + update_with_unpriced_products(basket, request.POST) if request.user.has_perm("purchase.change_basket"): - url = instance.get_absolute_url() + url = basket.get_absolute_url() else: url = reverse("purchase:new") messages.success(request, _("Successfully created basket.")) @@ -27,26 +35,61 @@ def new_basket(request: HttpRequest) -> HttpResponse: else: form = BasketForm() - return TemplateResponse(request, "purchase/basket_form.html", {"form": form}) + return TemplateResponse( + request, + "purchase/basket_form.html", + {"form": form, "products": Product.objects.with_no_fixed_price()}, + ) + + +def update_with_unpriced_products(basket: Basket, post_data: MultiValueDict): + no_fixed_price = { + product.id: product for product in Product.objects.with_no_fixed_price() + } + basket.items.filter(product__in=no_fixed_price.values()).delete() + for product_id, product in no_fixed_price.items(): + if prices := post_data.getlist(f"{UNPRICED_PREFIX}{product_id}"): + for price in prices: + basket.items.create(product=product, quantity=1, unit_price_cents=price) @require_http_methods(["GET", "POST"]) @permission_required("purchase.change_basket") -def update_basket(request: HttpRequest, pk: int) -> HttpResponse: +def update_basket(request: WSGIRequest, pk: int) -> HttpResponse: basket = get_object_or_404(Basket.objects.priced(), pk=pk) if request.method == "POST": form = BasketForm(request.POST, instance=basket) if form.is_valid(): basket = form.save() + update_with_unpriced_products(basket, request.POST) messages.success(request, _("Successfully updated basket.")) return redirect(basket.get_absolute_url()) else: form = BasketForm(instance=basket) - return TemplateResponse( + response = render( request, "purchase/basket_form.html", - {"form": form, "basket": basket}, + { + "form": form, + "basket": basket, + "products": Product.objects.with_no_fixed_price(), + }, + ) + trigger_client_event(response, "load-unpriced", after="swap") + return response + + +@require_http_methods(["GET"]) +def additional_unpriced_product(request: WSGIRequest) -> HttpResponse: + product_id = request.GET.get("product_to_add") + value = request.GET.get("value", 0) + product = get_object_or_404(Product.objects.with_no_fixed_price(), pk=product_id) + context = {"product": product, "value": value} + return render( + request, + "purchase/snippets/basket_unpriced_item.html", + context, )