Compare commits

...

16 Commits

33 changed files with 620 additions and 212 deletions

View File

@ -48,7 +48,7 @@ factory-boy==3.2.1
# via -r requirements-dev.in
faker==18.3.1
# via factory-boy
filelock==3.10.4
filelock==3.10.7
# via virtualenv
h11==0.14.0
# via wsproto
@ -81,7 +81,7 @@ pathspec==0.11.1
# via black
pip-tools==6.12.3
# via -r requirements-dev.in
platformdirs==3.1.1
platformdirs==3.2.0
# via
# black
# virtualenv

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/static/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 667 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1,75 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M2816 5005 c-56 -19 -102 -60 -127 -111 -22 -46 -22 -52 -22 -435 l0
-387 27 -46 c29 -50 74 -87 125 -105 23 -7 152 -11 395 -11 l361 0 -1 -247
c-1 -137 -2 -251 -3 -255 -1 -3 -352 -6 -781 -6 l-780 0 -1 32 c0 17 -1 68 -1
113 -1 98 -19 137 -70 150 -18 5 -248 9 -511 8 l-477 -1 -32 -31 -31 -31 -1
-118 -1 -119 -287 -2 c-188 -1 -293 -5 -307 -13 -33 -17 -49 -50 -55 -115 -4
-33 -9 -71 -11 -85 -3 -14 -7 -50 -10 -80 -3 -30 -7 -71 -10 -90 -5 -36 -11
-93 -20 -182 -5 -51 -10 -91 -20 -168 -2 -19 -7 -60 -10 -90 -3 -30 -8 -71
-10 -90 -9 -72 -15 -116 -20 -170 -3 -30 -7 -71 -10 -90 -6 -44 -17 -137 -20
-170 -6 -65 -16 -148 -21 -180 -2 -19 -7 -53 -9 -75 -2 -22 -7 -69 -10 -105
-4 -35 -8 -71 -10 -80 -2 -8 -6 -44 -9 -80 -4 -36 -9 -76 -11 -90 -16 -99 -20
-213 -20 -645 l0 -490 30 -54 c32 -58 89 -111 151 -138 37 -17 172 -18 2354
-19 2222 -1 2317 -1 2375 17 78 24 136 73 172 147 l28 57 0 485 c0 424 -5 582
-20 640 -2 8 -7 45 -10 82 -3 37 -7 75 -9 85 -2 10 -7 50 -11 88 -4 39 -9 86
-12 105 -2 19 -6 51 -8 70 -2 19 -6 60 -10 90 -10 92 -15 133 -20 185 -3 28
-8 61 -10 75 -2 14 -7 54 -10 90 -3 36 -8 76 -10 90 -2 14 -6 50 -10 80 -3 30
-7 69 -10 85 -2 17 -6 59 -10 95 -4 36 -8 76 -10 90 -2 14 -7 52 -10 85 -3 33
-8 76 -10 95 -3 19 -7 58 -10 85 -3 28 -10 82 -15 120 -5 39 -12 97 -15 130
-8 70 -15 86 -50 109 -24 16 -74 18 -543 19 l-517 2 0 253 -1 252 366 0 c363
0 365 0 416 24 54 25 87 60 110 116 19 44 21 760 2 822 -6 21 -27 55 -46 74
-70 73 -14 68 -939 70 -633 1 -840 -2 -867 -11z m1659 -196 c4 -5 8 -459 5
-666 l0 -33 -805 0 c-760 0 -805 1 -806 18 -4 70 0 672 4 679 7 10 1591 12
1602 2z m-2670 -1874 l0 -570 -357 0 -358 -1 -1 26 c-1 30 -1 1091 0 1105 1 6
127 10 359 10 l357 -1 0 -569z m-939 266 c20 -1 21 -3 21 -416 0 -228 -3 -415
-6 -416 -3 -1 -23 -2 -44 -4 -97 -8 -139 -99 -78 -167 l29 -33 648 0 c721 -1
695 -3 720 66 24 72 -29 134 -113 132 l-33 -1 -1 87 c-2 296 1 744 5 748 7 8
2666 6 2674 -1 3 -4 7 -12 8 -19 2 -14 15 -137 19 -182 2 -16 6 -48 8 -70 7
-48 15 -121 22 -190 3 -27 10 -84 15 -125 5 -41 12 -97 15 -125 3 -27 7 -66
10 -85 2 -19 7 -62 10 -95 4 -33 8 -73 10 -90 2 -16 6 -55 10 -85 3 -30 7 -66
10 -80 3 -14 7 -52 10 -85 3 -33 7 -71 9 -85 3 -14 8 -54 11 -90 4 -36 8 -76
10 -90 1 -14 6 -54 10 -90 4 -36 9 -73 10 -82 2 -9 6 -49 10 -90 3 -40 8 -81
11 -90 3 -10 2 -19 -3 -19 -25 -4 -4670 -4 -4681 -1 -7 2 -10 12 -8 21 3 9 7
45 11 81 3 36 8 76 10 90 2 14 7 52 10 85 7 70 15 139 20 175 2 14 7 56 10 95
4 38 8 77 10 87 2 9 6 48 10 85 3 37 8 79 10 93 3 14 7 52 10 85 3 33 8 71 10
85 2 14 7 54 11 90 3 36 7 72 9 80 2 8 6 44 9 80 8 76 12 106 26 215 5 44 12
105 15 135 3 30 7 69 9 85 2 17 7 64 11 106 8 85 12 95 39 100 15 3 313 3 412
0z m4051 -2456 c2 -359 1 -381 -16 -402 -11 -13 -30 -27 -42 -31 -29 -11
-4587 -9 -4610 1 -8 4 -23 21 -32 36 -15 26 -17 69 -17 403 l0 373 2358 0
2357 0 2 -380z"/>
<path d="M2620 2883 c-69 -28 -73 -139 -7 -174 27 -15 277 -20 325 -7 34 10
72 57 72 91 0 28 -28 75 -53 89 -31 17 -297 18 -337 1z"/>
<path d="M3289 2892 c-42 -5 -74 -46 -74 -97 0 -50 23 -81 69 -93 50 -13 291
-8 319 7 67 36 63 154 -6 177 -22 8 -258 12 -308 6z"/>
<path d="M3933 2892 c-34 -5 -73 -58 -73 -99 0 -38 44 -92 77 -94 107 -8 282
-3 305 8 63 31 77 102 29 155 l-28 32 -144 0 c-79 1 -154 0 -166 -2z"/>
<path d="M2553 2362 c-27 -4 -69 -46 -75 -75 -7 -37 11 -84 39 -103 24 -16 50
-18 195 -19 154 0 169 2 195 21 36 28 50 73 33 114 -24 57 -46 64 -214 64 -83
1 -161 0 -173 -2z"/>
<path d="M3262 2355 c-54 -23 -79 -83 -56 -131 26 -53 44 -58 222 -59 151 -2
166 0 195 19 40 27 55 74 37 116 -24 57 -45 63 -216 65 -97 1 -164 -3 -182
-10z"/>
<path d="M3991 2357 c-82 -21 -97 -142 -22 -178 23 -12 69 -15 197 -14 l167 1
28 32 c39 44 39 89 0 131 -16 17 -37 31 -48 32 -83 5 -299 3 -322 -4z"/>
<path d="M2414 1812 c-49 -39 -53 -107 -10 -151 26 -26 27 -26 204 -29 104 -2
189 1 204 7 52 20 77 84 52 136 -23 50 -46 55 -247 55 -157 0 -185 -3 -203
-18z"/>
<path d="M3230 1817 c-70 -35 -61 -151 13 -177 17 -5 104 -10 194 -10 184 0
215 8 238 63 18 43 10 77 -25 110 l-30 27 -183 0 c-126 0 -190 -4 -207 -13z"/>
<path d="M4027 1810 c-55 -43 -43 -140 19 -166 40 -17 338 -20 380 -4 69 27
87 108 35 161 l-29 29 -190 0 c-175 0 -191 -1 -215 -20z"/>
<path d="M1921 809 c-39 -8 -56 -22 -71 -58 -16 -39 -1 -89 34 -115 27 -21 36
-21 669 -22 685 -1 677 -1 709 48 23 35 15 98 -17 126 -14 12 -31 22 -38 23
-54 5 -1263 3 -1286 -2z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@ -0,0 +1,18 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/static/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff"
}

View File

@ -7,6 +7,7 @@
<title>Checkout</title>
<link href="{% static "vendor/bootstrap-5.2.3-dist/css/bootstrap.min.css" %}"
rel="stylesheet">
{% include "common/favicon.html" %}
<style>
body {
margin-bottom: 2em;

View File

@ -0,0 +1,10 @@
{% load static %}
<link rel="apple-touch-icon" sizes="180x180" href="{% static "icons/apple-touch-icon.png" %}">
<link rel="icon" type="image/png" sizes="32x32" href="{% static "icons/favicon-32x32.png" %}">
<link rel="icon" type="image/png" sizes="16x16" href="{% static "icons/favicon-16x16.png" %}">
<link rel="manifest" href="{% static "icons/site.webmanifest" %}">
<link rel="mask-icon" href="{% static "icons/safari-pinned-tab.svg" %}" color="#5bbad5">
<link rel="shortcut icon" href="{% static "icons/favicon.ico" %}">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="msapplication-config" content="{% static "icons/browserconfig.xml" %}">
<meta name="theme-color" content="#ffffff">

View File

@ -75,5 +75,16 @@
"unit_price_cents": 290,
"display_order": 7
}
},
{
"model": "purchase.product",
"fields": {
"created_at": "2022-04-28T16:08:20.471Z",
"updated_at": "2022-04-28T16:09:34.003Z",
"name": "Tomme de ch\u00e8vre",
"image": "",
"unit_price_cents": 0,
"display_order": 8
}
}
]

View File

@ -1,13 +1,16 @@
from crispy_forms import layout
from crispy_forms.bootstrap import InlineRadios
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Div, Layout, Submit
from django import forms
from django.urls import reverse
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):
@ -24,6 +27,12 @@ class BasketForm(forms.ModelForm):
self.helper = FormHelper()
self.helper.form_class = "form-horizontal"
self.helper.add_input(Submit("submit", _("Save")))
self.helper.attrs = {
"hx_post": reverse("purchase:price_preview"),
"hx_trigger": "keyup delay:500ms,change delay:500ms",
"hx_target": "#price_preview",
"hx_swap": "innerHTML",
}
self.helper.layout = Layout()
products = {}
basket = kwargs.get("instance")
@ -31,8 +40,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(
@ -43,12 +52,21 @@ class BasketForm(forms.ModelForm):
},
)
fields.append(BasketItemField(field_name, product=product))
total = 0
if basket:
total = basket.price / 100
self.helper.layout = Layout(
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"),
Div(
layout.HTML(f"Montant total : {total:.2f}"),
css_id="price_preview",
css_class="mb-2",
),
)
def save(self):
@ -56,8 +74,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

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-04-27 22:58+0200\n"
"POT-Creation-Date: 2023-03-27 16:54+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -18,96 +18,97 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: purchase/admin.py:18
#: purchase/admin.py:19
msgid "unit price"
msgstr ""
#: purchase/admin.py:22
#: purchase/admin.py:23
msgid "sold"
msgstr ""
#: purchase/admin.py:26 purchase/admin.py:39
#: purchase/admin.py:27 purchase/admin.py:40
msgid "turnover"
msgstr ""
#: purchase/admin.py:53 purchase/admin.py:70
#: purchase/admin.py:54 purchase/admin.py:71
msgid "price"
msgstr ""
#: purchase/forms.py:23
#: purchase/forms.py:27
msgid "Save"
msgstr ""
#: purchase/models.py:13
#: purchase/models.py:17
msgid "created at"
msgstr ""
#: purchase/models.py:14
#: purchase/models.py:18
msgid "updated at"
msgstr ""
#: purchase/models.py:42 purchase/models.py:83
#: purchase/models.py:46 purchase/models.py:93
msgid "name"
msgstr ""
#: purchase/models.py:47 purchase/models.py:167
#: purchase/models.py:51 purchase/models.py:193
msgid "payment method"
msgstr ""
#: purchase/models.py:48
#: purchase/models.py:52
msgid "payment methods"
msgstr ""
#: purchase/models.py:84
#: purchase/models.py:94
msgid "image"
msgstr ""
#: purchase/models.py:86 purchase/models.py:203
#: purchase/models.py:96 purchase/models.py:229
msgid "unit price (cents)"
msgstr ""
#: purchase/models.py:86
msgid "unit price in cents"
#: purchase/models.py:98
msgid ""
"Unit price in cents. Use zero to denote that the product has no fixed price."
msgstr ""
#: purchase/models.py:89
#: purchase/models.py:103
msgid "display order"
msgstr ""
#: purchase/models.py:96 purchase/models.py:193
#: purchase/models.py:110 purchase/models.py:219
msgid "product"
msgstr ""
#: purchase/models.py:97
#: purchase/models.py:111
msgid "products"
msgstr ""
#: purchase/models.py:173 purchase/models.py:199
#: purchase/models.py:199 purchase/models.py:225
msgid "basket"
msgstr ""
#: purchase/models.py:174
#: purchase/models.py:200
msgid "baskets"
msgstr ""
#: purchase/models.py:177
#: purchase/models.py:203
#, python-format
msgid "Basket #%(id)s"
msgstr ""
#: purchase/models.py:201
#: purchase/models.py:227
msgid "quantity"
msgstr ""
#: purchase/models.py:204
#: purchase/models.py:230
msgid "product's unit price in cents at the time of purchase"
msgstr ""
#: purchase/models.py:210
#: purchase/models.py:236
msgid "basket item"
msgstr ""
#: purchase/models.py:211
#: purchase/models.py:237
msgid "basket items"
msgstr ""
@ -116,16 +117,17 @@ msgstr ""
msgid "Are you sure you want to delete \"%(basket)s\"?"
msgstr ""
#: purchase/templates/purchase/basket_form.html:11
#: purchase/templates/purchase/basket_form.html:14
msgid "Missing payment method."
msgstr ""
#: purchase/templates/purchase/basket_form.html:14
#: purchase/templates/purchase/basket_form.html:17
#: purchase/templates/purchase/basket_form.html:52
msgid "New basket"
msgstr ""
#: purchase/templates/purchase/basket_form.html:18
msgid "New"
#: purchase/templates/purchase/basket_form.html:36
msgid "Add product"
msgstr ""
#: purchase/templates/purchase/basket_list.html:5
@ -144,45 +146,45 @@ msgid_plural "%(counter)s items"
msgstr[0] ""
msgstr[1] ""
#: purchase/templates/purchase/reports.html:11
#: purchase/templates/purchase/reports.html:9
msgid "Reports"
msgstr ""
#: purchase/templates/purchase/reports.html:12
#: purchase/templates/purchase/reports.html:10
msgid "General"
msgstr ""
#: purchase/templates/purchase/reports.html:14
#: purchase/templates/purchase/reports.html:12
msgid "Total turnover:"
msgstr ""
#: purchase/templates/purchase/reports.html:15
#: purchase/templates/purchase/reports.html:13
msgid "Average basket:"
msgstr ""
#: purchase/templates/purchase/reports.html:18
#: purchase/templates/purchase/reports.html:16
msgid "By day"
msgstr ""
#: purchase/templates/purchase/reports.html:19
#: purchase/templates/purchase/reports.html:17
#: purchase/templates/purchase/snippets/report_payment_methods.html:8
#: purchase/templates/purchase/snippets/report_products.html:8
msgid "Turnover"
msgstr ""
#: purchase/templates/purchase/reports.html:25
#: purchase/templates/purchase/reports.html:23
msgid "Average basket"
msgstr ""
#: purchase/templates/purchase/reports.html:34
#: purchase/templates/purchase/reports.html:32
msgid "Products"
msgstr ""
#: purchase/templates/purchase/reports.html:40
#: purchase/templates/purchase/reports.html:36
msgid "Turnover by payment method"
msgstr ""
#: purchase/templates/purchase/reports.html:43
#: purchase/templates/purchase/reports.html:39
msgid "Baskets without payment method"
msgstr ""
@ -207,42 +209,42 @@ msgid "Product"
msgstr ""
#: purchase/templates/purchase/snippets/report_products.html:7
#: purchase/views/reports.py:79 purchase/views/reports.py:93
#: purchase/views/reports.py:102 purchase/views/reports.py:116
msgid "# sold"
msgstr ""
#: purchase/views/basket.py:15
#: purchase/views/basket.py:33
msgid "Successfully created basket."
msgstr ""
#: purchase/views/basket.py:30
#: purchase/views/basket.py:70
msgid "Successfully updated basket."
msgstr ""
#: purchase/views/basket.py:45
#: purchase/views/basket.py:116
msgid "Basket successfully deleted."
msgstr ""
#: purchase/views/reports.py:29
#: purchase/views/reports.py:64
msgid "No sale to report"
msgstr ""
#: purchase/views/reports.py:70
#: purchase/views/reports.py:93
msgid "Sales by product"
msgstr ""
#: purchase/views/reports.py:85 purchase/views/reports.py:100
#: purchase/views/reports.py:108 purchase/views/reports.py:123
msgid "Turnover by product"
msgstr ""
#: purchase/views/reports.py:122
#: purchase/views/reports.py:147
msgid "Sales by hour"
msgstr ""
#: purchase/views/reports.py:133
#: purchase/views/reports.py:158
msgid "Basket count by hour"
msgstr ""
#: purchase/views/reports.py:141
#: purchase/views/reports.py:166
msgid "Turnover by hour"
msgstr ""

View File

@ -5,7 +5,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-04-27 22:58+0200\n"
"POT-Creation-Date: 2023-03-27 16:54+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -15,96 +15,97 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: purchase/admin.py:18
#: purchase/admin.py:19
msgid "unit price"
msgstr "prix unitaire"
#: purchase/admin.py:22
#: purchase/admin.py:23
msgid "sold"
msgstr "vendu"
#: purchase/admin.py:26 purchase/admin.py:39
#: purchase/admin.py:27 purchase/admin.py:40
msgid "turnover"
msgstr "chiffre d'affaires"
#: purchase/admin.py:53 purchase/admin.py:70
#: purchase/admin.py:54 purchase/admin.py:71
msgid "price"
msgstr "prix"
#: purchase/forms.py:23
#: purchase/forms.py:27
msgid "Save"
msgstr "Enregistrer"
#: purchase/models.py:13
#: purchase/models.py:17
msgid "created at"
msgstr "créé à"
#: purchase/models.py:14
#: purchase/models.py:18
msgid "updated at"
msgstr "mis à jour à"
#: purchase/models.py:42 purchase/models.py:83
#: purchase/models.py:46 purchase/models.py:93
msgid "name"
msgstr "nom"
#: purchase/models.py:47 purchase/models.py:167
#: purchase/models.py:51 purchase/models.py:193
msgid "payment method"
msgstr "moyen de paiement"
#: purchase/models.py:48
#: purchase/models.py:52
msgid "payment methods"
msgstr "moyens de paiement"
#: purchase/models.py:84
#: purchase/models.py:94
msgid "image"
msgstr "image"
#: purchase/models.py:86 purchase/models.py:203
#: purchase/models.py:96 purchase/models.py:229
msgid "unit price (cents)"
msgstr "prix unitaire (centimes)"
#: purchase/models.py:86
msgid "unit price in cents"
msgstr "prix unitaire en centimes"
#: purchase/models.py:98
msgid ""
"Unit price in cents. Use zero to denote that the product has no fixed price."
msgstr "Prix unitaire en centimes. Utiliser zéro pour indiquer que le produit n'a pas de prix fixe."
#: purchase/models.py:89
#: purchase/models.py:103
msgid "display order"
msgstr "ordre d'affichage"
#: purchase/models.py:96 purchase/models.py:193
#: purchase/models.py:110 purchase/models.py:219
msgid "product"
msgstr "produit"
#: purchase/models.py:97
#: purchase/models.py:111
msgid "products"
msgstr "produits"
#: purchase/models.py:173 purchase/models.py:199
#: purchase/models.py:199 purchase/models.py:225
msgid "basket"
msgstr "panier"
#: purchase/models.py:174
#: purchase/models.py:200
msgid "baskets"
msgstr "paniers"
#: purchase/models.py:177
#: purchase/models.py:203
#, python-format
msgid "Basket #%(id)s"
msgstr "Panier n°%(id)s"
#: purchase/models.py:201
#: purchase/models.py:227
msgid "quantity"
msgstr "quantité"
#: purchase/models.py:204
#: purchase/models.py:230
msgid "product's unit price in cents at the time of purchase"
msgstr "prix unitaire du produit en centimes au moment de l'achat"
#: purchase/models.py:210
#: purchase/models.py:236
msgid "basket item"
msgstr "article de panier"
#: purchase/models.py:211
#: purchase/models.py:237
msgid "basket items"
msgstr "articles de panier"
@ -113,17 +114,18 @@ msgstr "articles de panier"
msgid "Are you sure you want to delete \"%(basket)s\"?"
msgstr "Êtes-vous sûr de vouloir supprimer \"%(basket)s\" ?"
#: purchase/templates/purchase/basket_form.html:11
#: purchase/templates/purchase/basket_form.html:14
msgid "Missing payment method."
msgstr "Moyen de paiement manquant."
#: purchase/templates/purchase/basket_form.html:14
#: purchase/templates/purchase/basket_form.html:17
#: purchase/templates/purchase/basket_form.html:52
msgid "New basket"
msgstr "Nouveau panier"
#: purchase/templates/purchase/basket_form.html:18
msgid "New"
msgstr "Nouveau"
#: purchase/templates/purchase/basket_form.html:36
msgid "Add product"
msgstr "Ajouter un produit"
#: purchase/templates/purchase/basket_list.html:5
msgid "Baskets"
@ -141,45 +143,45 @@ msgid_plural "%(counter)s items"
msgstr[0] "1 article"
msgstr[1] "%(counter)s articles"
#: purchase/templates/purchase/reports.html:11
#: purchase/templates/purchase/reports.html:9
msgid "Reports"
msgstr "Rapports"
#: purchase/templates/purchase/reports.html:12
#: purchase/templates/purchase/reports.html:10
msgid "General"
msgstr "Général"
#: purchase/templates/purchase/reports.html:14
#: purchase/templates/purchase/reports.html:12
msgid "Total turnover:"
msgstr "Chiffre d'affaires total :"
#: purchase/templates/purchase/reports.html:15
#: purchase/templates/purchase/reports.html:13
msgid "Average basket:"
msgstr "Panier moyen :"
#: purchase/templates/purchase/reports.html:18
#: purchase/templates/purchase/reports.html:16
msgid "By day"
msgstr "Par jour"
#: purchase/templates/purchase/reports.html:19
#: purchase/templates/purchase/reports.html:17
#: purchase/templates/purchase/snippets/report_payment_methods.html:8
#: purchase/templates/purchase/snippets/report_products.html:8
msgid "Turnover"
msgstr "Chiffre d'affaires"
#: purchase/templates/purchase/reports.html:25
#: purchase/templates/purchase/reports.html:23
msgid "Average basket"
msgstr "Panier moyen"
#: purchase/templates/purchase/reports.html:34
#: purchase/templates/purchase/reports.html:32
msgid "Products"
msgstr "Produits"
#: purchase/templates/purchase/reports.html:40
#: purchase/templates/purchase/reports.html:36
msgid "Turnover by payment method"
msgstr "Chiffre d'affaires par moyen de paiement"
#: purchase/templates/purchase/reports.html:43
#: purchase/templates/purchase/reports.html:39
msgid "Baskets without payment method"
msgstr "Paniers sans moyen de paiement"
@ -204,45 +206,42 @@ msgid "Product"
msgstr "Produit"
#: purchase/templates/purchase/snippets/report_products.html:7
#: purchase/views/reports.py:79 purchase/views/reports.py:93
#: purchase/views/reports.py:102 purchase/views/reports.py:116
msgid "# sold"
msgstr "Nb. vendus"
#: purchase/views/basket.py:15
#: purchase/views/basket.py:33
msgid "Successfully created basket."
msgstr "Panier correctement créé."
#: purchase/views/basket.py:30
#: purchase/views/basket.py:70
msgid "Successfully updated basket."
msgstr "Panier correctement modifié."
#: purchase/views/basket.py:45
#: purchase/views/basket.py:116
msgid "Basket successfully deleted."
msgstr "Panier correctement supprimé."
#: purchase/views/reports.py:29
#: purchase/views/reports.py:64
msgid "No sale to report"
msgstr "Aucune vente à afficher"
#: purchase/views/reports.py:70
#: purchase/views/reports.py:93
msgid "Sales by product"
msgstr "Ventes par produit"
#: purchase/views/reports.py:85 purchase/views/reports.py:100
#: purchase/views/reports.py:108 purchase/views/reports.py:123
msgid "Turnover by product"
msgstr "Chiffre d'affaires par produit"
#: purchase/views/reports.py:122
#: purchase/views/reports.py:147
msgid "Sales by hour"
msgstr "Ventes par heure"
#: purchase/views/reports.py:133
#: purchase/views/reports.py:158
msgid "Basket count by hour"
msgstr "Nombre de paniers par heure"
#: purchase/views/reports.py:141
#: purchase/views/reports.py:166
msgid "Turnover by hour"
msgstr "Chiffre d'affaires par heure"
#~ msgid "Basket ID"
#~ msgstr "Id de panier"

View File

@ -36,9 +36,7 @@ class Command(BaseCommand):
methods_weights = [random.randint(1, 6) for _ in range(len(payment_methods))]
products_weights = [1 / product.display_order for product in products]
for _ in range(count):
method = None
if random.random() < 0.99: # noqa: PLR2004
method = random.choices(payment_methods, weights=methods_weights)[0]
method = random.choices(payment_methods, weights=methods_weights)[0]
basket = Basket.objects.create(payment_method=method)
items_in_basket = int(random.normalvariate(3, 2))
if items_in_basket > len(products):
@ -54,13 +52,23 @@ class Command(BaseCommand):
)
items = []
for product in selected_products:
items.append(
BasketItem(
product=product,
basket=basket,
quantity=random.randint(1, 3),
unit_price_cents=product.unit_price_cents,
),
)
if not product.has_fixed_price:
items.append(
BasketItem(
product=product,
basket=basket,
quantity=1,
unit_price_cents=random.randint(317, 514),
),
)
else:
items.append(
BasketItem(
product=product,
basket=basket,
quantity=random.randint(1, 3),
unit_price_cents=product.unit_price_cents,
),
)
BasketItem.objects.bulk_create(items)
return count

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

@ -0,0 +1,35 @@
# Generated by Django 4.1.7 on 2023-03-27 16:22
import django.db.models.deletion
from django.db import migrations, models
def delete_baskets_without_payment_method(apps, schema_editor):
Basket = apps.get_model("purchase", "Basket") # noqa: N806
Basket.objects.using(schema_editor.connection.alias).filter(
payment_method=None,
).delete()
class Migration(migrations.Migration):
dependencies = [
("purchase", "0013_remove_basketitem_unique_product_per_basket_and_more"),
]
operations = [
# Remove baskets with no payment method
migrations.RunPython(
delete_baskets_without_payment_method,
migrations.RunPython.noop,
),
migrations.AlterField(
model_name="basket",
name="payment_method",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="baskets",
to="purchase.paymentmethod",
verbose_name="payment method",
),
),
]

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:
@ -167,17 +179,14 @@ class BasketQuerySet(models.QuerySet):
def turnover(self) -> int:
return self.priced().aggregate(total=Sum("price"))["total"]
def no_payment_method(self) -> BasketQuerySet:
return self.filter(payment_method=None)
class Basket(Model):
payment_method = models.ForeignKey(
to=PaymentMethod,
on_delete=models.PROTECT,
related_name="baskets",
null=True,
blank=True,
null=False,
blank=False,
verbose_name=_("payment method"),
)
@ -223,9 +232,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,14 +1,25 @@
window.incrementValue = function (id) {
let value = parseInt(document.getElementById(id).value);
const element = document.getElementById(id);
let value = parseInt(element.value);
value = isNaN(value) ? 0 : value;
value++;
document.getElementById(id).value = value;
element.value = value;
window.dispatchChanged(element);
};
window.decrementValue = function (id) {
let value = parseInt(document.getElementById(id).value);
const element = document.getElementById(id);
let value = parseInt(element.value);
value = isNaN(value) ? 0 : value;
value--;
value = value < 0 ? 0 : value;
document.getElementById(id).value = value;
element.value = value;
window.dispatchChanged(element);
};
window.dispatchChanged = function (element) {
const event = new Event("change", { bubbles: true });
element.dispatchEvent(event);
};

View File

@ -1,13 +1,12 @@
{% 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 %}
{% block content %}
{% if basket %}
<h1>{{ basket }} <span class="badge bg-secondary">{{ basket.price|currency }}</span></h1>
<h1>{{ basket }} <span id="basket-price" class="badge bg-secondary">{{ basket.price|currency }}</span></h1>
<p class="metadata">
{{ basket.created_at }}
</p>
@ -15,10 +14,54 @@
<div class="alert alert-danger" role="alert">{% translate "Missing payment method." %}</div>
{% endif %}
{% else %}
<h1>{% translate "New basket" %}</h1>
<h1>{% translate "New basket" %} <span id="basket-price" class="badge bg-secondary d-none">{{ basket.price|currency }}</span></h1>
{% endif %}
{% crispy form %}
{% if basket %}
<a href="{% url "purchase:new" %}" class="btn btn-secondary">{% translate "New" %}</a>
{% endif %}
<div class="row">
<div class="col">
<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>
</div>
</div>
{% 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 %}
<div class="row mt-4">
<div class="col">
{% if basket %}
<a href="{% url "purchase:new" %}" class="btn btn-secondary">{% translate "New basket" %}</a>
{% endif %}
</div>
</div>
{% endblock %}
{% block extrascript %}
<script src="{% static 'vendor/htmx-1.8.6/htmx.min.js' %}" defer></script>
{% django_htmx_script %}
{% endblock %}

View File

@ -6,7 +6,7 @@
<div class="row row-cols-2 row-cols-xl-6 row-cols-lg-5 row-cols-md-4 row-cols-sm-3 g-4">
{% for basket in baskets %}
<div class="col">
<div class="card h-100 {% if not basket.payment_method %}bg-warning text-black{% endif %}">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">{% blocktranslate with basket_id=basket.id %}Basket #{{ basket_id }}{% endblocktranslate %}</h5>
<p class="card-text">

View File

@ -35,9 +35,6 @@
<h2>{% translate "Turnover by payment method" %}</h2>
{% include "purchase/snippets/report_payment_methods.html" %}
<h2>{% translate "Baskets without payment method" %}</h2>
{% include "purchase/snippets/report_no_payment_method.html" %}
{% endblock %}
{% block extrascript %}

View File

@ -15,7 +15,7 @@
<h4 class="card-title">{{ product.name }}</h4>
<div class="input-group">
<button class="btn btn-danger" type="button" onclick="decrementValue('{{ field.id_for_label }}')"><i class="fa-solid fa-minus"></i></button>
{% crispy_field field 'class' 'form-control' %}
{% crispy_field field 'class' 'form-control' 'inputmode' 'numeric' %}
<button class="btn btn-success {% if field.value %}border-white{% endif %}" type="button" onclick="incrementValue('{{ field.id_for_label }}')"><i class="fa-solid fa-plus"></i></button>
</div>
</div>

View File

@ -0,0 +1,25 @@
{% 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="1" inputmode="numeric" min="0"
name="unpriced_product-{{ product.pk }}" class="numberinput form-control" required="" id="unpriced_id_product-{{ product.pk }}"
value="{{ value|default:0 }}"
>
<span class="input-group-text" id="basic-addon1">cts</span>
</div>
</div>
</div>
</div>

View File

@ -1,21 +0,0 @@
{% load i18n %}
{% load purchase %}
<table class="table table-hover table-sm">
<thead><tr>
<th scope="col">{% translate "Basket" %}</th>
<th scope="col">{% translate "Price" %}</th>
</tr></thead>
<tbody>
{% for basket in no_payment_method %}
<tr>
<th scope="row">
<a href="{% url "purchase:update" basket.id %}">
{{ basket }}
</a>
</th>
<td>{{ basket.price|currency }}</td>
</tr>
{% endfor %}
</tbody>
</table>

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,15 +91,45 @@ def test_cashier_create_and_update_basket( # noqa: PLR0915
chain.double_click(quantity_input).perform()
quantity_input.send_keys("4")
# Don't add payment method
# 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")
# Add payment method
selenium.find_element(By.TAG_NAME, "html").send_keys(Keys.END)
time.sleep(1)
selenium.find_element(
By.CSS_SELECTOR,
f'input[type="radio"][value="{payment_methods[1].pk}"]',
).click()
# Save
selenium.find_element(By.ID, "submit-id-submit").click()
# Assert entries saved in DB (new basket with proper products)
assert Basket.objects.count() == 1
basket = Basket.objects.priced().first()
assert basket.payment_method is None
assert basket.items.count() == 2
assert basket.payment_method == payment_methods[1]
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 +140,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)
@ -113,10 +157,6 @@ def test_cashier_create_and_update_basket( # noqa: PLR0915
created_message = selenium.find_element(By.CSS_SELECTOR, ".messages .alert-success")
assert created_message.text == "Panier correctement créé."
# Assert message in red for missing payment method
missing_payment = selenium.find_element(By.CSS_SELECTOR, ".alert.alert-danger")
assert missing_payment.text == "Moyen de paiement manquant."
# Assert ID, price, date & product quantities
# Selected products have a green background
title = selenium.find_element(By.TAG_NAME, "h1")
@ -141,6 +181,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()
@ -150,11 +196,6 @@ def test_cashier_create_and_update_basket( # noqa: PLR0915
quantity = int(quantity_input.get_attribute("value"))
assert quantity == 3
# Add payment method
selenium.find_element(By.TAG_NAME, "html").send_keys(Keys.END)
time.sleep(1)
selenium.find_element(By.ID, f"id_payment_method_{payment_methods[1].pk}").click()
# Save
selenium.find_element(By.ID, "submit-id-submit").click()
@ -162,7 +203,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 +214,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)
@ -224,34 +273,30 @@ def test_baskets_list(live_server: LiveServer, selenium: WebDriver):
pk=basket_with_payment_method.pk,
)
with freezegun.freeze_time("2022-09-24 19:02:00+0200"):
basket_no_payment_method = BasketWithItemsFactory(payment_method=None)
basket_no_payment_method = Basket.objects.priced().get(
pk=basket_no_payment_method.pk,
another_basket = BasketWithItemsFactory()
another_basket = Basket.objects.priced().get(
pk=another_basket.pk,
)
# Login
url = reverse("purchase:list")
login(live_server, selenium, cashier, url)
# Assert first basket (last created) has yellow background
# Assert basket info displayed
displayed_baskets = selenium.find_elements(By.CSS_SELECTOR, ".card.h-100")
first_basket = displayed_baskets[0]
assert "bg-warning" in first_basket.get_attribute("class")
text = first_basket.text.replace("\n", " ")
assert f"{basket_no_payment_method.pk} " in text
expected_articles_count = basket_no_payment_method.items.count()
assert f"{another_basket.pk} " in text
expected_articles_count = another_basket.items.count()
assert f" {expected_articles_count} article" in text
expected_price = basket_no_payment_method.price / 100
expected_price = another_basket.price / 100
assert f" {expected_price:.2f}" in text
expected_payment_method = "-"
expected_payment_method = another_basket.payment_method.name
assert f" {expected_payment_method} " in text
assert "19:02" in text
# Assert second basket (first created) doesn't have yellow background
# Assert basket info displayed including payment method
second_basket = displayed_baskets[1]
assert "bg-warning" not in second_basket.get_attribute("class")
text = second_basket.text.replace("\n", " ")
assert f"{basket_with_payment_method.pk} " in text
expected_articles_count = basket_with_payment_method.items.count()
@ -270,7 +315,7 @@ def test_baskets_list(live_server: LiveServer, selenium: WebDriver):
# Assert object deleted in DB
assert Basket.objects.count() == 1
assert Basket.objects.first() == basket_no_payment_method
assert Basket.objects.first() == another_basket
# Assert redirected to list view
wait.until(
@ -285,7 +330,7 @@ def test_baskets_list(live_server: LiveServer, selenium: WebDriver):
redirect_url = live_reverse(
live_server,
"purchase:update",
pk=basket_no_payment_method.pk,
pk=another_basket.pk,
)
wait.until(lambda driver: driver.current_url == redirect_url)

View File

@ -1,14 +1,27 @@
from django.urls import path
from purchase.views import delete_basket, list_baskets, new_basket, update_basket
from purchase.views import (
additional_unpriced_product,
delete_basket,
list_baskets,
new_basket,
price_preview,
update_basket,
)
from purchase.views.reports import by_hour_plot_view, products_plots_view, reports
app_name = "purchase"
urlpatterns = [
path("", list_baskets, name="list"),
path("price_preview/", price_preview, name="price_preview"),
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,3 +1,17 @@
from .basket import delete_basket, list_baskets, new_basket, update_basket
from .basket import (
additional_unpriced_product,
delete_basket,
list_baskets,
new_basket,
price_preview,
update_basket,
)
__all__ = ["new_basket", "update_basket", "delete_basket", "list_baskets"]
__all__ = [
"new_basket",
"update_basket",
"delete_basket",
"list_baskets",
"additional_unpriced_product",
"price_preview",
]

View File

@ -1,25 +1,33 @@
import logging
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.core.handlers.wsgi import WSGIRequest
from django.http import HttpResponse
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 PRICED_PREFIX, 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,39 +35,79 @@ 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 map(int, prices):
if price:
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,
)
@permission_required("purchase.view_basket")
@condition(etag_func=reports_etag, last_modified_func=reports_last_modified)
def list_baskets(request: HttpRequest) -> HttpResponse:
def list_baskets(request: WSGIRequest) -> HttpResponse:
context = {"baskets": Basket.objects.priced().order_by("-id")}
return TemplateResponse(request, "purchase/basket_list.html", context)
@require_http_methods(["GET", "POST"])
@permission_required("purchase.delete_basket")
def delete_basket(request: HttpRequest, pk: int) -> HttpResponse:
def delete_basket(request: WSGIRequest, pk: int) -> HttpResponse:
basket = get_object_or_404(Basket, pk=pk)
if request.method == "GET":
context = {"basket": basket}
@ -67,3 +115,21 @@ def delete_basket(request: HttpRequest, pk: int) -> HttpResponse:
basket.delete()
messages.success(request, _("Basket successfully deleted."))
return redirect("purchase:list")
@require_http_methods(["POST"])
@permission_required("purchase.add_basket")
def price_preview(request: WSGIRequest) -> HttpResponse:
total = 0
for name in request.POST:
if name.startswith(PRICED_PREFIX):
product_id = name[len(PRICED_PREFIX) :]
product = get_object_or_404(Product, pk=product_id)
total += product.unit_price_cents * int(request.POST.get(name, 0))
elif name.startswith(UNPRICED_PREFIX):
total += sum(map(int, request.POST.getlist(name)))
total = f"{total/100:.2f}"
return HttpResponse(
f'<span hx-swap-oob="true" id="basket-price" class="badge bg-secondary">{total}</span>Montant total : {total}',
)

View File

@ -79,7 +79,6 @@ def reports(request):
"average_basket_by_day": average_basket_by_day,
"products": products,
"payment_methods": PaymentMethod.objects.with_turnover().with_sold(),
"no_payment_method": Basket.objects.no_payment_method().priced(),
}
return TemplateResponse(request, template_name, context)