diff --git a/poetry.lock b/poetry.lock
index bfea066..2aa3afc 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -122,6 +122,21 @@ tomli = {version = "*", optional = true, markers = "extra == \"toml\""}
[package.extras]
toml = ["tomli"]
+[[package]]
+name = "crispy-bootstrap5"
+version = "0.6"
+description = "Bootstrap5 template pack for django-crispy-forms"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+django = ">=2.2"
+django-crispy-forms = ">=1.13.0"
+
+[package.extras]
+test = ["pytest", "pytest-django"]
+
[[package]]
name = "curtsies"
version = "0.3.10"
@@ -192,6 +207,14 @@ category = "main"
optional = false
python-versions = "*"
+[[package]]
+name = "django-crispy-forms"
+version = "1.14.0"
+description = "Best way to have Django DRY forms"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
[[package]]
name = "django-csp"
version = "3.7"
@@ -642,7 +665,7 @@ brotli = ["brotli"]
[metadata]
lock-version = "1.1"
python-versions = "^3.10"
-content-hash = "86cec3b5cbae86ec8a2e77f3e77139e12c48bcc8aa79918daed41f56f48597f4"
+content-hash = "41c39a9ccca46b60a7ad37f03ca80ee2787f0eba1a07a74ee2eb0722db1b656e"
[metadata.files]
asgiref = [
@@ -789,6 +812,10 @@ coverage = [
{file = "coverage-6.3.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:18d520c6860515a771708937d2f78f63cc47ab3b80cb78e86573b0a760161faf"},
{file = "coverage-6.3.2.tar.gz", hash = "sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9"},
]
+crispy-bootstrap5 = [
+ {file = "crispy-bootstrap5-0.6.tar.gz", hash = "sha256:e8c50daaf266d6127c7414f5669d5f2467b640d08b045ed9dd57e351d30609ae"},
+ {file = "crispy_bootstrap5-0.6-py3-none-any.whl", hash = "sha256:ca5d2afc2a5d1cf12b8d057886a81002cdf7793afcbe0ce006c2294069c1f387"},
+]
curtsies = [
{file = "curtsies-0.3.10.tar.gz", hash = "sha256:11efbb153d9cb22223dd9a44041ea0c313b8411e246e7f684aa843f6aa9c1600"},
]
@@ -839,6 +866,10 @@ django-cleanup = [
{file = "django-cleanup-5.2.0.tar.gz", hash = "sha256:909d10ff574f5ce1a40fa63bd5c94c9ed866fd7ae770994c46cdf66c3db3e846"},
{file = "django_cleanup-5.2.0-py2.py3-none-any.whl", hash = "sha256:193cf69de54b9fc0a0f4547edbb3a63bbe01728cb029f9f4b7912098cc1bced7"},
]
+django-crispy-forms = [
+ {file = "django-crispy-forms-1.14.0.tar.gz", hash = "sha256:35887b8851a931374dd697207a8f56c57a9c5cb9dbf0b9fa54314da5666cea5b"},
+ {file = "django_crispy_forms-1.14.0-py3-none-any.whl", hash = "sha256:bc4d2037f6de602d39c0bc452ac3029d1f5d65e88458872cc4dbc01c3a400604"},
+]
django-csp = [
{file = "django_csp-3.7-py2.py3-none-any.whl", hash = "sha256:01443a07723f9a479d498bd7bb63571aaa771e690f64bde515db6cdb76e8041a"},
{file = "django_csp-3.7.tar.gz", hash = "sha256:01eda02ad3f10261c74131cdc0b5a6a62b7c7ad4fd017fbefb7a14776e0a9727"},
diff --git a/pyproject.toml b/pyproject.toml
index 1cd2fa6..b626266 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,6 +17,8 @@ django-extensions = "^3.1.5"
bpython = "^0.22.1"
gunicorn = "^20.1.0"
Pillow = "^9.1.0"
+django-crispy-forms = "^1.14.0"
+crispy-bootstrap5 = "^0.6"
[tool.poetry.dev-dependencies]
pre-commit = "^2.7"
diff --git a/src/checkout/settings.py b/src/checkout/settings.py
index 6ac93c2..2c69411 100644
--- a/src/checkout/settings.py
+++ b/src/checkout/settings.py
@@ -82,6 +82,8 @@ INSTALLED_APPS = [
"django_cleanup.apps.CleanupConfig",
"common",
"purchase",
+ "crispy_forms",
+ "crispy_bootstrap5",
]
MIDDLEWARE = [
@@ -195,7 +197,7 @@ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# CSP
CSP_DEFAULT_SRC = ("'none'",)
CSP_IMG_SRC = ("'self'",)
-CSP_SCRIPT_SRC = ("'self'",)
+CSP_SCRIPT_SRC = ("'self'", "'unsafe-inline'")
CSP_CONNECT_SRC = ("'self'",)
CSP_STYLE_SRC = ("'self'", "'unsafe-inline'")
CSP_MANIFEST_SRC = ("'self'",)
@@ -204,3 +206,6 @@ CSP_BASE_URI = ("'none'",)
CSP_FORM_ACTION = ("'self'",)
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
+
+CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
+CRISPY_TEMPLATE_PACK = "bootstrap5"
diff --git a/src/checkout/urls.py b/src/checkout/urls.py
index a4d7283..7cfca63 100644
--- a/src/checkout/urls.py
+++ b/src/checkout/urls.py
@@ -15,7 +15,7 @@ Including another URLconf
"""
from django.conf.urls.static import static
from django.contrib import admin
-from django.urls import path
+from django.urls import include, path
from django.views.generic import TemplateView
from checkout import settings
@@ -27,7 +27,9 @@ urlpatterns = [
template_name="common/robots.txt", content_type="text/plain"
),
),
+ path("", include("common.urls")),
path("admin/", admin.site.urls),
+ path("purchase/", include("purchase.urls")),
]
if settings.DEBUG:
diff --git a/src/common/templates/common/base.html b/src/common/templates/common/base.html
new file mode 100644
index 0000000..78c31df
--- /dev/null
+++ b/src/common/templates/common/base.html
@@ -0,0 +1,23 @@
+{% load static %}
+
+
+
+
+ Checkout
+
+ {% block extrahead %}
+ {% endblock %}
+
+
+
+ {% if user.is_staff %}
+
Admin
+
Baskets
+ {% endif %}
+ {% block content %}
+ {% endblock %}
+
+
+
+
diff --git a/src/common/urls.py b/src/common/urls.py
new file mode 100644
index 0000000..d33a0c7
--- /dev/null
+++ b/src/common/urls.py
@@ -0,0 +1,8 @@
+from django.urls import path
+
+from common.views import home
+
+app_name = "common"
+urlpatterns = [
+ path("", home, name="home"),
+]
diff --git a/src/common/views.py b/src/common/views.py
index 91ea44a..4eaa5f1 100644
--- a/src/common/views.py
+++ b/src/common/views.py
@@ -1,3 +1,5 @@
-from django.shortcuts import render
+from django.shortcuts import redirect
-# Create your views here.
+
+def home(request):
+ return redirect("purchase:new")
diff --git a/src/purchase/admin.py b/src/purchase/admin.py
index e306886..04ccd02 100644
--- a/src/purchase/admin.py
+++ b/src/purchase/admin.py
@@ -25,18 +25,18 @@ class BasketItemInline(admin.TabularInline):
extra = 0
readonly_fields = ["price"]
- def price(self, instance) -> float:
- return instance.price / 100
+ def price(self, instance) -> str:
+ return instance.price_display
@register(Basket)
class BasketAdmin(admin.ModelAdmin):
- list_display = ["id", "status", "payment_method", "created_at", "price"]
- fields = ["created_at", "status", "payment_method"]
- list_filter = ["status", "payment_method"]
+ list_display = ["id", "payment_method", "created_at", "price"]
+ fields = ["created_at", "payment_method"]
+ list_filter = ["payment_method"]
date_hierarchy = "created_at"
readonly_fields = ["created_at"]
inlines = [BasketItemInline]
- def price(self, instance) -> float:
- return instance.price / 100
+ def price(self, instance) -> str:
+ return instance.price_display
diff --git a/src/purchase/forms.py b/src/purchase/forms.py
new file mode 100644
index 0000000..4757d08
--- /dev/null
+++ b/src/purchase/forms.py
@@ -0,0 +1,59 @@
+from crispy_forms.helper import FormHelper
+from crispy_forms.layout import Div, Field, Layout, Submit
+from django import forms
+
+from purchase.layout import BasketItemField
+from purchase.models import Basket, Product
+
+PREFIX = "product-"
+
+
+class BasketForm(forms.ModelForm):
+ class Meta:
+ model = Basket
+ fields = ["payment_method"]
+
+ class Media:
+ js = ["purchase/js/basket_form.js"]
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.helper = FormHelper()
+ self.helper.add_input(Submit("submit", "Submit"))
+ self.helper.layout = Layout()
+ products = {}
+ basket = kwargs.get("instance")
+ if basket:
+ for item in basket.items.all():
+ products[item.product] = item.quantity
+ fields = []
+ for product in Product.objects.all():
+ field_name = f"{PREFIX}{product.id}"
+ self.fields.update(
+ {
+ field_name: forms.IntegerField(
+ label=product.name,
+ min_value=0,
+ initial=products.get(product, 0),
+ )
+ }
+ )
+ fields.append(BasketItemField(field_name, product=product))
+ self.helper.layout = Layout(
+ Div(*fields, css_class="row"),
+ Field("payment_method"),
+ )
+
+ def save(self, commit=True):
+ instance: Basket = super().save(commit=True)
+ name: str
+ for name, value in self.cleaned_data.items():
+ if name.startswith(PREFIX):
+ product_id = int(name.removeprefix(PREFIX))
+ if value > 0:
+ instance.items.update_or_create(
+ product_id=product_id, defaults={"quantity": value}
+ )
+ if value == 0:
+ instance.items.filter(product_id=product_id).delete()
+ return instance
diff --git a/src/purchase/layout.py b/src/purchase/layout.py
new file mode 100644
index 0000000..625138c
--- /dev/null
+++ b/src/purchase/layout.py
@@ -0,0 +1,15 @@
+from crispy_forms.layout import Field
+
+
+class BasketItemField(Field):
+ template = "purchase/basket_item.html"
+
+ def __init__(self, *args, product, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.product = product
+
+ def render(self, *args, **kwargs):
+ extra_context = kwargs.get("extra_context", {})
+ extra_context.update({"product": self.product})
+ kwargs["extra_context"] = extra_context
+ return super().render(*args, **kwargs)
diff --git a/src/purchase/models.py b/src/purchase/models.py
index 410f695..3441656 100644
--- a/src/purchase/models.py
+++ b/src/purchase/models.py
@@ -1,4 +1,5 @@
from django.db import models
+from django.urls import reverse
class Model(models.Model):
@@ -34,12 +35,6 @@ class Product(Model):
class Basket(Model):
- STATUS_DRAFT = "DRAFT"
- STATUS_COMPLETE = "COMPLETE"
- _STATUS_CHOICES = [
- (STATUS_DRAFT, "Draft"),
- (STATUS_COMPLETE, "Complete"),
- ]
payment_method = models.ForeignKey(
to=PaymentMethod,
on_delete=models.PROTECT,
@@ -47,9 +42,6 @@ class Basket(Model):
null=True,
blank=True,
)
- status = models.CharField(
- max_length=20, choices=_STATUS_CHOICES, default=STATUS_DRAFT
- )
def __str__(self):
return f"Panier #{self.id}"
@@ -58,6 +50,14 @@ class Basket(Model):
def price(self) -> int:
return sum(item.price for item in self.items.all())
+ @property
+ def price_display(self) -> str:
+ price = self.price / 100
+ return f"{price}€"
+
+ def get_absolute_url(self):
+ return reverse("purchase:update", args=(self.pk,))
+
class BasketItem(Model):
product = models.ForeignKey(
@@ -71,3 +71,8 @@ class BasketItem(Model):
@property
def price(self) -> int:
return self.product.unit_price_cents * self.quantity
+
+ @property
+ def price_display(self) -> str:
+ price = self.price / 100
+ return f"{price}€"
diff --git a/src/purchase/static/purchase/js/basket_form.js b/src/purchase/static/purchase/js/basket_form.js
new file mode 100644
index 0000000..9d7437f
--- /dev/null
+++ b/src/purchase/static/purchase/js/basket_form.js
@@ -0,0 +1,14 @@
+window.incrementValue = function (id) {
+ let value = parseInt(document.getElementById(id).value);
+ value = isNaN(value) ? 0 : value;
+ value++;
+ document.getElementById(id).value = value;
+};
+
+window.decrementValue = function (id) {
+ let value = parseInt(document.getElementById(id).value);
+ value = isNaN(value) ? 0 : value;
+ value--;
+ value = value < 0 ? 0 : value;
+ document.getElementById(id).value = value;
+};
diff --git a/src/purchase/templates/purchase/basket_form.html b/src/purchase/templates/purchase/basket_form.html
new file mode 100644
index 0000000..1c6a268
--- /dev/null
+++ b/src/purchase/templates/purchase/basket_form.html
@@ -0,0 +1,16 @@
+{% extends "common/base.html" %}
+{% load crispy_forms_tags %}
+{% block content %}
+ {% if object %}
+ {{ object }}
+ {% else %}
+ New basket
+ {% endif %}
+ {% crispy form %}
+ {% if object %}
+
+ Price: {{ object.price_display }}
+
+ New
+ {% endif %}
+{% endblock %}
diff --git a/src/purchase/templates/purchase/basket_item.html b/src/purchase/templates/purchase/basket_item.html
new file mode 100644
index 0000000..62f704c
--- /dev/null
+++ b/src/purchase/templates/purchase/basket_item.html
@@ -0,0 +1,16 @@
+{% load crispy_forms_field %}
+
+
+ {% if product.image %}
+
+ {% endif %}
+
+
{{ product.name }}
+
+
+ {% crispy_field field 'class' 'form-control' %}
+
+
+
+
+
diff --git a/src/purchase/urls.py b/src/purchase/urls.py
new file mode 100644
index 0000000..83921e8
--- /dev/null
+++ b/src/purchase/urls.py
@@ -0,0 +1,9 @@
+from django.urls import path
+
+from purchase.views import NewBasketView, UpdateBasketView
+
+app_name = "purchase"
+urlpatterns = [
+ path("new/", NewBasketView.as_view(), name="new"),
+ path("update//", UpdateBasketView.as_view(), name="update"),
+]
diff --git a/src/purchase/views.py b/src/purchase/views.py
index 91ea44a..3b579a5 100644
--- a/src/purchase/views.py
+++ b/src/purchase/views.py
@@ -1,3 +1,22 @@
-from django.shortcuts import render
+from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
+from django.urls import reverse
+from django.views.generic import CreateView, UpdateView
-# Create your views here.
+from purchase.forms import BasketForm
+from purchase.models import Basket
+
+
+class ProtectedViewsMixin(PermissionRequiredMixin, LoginRequiredMixin):
+ pass
+
+
+class NewBasketView(ProtectedViewsMixin, CreateView):
+ permission_required = ["purchase.add_basket"]
+ model = Basket
+ form_class = BasketForm
+
+
+class UpdateBasketView(ProtectedViewsMixin, UpdateView):
+ permission_required = ["purchase.change_basket", "purchase.view_basket"]
+ model = Basket
+ form_class = BasketForm