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,
)