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