mirror of
https://github.com/Crocmagnon/checkout.git
synced 2024-12-22 06:01:47 +01:00
Implement basic baskets features
This commit is contained in:
parent
eead96359b
commit
d97030da6f
16 changed files with 249 additions and 23 deletions
33
poetry.lock
generated
33
poetry.lock
generated
|
@ -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"},
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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:
|
||||
|
|
23
src/common/templates/common/base.html
Normal file
23
src/common/templates/common/base.html
Normal 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
8
src/common/urls.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from django.urls import path
|
||||
|
||||
from common.views import home
|
||||
|
||||
app_name = "common"
|
||||
urlpatterns = [
|
||||
path("", home, name="home"),
|
||||
]
|
|
@ -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")
|
||||
|
|
|
@ -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
59
src/purchase/forms.py
Normal 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
15
src/purchase/layout.py
Normal 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)
|
|
@ -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}€"
|
||||
|
|
14
src/purchase/static/purchase/js/basket_form.js
Normal file
14
src/purchase/static/purchase/js/basket_form.js
Normal 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;
|
||||
};
|
16
src/purchase/templates/purchase/basket_form.html
Normal file
16
src/purchase/templates/purchase/basket_form.html
Normal 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 %}
|
16
src/purchase/templates/purchase/basket_item.html
Normal file
16
src/purchase/templates/purchase/basket_item.html
Normal 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
9
src/purchase/urls.py
Normal 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"),
|
||||
]
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue