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]
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"},

View file

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

View file

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

View file

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

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

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.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}"

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