Implement floating price items

This commit is contained in:
Gabriel Augendre 2023-03-27 16:45:00 +02:00
parent 072c762ad0
commit 56c2c7045d
9 changed files with 218 additions and 26 deletions

View file

@ -15,7 +15,6 @@ def live_server(settings, live_server):
@pytest.fixture() @pytest.fixture()
def firefox_options(firefox_options): def firefox_options(firefox_options):
firefox_options.add_argument("-headless")
return firefox_options return firefox_options

View file

@ -7,7 +7,8 @@ from django.utils.translation import gettext as _
from purchase.layout import BasketItemField from purchase.layout import BasketItemField
from purchase.models import Basket, Product from purchase.models import Basket, Product
PREFIX = "product-" PRICED_PREFIX = "product-"
UNPRICED_PREFIX = "unpriced_product-"
class BasketForm(forms.ModelForm): class BasketForm(forms.ModelForm):
@ -31,8 +32,8 @@ class BasketForm(forms.ModelForm):
for item in basket.items.all(): for item in basket.items.all():
products[item.product] = item.quantity products[item.product] = item.quantity
fields = [] fields = []
for product in Product.objects.all(): for product in Product.objects.with_fixed_price():
field_name = f"{PREFIX}{product.id}" field_name = f"{PRICED_PREFIX}{product.id}"
self.fields.update( self.fields.update(
{ {
field_name: forms.IntegerField( field_name: forms.IntegerField(
@ -47,6 +48,7 @@ class BasketForm(forms.ModelForm):
Div( Div(
*fields, *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_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"), InlineRadios("payment_method"),
) )
@ -56,8 +58,8 @@ class BasketForm(forms.ModelForm):
name: str name: str
products = {product.id: product for product in Product.objects.all()} products = {product.id: product for product in Product.objects.all()}
for name, value in self.cleaned_data.items(): for name, value in self.cleaned_data.items():
if name.startswith(PREFIX): if name.startswith(PRICED_PREFIX):
product_id = int(name.removeprefix(PREFIX)) product_id = int(name.removeprefix(PRICED_PREFIX))
product = products[product_id] product = products[product_id]
if value > 0: if value > 0:
instance.items.update_or_create( instance.items.update_or_create(

View file

@ -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)",
),
),
]

View file

@ -4,7 +4,7 @@ import hashlib
import uuid import uuid
from django.db import models 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.db.models.functions import Coalesce
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext from django.utils.translation import gettext
@ -77,6 +77,12 @@ class ProductQuerySet(models.QuerySet):
def with_sold(self): def with_sold(self):
return self.annotate(sold=Coalesce(Sum("basket_items__quantity"), 0)) 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): class ProductManager(models.Manager):
def get_by_natural_key(self, name): def get_by_natural_key(self, name):
@ -88,7 +94,9 @@ class Product(Model):
image = models.ImageField(null=True, blank=True, verbose_name=_("image")) image = models.ImageField(null=True, blank=True, verbose_name=_("image"))
unit_price_cents = models.PositiveIntegerField( unit_price_cents = models.PositiveIntegerField(
verbose_name=_("unit price (cents)"), 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( display_order = models.PositiveIntegerField(
default=default_product_display_order, default=default_product_display_order,
@ -115,6 +123,10 @@ class Product(Model):
base=16, base=16,
) )
@property
def has_fixed_price(self) -> bool:
return self.unit_price_cents > 0
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
super().save(*args, **kwargs) super().save(*args, **kwargs)
if not self.image: if not self.image:
@ -223,9 +235,6 @@ class BasketItem(Model):
class Meta: class Meta:
verbose_name = _("basket item") verbose_name = _("basket item")
verbose_name_plural = _("basket items") verbose_name_plural = _("basket items")
constraints = [
UniqueConstraint("product", "basket", name="unique_product_per_basket"),
]
class Cache(SingletonModel): class Cache(SingletonModel):

View file

@ -1,7 +1,6 @@
{% extends "common/base.html" %} {% extends "common/base.html" %}
{% load static %} {% load i18n static crispy_forms_tags purchase django_htmx %}
{% load i18n %}
{% load crispy_forms_tags purchase %}
{% block extrahead %} {% block extrahead %}
<link rel="stylesheet" href="{% static "purchase/css/basket_form.css" %}"> <link rel="stylesheet" href="{% static "purchase/css/basket_form.css" %}">
{% endblock %} {% endblock %}
@ -18,7 +17,43 @@
<h1>{% translate "New basket" %}</h1> <h1>{% translate "New basket" %}</h1>
{% endif %} {% endif %}
{% crispy form %} {% crispy form %}
<form
hx-get="{% url "purchase:additional_unpriced_product" %}"
hx-target="#products"
hx-swap="beforeend"
>
<div class="input-group">
<select class="form-select" name="product_to_add" id="product_to_add">
{% for product in products %}
<option value="{{ product.pk }}">{{ product.name }}</option>
{% endfor %}
</select>
<button
class="btn btn-outline-secondary"
type="submit"
id="add_product"
>
{% translate "Add product" %}
</button>
</div>
</form>
{% for item in basket.items.all %}
{% if item.product.unit_price_cents == 0 %}
<input
type="hidden"
hx-get="{% url "purchase:additional_unpriced_product" %}?product_to_add={{ item.product.pk }}&value={{ item.unit_price_cents }}"
hx-trigger="load"
hx-target="#products"
hx-swap="beforeend"
>
{% endif %}
{% endfor %}
{% if basket %} {% if basket %}
<a href="{% url "purchase:new" %}" class="btn btn-secondary">{% translate "New" %}</a> <a href="{% url "purchase:new" %}" class="btn btn-secondary">{% translate "New" %}</a>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block extrascript %}
<script src="{% static 'vendor/htmx-1.8.6/htmx.min.js' %}" defer></script>
{% django_htmx_script %}
{% endblock %}

View file

@ -0,0 +1,23 @@
{% load crispy_forms_field %}
<div class="col">
<div class="card h-100 bg-success text-white" data-product-id="{{ product.pk }}">
{% if product.image %}
<img src="{{ product.image.url }}" class="card-img">
{% else %}
<div class="card-img product-img-placeholder"
style="background-color: hsl({{ product.color_hue }}, 60%, 80%)">
<span>
{{ product.name|slice:"1" }}
</span>
</div>
{% endif %}
<div class="card-body">
<h4 class="card-title">{{ product.name }}</h4>
<div class="input-group">
<input type="number" step="0.01" name="unpriced_product-{{ product.pk }}" min="0" class="numberinput form-control" required="" id="unpriced_id_product-{{ product.pk }}"
value="{{ value|default:0 }}"
>
</div>
</div>
</div>
</div>

View file

@ -6,6 +6,7 @@ from pytest_django.live_server_helper import LiveServer
from selenium.webdriver import ActionChains, Keys from selenium.webdriver import ActionChains, Keys
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support.select import Select
from selenium.webdriver.support.wait import WebDriverWait from selenium.webdriver.support.wait import WebDriverWait
from common.models import User from common.models import User
@ -34,6 +35,11 @@ def test_cashier_create_and_update_basket( # noqa: PLR0915
ProductFactory(), ProductFactory(),
ProductFactory(), ProductFactory(),
] ]
unpriced_products = [
ProductFactory(unit_price_cents=0),
ProductFactory(unit_price_cents=0),
ProductFactory(unit_price_cents=0),
]
payment_methods = [ payment_methods = [
PaymentMethodFactory(), PaymentMethodFactory(),
PaymentMethodFactory(), PaymentMethodFactory(),
@ -85,6 +91,29 @@ def test_cashier_create_and_update_basket( # noqa: PLR0915
chain.double_click(quantity_input).perform() chain.double_click(quantity_input).perform()
quantity_input.send_keys("4") 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 # Don't add payment method
# Save # Save
selenium.find_element(By.ID, "submit-id-submit").click() 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 assert Basket.objects.count() == 1
basket = Basket.objects.priced().first() basket = Basket.objects.priced().first()
assert basket.payment_method is None 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]).quantity == 2
assert ( assert (
basket.items.get(product=products[0]).unit_price_cents 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 basket.items.get(product=products[1]).unit_price_cents
== 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 # Assert redirected to basket update view
redirect_url = live_reverse(live_server, "purchase:update", pk=basket.pk) 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")) quantity = int(quantity_input.get_attribute("value"))
assert quantity == 0 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 # Click on - on product 2
displayed_product = displayed_products[1] displayed_product = displayed_products[1]
displayed_product.find_element(By.CLASS_NAME, "btn-danger").click() 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 assert Basket.objects.count() == 1
basket = Basket.objects.priced().first() basket = Basket.objects.priced().first()
assert basket.payment_method == payment_methods[1] 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]).quantity == 2
assert ( assert (
basket.items.get(product=products[0]).unit_price_cents 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 basket.items.get(product=products[1]).unit_price_cents
== 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 # Assert redirected to same view
redirect_url = live_reverse(live_server, "purchase:update", pk=basket.pk) redirect_url = live_reverse(live_server, "purchase:update", pk=basket.pk)

View file

@ -1,6 +1,7 @@
from django.urls import path from django.urls import path
from purchase.views import delete_basket, list_baskets, new_basket, update_basket 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 from purchase.views.reports import by_hour_plot_view, products_plots_view, reports
app_name = "purchase" app_name = "purchase"
@ -9,6 +10,11 @@ urlpatterns = [
path("new/", new_basket, name="new"), path("new/", new_basket, name="new"),
path("<int:pk>/update/", update_basket, name="update"), path("<int:pk>/update/", update_basket, name="update"),
path("<int:pk>/delete/", delete_basket, name="delete"), path("<int:pk>/delete/", delete_basket, name="delete"),
path(
"additional_unpriced_product/",
additional_unpriced_product,
name="additional_unpriced_product",
),
path("reports/", reports, name="reports"), path("reports/", reports, name="reports"),
# plots # plots
path("reports/products_plots/", products_plots_view, name="products_plots"), path("reports/products_plots/", products_plots_view, name="products_plots"),

View file

@ -1,25 +1,33 @@
import logging
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import permission_required from django.contrib.auth.decorators import permission_required
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpRequest, HttpResponse 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.template.response import TemplateResponse
from django.urls import reverse from django.urls import reverse
from django.utils.datastructures import MultiValueDict
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import condition, require_http_methods 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.forms import UNPRICED_PREFIX, BasketForm
from purchase.models import Basket, reports_etag, reports_last_modified from purchase.models import Basket, Product, reports_etag, reports_last_modified
logger = logging.getLogger(__name__)
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
@permission_required("purchase.add_basket") @permission_required("purchase.add_basket")
def new_basket(request: HttpRequest) -> HttpResponse: def new_basket(request: WSGIRequest) -> HttpResponse:
if request.method == "POST": if request.method == "POST":
form = BasketForm(request.POST) form = BasketForm(request.POST)
if form.is_valid(): 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"): if request.user.has_perm("purchase.change_basket"):
url = instance.get_absolute_url() url = basket.get_absolute_url()
else: else:
url = reverse("purchase:new") url = reverse("purchase:new")
messages.success(request, _("Successfully created basket.")) messages.success(request, _("Successfully created basket."))
@ -27,26 +35,61 @@ def new_basket(request: HttpRequest) -> HttpResponse:
else: else:
form = BasketForm() 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"]) @require_http_methods(["GET", "POST"])
@permission_required("purchase.change_basket") @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) basket = get_object_or_404(Basket.objects.priced(), pk=pk)
if request.method == "POST": if request.method == "POST":
form = BasketForm(request.POST, instance=basket) form = BasketForm(request.POST, instance=basket)
if form.is_valid(): if form.is_valid():
basket = form.save() basket = form.save()
update_with_unpriced_products(basket, request.POST)
messages.success(request, _("Successfully updated basket.")) messages.success(request, _("Successfully updated basket."))
return redirect(basket.get_absolute_url()) return redirect(basket.get_absolute_url())
else: else:
form = BasketForm(instance=basket) form = BasketForm(instance=basket)
return TemplateResponse( response = render(
request, request,
"purchase/basket_form.html", "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,
) )