mirror of
https://github.com/Crocmagnon/checkout.git
synced 2024-12-22 22:21:47 +01:00
Implement floating price items
This commit is contained in:
parent
072c762ad0
commit
56c2c7045d
9 changed files with 218 additions and 26 deletions
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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):
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
|
@ -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)
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
Loading…
Reference in a new issue