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()
def firefox_options(firefox_options):
firefox_options.add_argument("-headless")
return firefox_options

View file

@ -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(

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

View file

@ -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 %}
<link rel="stylesheet" href="{% static "purchase/css/basket_form.css" %}">
{% endblock %}
@ -18,7 +17,43 @@
<h1>{% translate "New basket" %}</h1>
{% endif %}
{% 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 %}
<a href="{% url "purchase:new" %}" class="btn btn-secondary">{% translate "New" %}</a>
{% endif %}
{% 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.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)

View file

@ -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("<int:pk>/update/", update_basket, name="update"),
path("<int:pk>/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"),

View file

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