Implement basic baskets features

This commit is contained in:
Gabriel Augendre 2022-04-24 18:59:04 +02:00
parent eead96359b
commit d97030da6f
16 changed files with 249 additions and 23 deletions

33
poetry.lock generated
View file

@ -122,6 +122,21 @@ tomli = {version = "*", optional = true, markers = "extra == \"toml\""}
[package.extras] [package.extras]
toml = ["tomli"] 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]] [[package]]
name = "curtsies" name = "curtsies"
version = "0.3.10" version = "0.3.10"
@ -192,6 +207,14 @@ category = "main"
optional = false optional = false
python-versions = "*" 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]] [[package]]
name = "django-csp" name = "django-csp"
version = "3.7" version = "3.7"
@ -642,7 +665,7 @@ brotli = ["brotli"]
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "86cec3b5cbae86ec8a2e77f3e77139e12c48bcc8aa79918daed41f56f48597f4" content-hash = "41c39a9ccca46b60a7ad37f03ca80ee2787f0eba1a07a74ee2eb0722db1b656e"
[metadata.files] [metadata.files]
asgiref = [ 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-pp36.pp37.pp38-none-any.whl", hash = "sha256:18d520c6860515a771708937d2f78f63cc47ab3b80cb78e86573b0a760161faf"},
{file = "coverage-6.3.2.tar.gz", hash = "sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9"}, {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 = [ curtsies = [
{file = "curtsies-0.3.10.tar.gz", hash = "sha256:11efbb153d9cb22223dd9a44041ea0c313b8411e246e7f684aa843f6aa9c1600"}, {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.tar.gz", hash = "sha256:909d10ff574f5ce1a40fa63bd5c94c9ed866fd7ae770994c46cdf66c3db3e846"},
{file = "django_cleanup-5.2.0-py2.py3-none-any.whl", hash = "sha256:193cf69de54b9fc0a0f4547edbb3a63bbe01728cb029f9f4b7912098cc1bced7"}, {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 = [ django-csp = [
{file = "django_csp-3.7-py2.py3-none-any.whl", hash = "sha256:01443a07723f9a479d498bd7bb63571aaa771e690f64bde515db6cdb76e8041a"}, {file = "django_csp-3.7-py2.py3-none-any.whl", hash = "sha256:01443a07723f9a479d498bd7bb63571aaa771e690f64bde515db6cdb76e8041a"},
{file = "django_csp-3.7.tar.gz", hash = "sha256:01eda02ad3f10261c74131cdc0b5a6a62b7c7ad4fd017fbefb7a14776e0a9727"}, {file = "django_csp-3.7.tar.gz", hash = "sha256:01eda02ad3f10261c74131cdc0b5a6a62b7c7ad4fd017fbefb7a14776e0a9727"},

View file

@ -17,6 +17,8 @@ django-extensions = "^3.1.5"
bpython = "^0.22.1" bpython = "^0.22.1"
gunicorn = "^20.1.0" gunicorn = "^20.1.0"
Pillow = "^9.1.0" Pillow = "^9.1.0"
django-crispy-forms = "^1.14.0"
crispy-bootstrap5 = "^0.6"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pre-commit = "^2.7" pre-commit = "^2.7"

View file

@ -82,6 +82,8 @@ INSTALLED_APPS = [
"django_cleanup.apps.CleanupConfig", "django_cleanup.apps.CleanupConfig",
"common", "common",
"purchase", "purchase",
"crispy_forms",
"crispy_bootstrap5",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@ -195,7 +197,7 @@ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# CSP # CSP
CSP_DEFAULT_SRC = ("'none'",) CSP_DEFAULT_SRC = ("'none'",)
CSP_IMG_SRC = ("'self'",) CSP_IMG_SRC = ("'self'",)
CSP_SCRIPT_SRC = ("'self'",) CSP_SCRIPT_SRC = ("'self'", "'unsafe-inline'")
CSP_CONNECT_SRC = ("'self'",) CSP_CONNECT_SRC = ("'self'",)
CSP_STYLE_SRC = ("'self'", "'unsafe-inline'") CSP_STYLE_SRC = ("'self'", "'unsafe-inline'")
CSP_MANIFEST_SRC = ("'self'",) CSP_MANIFEST_SRC = ("'self'",)
@ -204,3 +206,6 @@ CSP_BASE_URI = ("'none'",)
CSP_FORM_ACTION = ("'self'",) CSP_FORM_ACTION = ("'self'",)
DEFAULT_AUTO_FIELD = "django.db.models.AutoField" DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
CRISPY_TEMPLATE_PACK = "bootstrap5"

View file

@ -15,7 +15,7 @@ Including another URLconf
""" """
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import include, path
from django.views.generic import TemplateView from django.views.generic import TemplateView
from checkout import settings from checkout import settings
@ -27,7 +27,9 @@ urlpatterns = [
template_name="common/robots.txt", content_type="text/plain" template_name="common/robots.txt", content_type="text/plain"
), ),
), ),
path("", include("common.urls")),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("purchase/", include("purchase.urls")),
] ]
if settings.DEBUG: if settings.DEBUG:

View file

@ -0,0 +1,23 @@
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Checkout</title>
<link href="{% static "vendor/bootstrap-5.1.3-dist/css/bootstrap.min.css" %}"
rel="stylesheet">
{% block extrahead %}
{% endblock %}
</head>
<body>
<div class="container">
{% if user.is_staff %}
<a href="{% url "admin:index" %}" class="btn btn-outline-secondary">Admin</a>
<a href="{% url "admin:purchase_basket_changelist" %}" class="btn btn-outline-secondary">Baskets</a>
{% endif %}
{% block content %}
{% endblock %}
</div>
<script src="{% static "vendor/bootstrap-5.1.3-dist/js/bootstrap.bundle.min.js" %}"></script>
</body>
</html>

8
src/common/urls.py Normal file
View file

@ -0,0 +1,8 @@
from django.urls import path
from common.views import home
app_name = "common"
urlpatterns = [
path("", home, name="home"),
]

View file

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

View file

@ -25,18 +25,18 @@ class BasketItemInline(admin.TabularInline):
extra = 0 extra = 0
readonly_fields = ["price"] readonly_fields = ["price"]
def price(self, instance) -> float: def price(self, instance) -> str:
return instance.price / 100 return instance.price_display
@register(Basket) @register(Basket)
class BasketAdmin(admin.ModelAdmin): class BasketAdmin(admin.ModelAdmin):
list_display = ["id", "status", "payment_method", "created_at", "price"] list_display = ["id", "payment_method", "created_at", "price"]
fields = ["created_at", "status", "payment_method"] fields = ["created_at", "payment_method"]
list_filter = ["status", "payment_method"] list_filter = ["payment_method"]
date_hierarchy = "created_at" date_hierarchy = "created_at"
readonly_fields = ["created_at"] readonly_fields = ["created_at"]
inlines = [BasketItemInline] inlines = [BasketItemInline]
def price(self, instance) -> float: def price(self, instance) -> str:
return instance.price / 100 return instance.price_display

59
src/purchase/forms.py Normal file
View file

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

15
src/purchase/layout.py Normal file
View file

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

View file

@ -1,4 +1,5 @@
from django.db import models from django.db import models
from django.urls import reverse
class Model(models.Model): class Model(models.Model):
@ -34,12 +35,6 @@ class Product(Model):
class Basket(Model): class Basket(Model):
STATUS_DRAFT = "DRAFT"
STATUS_COMPLETE = "COMPLETE"
_STATUS_CHOICES = [
(STATUS_DRAFT, "Draft"),
(STATUS_COMPLETE, "Complete"),
]
payment_method = models.ForeignKey( payment_method = models.ForeignKey(
to=PaymentMethod, to=PaymentMethod,
on_delete=models.PROTECT, on_delete=models.PROTECT,
@ -47,9 +42,6 @@ class Basket(Model):
null=True, null=True,
blank=True, blank=True,
) )
status = models.CharField(
max_length=20, choices=_STATUS_CHOICES, default=STATUS_DRAFT
)
def __str__(self): def __str__(self):
return f"Panier #{self.id}" return f"Panier #{self.id}"
@ -58,6 +50,14 @@ class Basket(Model):
def price(self) -> int: def price(self) -> int:
return sum(item.price for item in self.items.all()) 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): class BasketItem(Model):
product = models.ForeignKey( product = models.ForeignKey(
@ -71,3 +71,8 @@ class BasketItem(Model):
@property @property
def price(self) -> int: def price(self) -> int:
return self.product.unit_price_cents * self.quantity return self.product.unit_price_cents * self.quantity
@property
def price_display(self) -> str:
price = self.price / 100
return f"{price}"

View file

@ -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;
};

View file

@ -0,0 +1,16 @@
{% extends "common/base.html" %}
{% load crispy_forms_tags %}
{% block content %}
{% if object %}
<h1>{{ object }}</h1>
{% else %}
<h1>New basket</h1>
{% endif %}
{% crispy form %}
{% if object %}
<div>
Price: {{ object.price_display }}
</div>
<a href="{% url "purchase:new" %}" class="btn btn-secondary">New</a>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,16 @@
{% load crispy_forms_field %}
<div class="col-sm-3 mb-3 mt-3">
<div class="card">
{% if product.image %}
<img src="{{ product.image.url }}" class="card-img-top">
{% endif %}
<div class="card-body">
<h5 class="card-title">{{ product.name }}</h5>
<div class="input-group">
<button class="btn btn-danger" type="button" onclick="decrementValue('{{ field.id_for_label }}')">-</button>
{% crispy_field field 'class' 'form-control' %}
<button class="btn btn-success" type="button" onclick="incrementValue('{{ field.id_for_label }}')">+</button>
</div>
</div>
</div>
</div>

9
src/purchase/urls.py Normal file
View file

@ -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/<int:pk>/", UpdateBasketView.as_view(), name="update"),
]

View file

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