Merge pull request #9 from mtyton/feature/shopping_cart

Added a shopping cart feature.
pull/2/head
mtyton 2023-06-03 12:08:01 +02:00 zatwierdzone przez GitHub
commit 81c5926261
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
29 zmienionych plików z 1016 dodań i 23 usunięć

1
.gitignore vendored
Wyświetl plik

@ -139,6 +139,7 @@ GitHub.sublime-settings
#postgres pass files
*.my_pgpass
*.sql
artel/static/
# media
artel/media/*

Wyświetl plik

@ -23,7 +23,9 @@ RUN apt-get update --yes --quiet && apt-get install --yes --quiet --no-install-r
# Install the project requirements.
COPY requirements.txt /
COPY requirements_dev.txt /
RUN pip install -r /requirements.txt
RUN pip install -r /requirements_dev.txt
# Use /app folder as a directory where the source code is stored.
WORKDIR /app

Wyświetl plik

@ -49,6 +49,8 @@ INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"phonenumber_field",
]
MIDDLEWARE = [
@ -173,3 +175,6 @@ WAGTAILADMIN_BASE_URL = "http://example.com"
# STORE SETTINGS
PRODUCTS_PER_PAGE = 6
# CART settings
CART_SESSION_ID = 'cart'

Wyświetl plik

@ -0,0 +1,23 @@
.shaded-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 5000;
}
.unshaded-overlay{
position: absolute;
top: 25%;
left: 35%;
width: 30%;
height: 30%;
background-color: rgb(255,255,255);
z-index: 9999;
}
.input-container {
max-width: 50px;
}

Wyświetl plik

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5Zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5Zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6Z"/>
<path d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1ZM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118ZM2.5 3h11V2h-11v1Z"/>
</svg>

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 553 B

Wyświetl plik

@ -51,7 +51,7 @@
<script type="text/javascript" src="{% static 'js/jquery-3.6.4.min.js' %}"></script>
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.14.7/dist/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"></script>
<script type="text/javascript" src="{% static 'bootstrap/js/bootstrap.bundle.min.js' %}"></script>
<script src="{% static 'js/cart.js' %}"></script>
{% block extra_js %}
{# Override this in templates to add extra javascript #}
{% endblock %}

Wyświetl plik

@ -19,6 +19,11 @@
</li>
{% endfor %}
</ul>
</div>
<div class="d-flex flex-column flex-shrink-0 p-3 mr-5" style="width: 280px;">
<hr>
</div>
<a href={% url 'cart' %} alt="Koszyk" > Koszyk </a>
</div>

Wyświetl plik

@ -1,7 +1,11 @@
from django.conf import settings
from django.urls import include, path
from django.urls import (
include,
path
)
from django.contrib import admin
from wagtail.admin import urls as wagtailadmin_urls
from wagtail import urls as wagtail_urls
from wagtail.documents import urls as wagtaildocs_urls
@ -12,7 +16,8 @@ urlpatterns = [
path("django-admin/", admin.site.urls),
path("admin/", include(wagtailadmin_urls)),
path("documents/", include(wagtaildocs_urls)),
path("search/", search_views.search, name="search")
path("search/", search_views.search, name="search"),
path("store-app/", include("store.urls"))
]
@ -22,7 +27,8 @@ if settings.DEBUG:
# Serve static and media files from development server
urlpatterns += staticfiles_urlpatterns()
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.MEDIA_URL,
document_root=settings.MEDIA_ROOT)
urlpatterns = urlpatterns + [
# For anything not caught by a more specific rule above, hand over to

Wyświetl plik

@ -13,6 +13,8 @@ services:
- DATABASE_URL
env_file:
- .env
stdin_open: true
tty: true
db:
image: postgres
restart: always

Wyświetl plik

@ -3,3 +3,6 @@ wagtail>=4.2,<4.3
wagtailmenus>=3.1.5,<=3.1.7
psycopg2>=2.9.5,<=2.9.6
dj-database-url<=2.0.0
djangorestframework==3.14.0
phonenumbers==8.13.13
django-phonenumber-field==7.1.0

Wyświetl plik

@ -2,3 +2,4 @@ FLAKE8>=6.0.0
pre-commit>=3.3.1
isort>=5.12
black>=23.3.0
ipdb==0.12.3

Wyświetl plik

@ -0,0 +1,96 @@
from abc import (
ABC,
abstractmethod
)
from typing import List
from dataclasses import dataclass
from django.http.request import HttpRequest
from django.conf import settings
from store.models import Product
@dataclass
class CartItem:
product: Product
quantity: int
class BaseCart(ABC):
def validate_item_id(self, item_id):
return Product.objects.get(id=item_id)
@abstractmethod
def add_item(self, item_id, quantity):
...
@abstractmethod
def remove_item(self, item_id):
...
@abstractmethod
def update_item_quantity(self, item_id, change):
...
@abstractmethod
def get_items(self):
...
class SessionCart(BaseCart):
def __init__(self, request: HttpRequest) -> None:
super().__init__()
self.session = request.session
if not self.session.get(settings.CART_SESSION_ID):
self.session[settings.CART_SESSION_ID] = {}
def add_item(self, item_id: int, quantity: int) -> None:
# TODO - add logging
self.validate_item_id(item_id)
quantity = int(quantity)
item_id = int(item_id)
if not self.session[settings.CART_SESSION_ID].get(str(item_id)):
self.session[settings.CART_SESSION_ID][item_id] = quantity
self.session.modified = True
else:
self.update_item_quantity(item_id, quantity)
def remove_item(self, item_id: int) -> None:
self.validate_item_id(item_id)
try:
self.session[settings.CART_SESSION_ID].pop(item_id)
self.session.modified = True
except KeyError:
# TODO - add logging
...
def update_item_quantity(self, item_id: int, change: int) -> None:
self.validate_item_id(item_id)
try:
self.session[settings.CART_SESSION_ID][str(item_id)] += change
self.session.modified = True
except KeyError:
# TODO - add logging
self.add_item(item_id, change)
def get_items(self) -> List[CartItem]:
_items = []
for item_id, quantity in self.session[settings.CART_SESSION_ID].items():
_items.append(CartItem(quantity=quantity, product=Product.objects.get(id=item_id)))
return _items
@property
def total_price(self):
total = 0
for item in self.get_items():
total += item.product.price * int(item.quantity)
return total
def is_empty(self) -> bool:
return not bool(self.session[settings.CART_SESSION_ID].items())
def clear(self) -> None:
self.session[settings.CART_SESSION_ID] = {}
self.session.modified = True

Wyświetl plik

@ -0,0 +1,43 @@
from django import forms
from phonenumber_field.formfields import PhoneNumberField
# from phonenumber_field.widgets import PhoneNumberPrefixWidget
from store.models import (
CustomerData,
)
class CustomerDataForm(forms.ModelForm):
class Meta:
model = CustomerData
fields = [
"name", "surname", "email", "phone",
"street", "city", "zip_code"
]
name = forms.CharField(
max_length=255, label="Imię", widget=forms.TextInput(attrs={"class": "form-control"})
)
surname = forms.CharField(
max_length=255, label="Nazwisko", widget=forms.TextInput(attrs={"class": "form-control"})
)
street = forms.CharField(
max_length=255, label="Adres", widget=forms.TextInput(attrs={"class": "form-control"})
)
city = forms.CharField(
max_length=255, label="Miasto", widget=forms.TextInput(attrs={"class": "form-control"})
)
zip_code = forms.CharField(
max_length=255, label="Kod pocztowy", widget=forms.TextInput(attrs={"class": "form-control"})
)
email = forms.EmailField(
max_length=255, label="E-mail", widget=forms.EmailInput(attrs={"class": "form-control"})
)
phone = PhoneNumberField(
region="PL", label="Numer telefonu", widget=forms.TextInput(attrs={"class": "form-control"})
)
country = forms.ChoiceField(
choices=(("PL", "Polska"), ), label="Kraj",
widget=forms.Select(attrs={"class": "form-control"})
)

Wyświetl plik

@ -0,0 +1,28 @@
# Generated by Django 4.1.9 on 2023-05-28 11:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('store', '0002_product_available_producttemplate_tags_and_more'),
]
operations = [
migrations.AddField(
model_name='product',
name='info',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='product',
name='name',
field=models.CharField(blank=True, max_length=255),
),
migrations.AlterField(
model_name='producttemplate',
name='description',
field=models.TextField(blank=True),
),
]

Wyświetl plik

@ -0,0 +1,53 @@
# Generated by Django 4.1.9 on 2023-06-01 19:00
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import phonenumber_field.modelfields
class Migration(migrations.Migration):
dependencies = [
("store", "0003_product_info_product_name_and_more"),
]
operations = [
migrations.CreateModel(
name="CustomerData",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("name", models.CharField(max_length=255)),
("surname", models.CharField(max_length=255)),
("email", models.EmailField(max_length=254)),
("phone", phonenumber_field.modelfields.PhoneNumberField(max_length=128, region=None)),
("street", models.CharField(max_length=255)),
("city", models.CharField(max_length=255)),
("zip_code", models.CharField(max_length=120)),
("country", models.CharField(max_length=120)),
],
),
migrations.CreateModel(
name="Order",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("sent", models.BooleanField(default=False)),
("customer", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="store.customerdata")),
],
),
migrations.CreateModel(
name="OrderProduct",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("quantity", models.IntegerField(validators=[django.core.validators.MinValueValidator(1)])),
(
"order",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, related_name="products", to="store.order"
),
),
("product", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="store.product")),
],
),
]

Wyświetl plik

@ -4,6 +4,7 @@ from django.core.paginator import (
EmptyPage
)
from django.conf import settings
from django.core.validators import MinValueValidator
from modelcluster.models import ClusterableModel
from modelcluster.fields import ParentalKey
@ -14,6 +15,7 @@ from wagtail.admin.panels import (
from wagtail.models import Page
from wagtail import fields as wagtail_fields
from taggit.managers import TaggableManager
from phonenumber_field.modelfields import PhoneNumberField
class ProductAuthor(models.Model):
@ -56,7 +58,7 @@ class ProductTemplate(ClusterableModel):
author = models.ForeignKey(ProductAuthor, on_delete=models.CASCADE)
title = models.CharField(max_length=255)
code = models.CharField(max_length=255)
description = models.TextField()
description = models.TextField(blank=True)
tags = TaggableManager()
@ -82,6 +84,8 @@ class ProductImage(models.Model):
class Product(ClusterableModel):
name = models.CharField(max_length=255, blank=True)
info = models.TextField(blank=True)
template = models.ForeignKey(ProductTemplate, on_delete=models.CASCADE, related_name="products")
price = models.FloatField()
available = models.BooleanField(default=True)
@ -90,7 +94,9 @@ class Product(ClusterableModel):
FieldPanel("template"),
FieldPanel("price"),
InlinePanel("param_values"),
FieldPanel("available")
FieldPanel("available"),
FieldPanel("name"),
FieldPanel("info")
]
@property
@ -100,9 +106,17 @@ class Product(ClusterableModel):
if images:
return images.first().image
@property
def tags(self):
return self.template.tags.all()
@property
def description(self):
return self.info or self.template.description
@property
def title(self):
return f"{self.template.title} - {self.price}"
return self.name or self.template.title
class TemplateParamValue(models.Model):
@ -142,3 +156,46 @@ class ProductListPage(Page):
FieldPanel("description"),
FieldPanel("tags")
]
class CustomerData(models.Model):
name = models.CharField(max_length=255)
surname = models.CharField(max_length=255)
email = models.EmailField()
phone = PhoneNumberField()
street = models.CharField(max_length=255)
city = models.CharField(max_length=255)
zip_code = models.CharField(max_length=120)
country = models.CharField(max_length=120)
@property
def full_name(self):
return f"{self.name} {self.surname}"
@property
def full_address(self):
return f"{self.street}, {self.zip_code} {self.city}, {self.country}"
class OrderProductManager(models.Manager):
def create_from_cart(self, cart, order):
for item in cart:
self.create(
product=item.product,
order=order,
quantity=item.quantity
)
class OrderProduct(models.Model):
product = models.ForeignKey(Product, on_delete=models.CASCADE)
order = models.ForeignKey("Order", on_delete=models.CASCADE, related_name="products")
quantity = models.IntegerField(validators=[MinValueValidator(1)])
objects = OrderProductManager()
class Order(models.Model):
customer = models.ForeignKey(CustomerData, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
sent = models.BooleanField(default=False)

Wyświetl plik

@ -0,0 +1,37 @@
from rest_framework import serializers
from store.models import Product
class TagSerializer(serializers.Serializer):
name = serializers.CharField()
slug = serializers.CharField()
class ProductSerializer(serializers.ModelSerializer):
class Meta:
model = Product
fields = ["title", "description", "price", "available", "tags"]
tags = TagSerializer(many=True)
class CartProductSerializer(serializers.Serializer):
product = ProductSerializer()
quantity = serializers.IntegerField()
class CartProductAddSerializer(serializers.Serializer):
product_id = serializers.IntegerField()
quantity = serializers.IntegerField()
def validate_product_id(self, value):
try:
Product.objects.get(id=value)
except Product.DoesNotExist:
raise serializers.ValidationError("Unable to add not existing product")
return value
def save(self, cart):
cart.add_item(self.validated_data["product_id"], self.validated_data["quantity"])

Wyświetl plik

@ -0,0 +1,138 @@
$(document).on('click', '.add-to-cart-button', function(event) {
event.preventDefault();
const button = $(this);
const formData = new FormData();
const productID = parseInt($(this).data('product-id'));
const quantity = parseInt($('#quantity'+productID).val());
const addToCartURL = $(this).data('add-to-cart-url');
const csrfToken = $(this).data('csrf-token');
console.log(productID);
formData.append('product_id', productID);
formData.append('quantity', quantity); // Serialize the form data correctly
button.prop('disabled', true);
$.ajax({
type: 'POST',
url: addToCartURL,
data: formData, // Use the serialized form data
headers: { 'X-CSRFToken': csrfToken },
dataType: 'json',
processData: false, // Prevent jQuery from processing the data
contentType: false, // Let the browser set the content type
success: function(data) {
// Show the options block
//$('#addToCartModal').show();
//createShadedOverlay()
button.prop('disabled', false);
},
error: function() {
button.prop('disabled', false);
}
});
});
function createShadedOverlay() {
const body = document.body;
const overlay = document.createElement('div');
overlay.classList.add('shaded-overlay');
body.appendChild(overlay);
const optionsdiv = document.getElementById('addToCartModal');
optionsdiv.classList.add('unshaded-overlay');
body.appendChild(optionsdiv);
}
function removeShadedOverlay() {
const overlay = document.querySelector('.shaded-overlay');
if (overlay) {
overlay.remove();
}
}
const cartButton = document.getElementById('cart-button');
const cartDropdown = document.getElementById('cart-dropdown');
function fetchCartItems(xcsrf_token){
fetch('/store/cart-action/list-products')
.then(response => response.json())
.then(data => {
const cartItems = data.cart_items;
const cartItemsList = document.getElementById('cart-items');
const csrf_token = xcsrf_token
cartItemsList.innerHTML = ''; // Clear existing cart items
cartItems.forEach(item => {
const li = document.createElement('li');
li.textContent = `Product ID: ${item.product_id}, Quantity: `;
const quantityInput = document.createElement('input');
quantityInput.type = 'number';
quantityInput.classList.add('quantity-input');
quantityInput.value = item.quantity;
quantityInput.min = '1';
quantityInput.step = '1';
quantityInput.dataset.productId = item.product_id;
quantityInput.dataset.csrfToken = csrf_token;
li.appendChild(quantityInput);
li.appendChild(document.createTextNode(' '));
const removeButton = document.createElement('a');
removeButton.href = '#';
removeButton.classList.add('remove-from-cart-button');
removeButton.dataset.productId = item.product_id;
removeButton.dataset.csrfToken = csrf_token;
removeButton.textContent = 'Remove';
li.appendChild(removeButton);
cartItemsList.appendChild(li);
});
});
}
$(document).on('click', '.remove-from-cart-button', function(event) {
event.preventDefault();
const button = $(this);
const productId = button.data('product-id');
const csrfToken = button.data('csrf-token');
const url = button.data('remove-from-cart-url');
$.ajax({
type: 'POST',
url: url,
data: {"product_id": productId},
headers: { 'X-CSRFToken': csrfToken },
dataType: 'json',
success: function(data) {
alert("Item has been removed");
location.reload();
},
error: function() {
alert("Error occurred while removing the item from the cart.");
}
});
});
$(document).on('change', '.quantity-input', function(event) {
event.preventDefault();
const input = $(this);
const formData = new FormData();
const productID = $(this).data('product-id');
const newQuantity = input.val();
const csrfToken = $(this).data('csrf-token');
formData.append('product_id', productID);
formData.append('quantity', newQuantity);
$.ajax({
type: 'POST',
url: '/../../cart/item/',
data: formData, // Use the serialized form data
headers: { 'X-CSRFToken': csrfToken },
dataType: 'json',
processData: false, // Prevent jQuery from processing the data
contentType: false, // Let the browser set the content type
});
});

Wyświetl plik

@ -0,0 +1,32 @@
{% extends 'base.html' %}
{% load static %}
{% block content %}
<section class="h-100">
<div class="container h-100 py-5">
<div class="row d-flex justify-content-center align-items-center">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3 class="fw-normal mb-0 text-black">Shopping Cart</h3>
</div>
</div>
{% for item in cart.get_items %}
{% include 'store/partials/cart_item.html' %}
{% endfor %}
<div class="card ">
<div class="card-body">
<div class="row">
<div class="col-sm-6">
<h5 class="fw-normal mb-0 text-black">To Pay: {{cart.total_price}}</h5>
</div>
<div class="col-sm-6 text-end">
<a href="{% url 'order' %}" class="btn btn-success btn-block btn-lg">Proceed to Pay</a>
</div>
</div>
</div>
</div>
</div>
</section>
{% endblock %}

Wyświetl plik

@ -0,0 +1,94 @@
{% extends 'base.html' %}
{% load static %}
{% block content %}
<form action="" method="POST">
{% csrf_token %}
<section class="order-form m-4">
<div class="container pt-4">
<div class="row">
<div class="col-12 px-4">
<h1>Order Data</h1>
<span>We won't share your data</span>
<hr class="mt-1" />
</div>
</div>
<div class="col-12">
<div class="row mx-4">
<div class="col-sm-6">
<label class="order-form-label">{{form.name.label}}</label>
</div>
<div class="col-sm-6">
<label class="order-form-label">{{form.surname.label}}</label>
</div>
<div class="col-sm-6">
<div class="form-outline">
{{form.name}}
</div>
</div>
<div class="col-sm-6 mt-2 mt-sm-0">
<div class="form-outline">
{{form.surname}}
</div>
</div>
</div>
<div class="row mx-4">
<div class="col-sm-6">
<label class="order-form-label">{{form.email.label}}</label>
</div>
<div class="col-sm-6">
<label class="order-form-label">{{form.phone.label}}</label>
</div>
<div class="col-sm-6">
<div class="form-outline">
{{form.email}}
</div>
</div>
<div class="col-sm-6 mt-2 mt-sm-0">
<div class="form-outline">
{{form.phone}}
</div>
</div>
</div>
</div>
<div class="row mt-3 mx-4">
<div class="col-12">
<label class="order-form-label">Dane Kontaktowe</label>
</div>
<hr class="mt-1" />
<div class="col-sm-6 mt-2 pe-sm-2">
<div class="form-outline">
<label class="form-label" for="{{form.street.id}}">{{form.street.label}}</label>
{{form.street}}
</div>
</div>
<div class="col-sm-6 mt-2 pe-sm-2">
<div class="form-outline">
<label class="form-label" for="{{form.street.id}}">{{form.country.label}}</label>
{{form.country}}
</div>
</div>
<div class="col-sm-6 mt-2 pe-sm-2">
<div class="form-outline">
<label class="form-label" for="form7">{{form.city.label}}</label>
{{form.city}}
</div>
</div>
<div class="col-sm-6 mt-2 pe-sm-2">
<div class="form-outline">
<label class="form-label" for="form7">{{form.zip_code.label}}</label>
{{form.zip_code}}
</div>
</div>
<div class="col-12 text-end mt-3">
<div class="form-outline">
<input type="submit" value="Confirm" class="btn btn-success btn-lg">
</div>
</div>
</div>
</div>
</section>
</form>
{% endblock %}

Wyświetl plik

@ -0,0 +1,77 @@
{% extends 'base.html' %}
{% load static %}
{% block content %}
<section class="h-100">
<div class="container">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3 class="fw-normal mb-0 text-black">Customer Data</h3>
</div>
<div class="card mb-2 py-5">
<div class="card-body">
<div class="row">
<div class="col-sm-3">
<p class="mb-0">Full Name</p>
</div>
<div class="col-sm-9">
<p class="text-muted mb-0">{{customer_data.full_name}}</p>
</div>
</div>
<hr>
<div class="row">
<div class="col-sm-3">
<p class="mb-0">Email</p>
</div>
<div class="col-sm-9">
<p class="text-muted mb-0">{{customer_data.email}}</p>
</div>
</div>
<hr>
<div class="row">
<div class="col-sm-3">
<p class="mb-0">Phone</p>
</div>
<div class="col-sm-9">
<p class="text-muted mb-0">{{customer_data.phone}}</p>
</div>
</div>
<hr>
<div class="row">
<div class="col-sm-3">
<p class="mb-0">Address</p>
</div>
<div class="col-sm-9">
<p class="text-muted mb-0">{{customer_data.full_address}}</p>
</div>
</div>
</div>
</div>
</div>
<div class="container mt-5">
<div class="row d-flex justify-content-center align-items-center">
<div class="d-flex justify-content-between align-items-center mb-4">
<h3 class="fw-normal mb-0 text-black">Order Items</h3>
</div>
</div>
{% for item in cart.get_items %}
{% include 'store/partials/summary_cart_item.html' %}
{% endfor %}
<div class="card ">
<div class="card-body">
<div class="row">
<div class="col-sm-6">
<h5 class="fw-normal mb-0 text-black">To Pay: {{cart.total_price}}</h5>
</div>
<div class="col-sm-6 text-end">
<a href="" class="btn btn-success btn-block btn-lg">Confirm</a>
</div>
</div>
</div>
</div>
</div>
</section>
{% endblock %}

Wyświetl plik

@ -0,0 +1,42 @@
{% load static %}
<div class="card rounded-3 mb-4">
<div class="card-body p-4">
<div class="row d-flex justify-content-between align-items-center">
<div class="col-md-2 col-lg-2 col-xl-2">
<img
src="{{item.product.main_image.url}}"
class="img-fluid rounded-3">
</div>
<div class="col-md-3 col-lg-3 col-xl-3">
<p class="lead fw-normal mb-2">{{item.product.title}}</p>
</div>
<div class="col-md-3 col-lg-3 col-xl-2 d-flex">
<button class="btn btn-link px-2"
onclick="this.parentNode.querySelector('input[type=number]').stepDown()">
<i class="fas fa-minus"></i>
</button>
<input id="form1" min="0" name="quantity" value="{{item.quantity}}" type="number"
class="form-control form-control-sm" />
<button class="btn btn-link px-2"
onclick="this.parentNode.querySelector('input[type=number]').stepUp()">
<i class="fas fa-plus"></i>
</button>
</div>
<div class="col-md-3 col-lg-2 col-xl-2 offset-lg-1">
<h5 class="mb-0">{{item.product.price}} ZŁ</h5>
</div>
<div class="col-md-1 col-lg-1 col-xl-1 text-end">
<a href="#!" class="text-danger remove-from-cart-button"
data-product-id="{{item.product.id}}"
data-csrf-token="{{csrf_token}}"
data-remove-from-cart-url={% url "cart-action-remove-product" %}>
<img src="{% static 'images/icons/trash.svg'%}" />
</a>
</div>
</div>
</div>
</div>

Wyświetl plik

@ -1,18 +1,25 @@
{% load static %}
{% load static %}
<div class="card h-100" style="width: 15rem;">
<div class="card h-100" style="width: 15rem;">
<div class="card-header">{{item.title}}</div>
<div class="card-body">
<img src="{{item.main_image.url}}" class="rounded mx-auto d-block" style="width: 10rem; height: 10 rem;">
<p class="card-text">{{item.description}}</p>
<div class="text-end">
<a href="#" class="btn btn-outline-primary">Details</a>
<a href="#" class="btn btn-outline-success">
<img src = "{% static 'images/icons/cart.svg' %}" alt="Koszyk"/>
</a>
<div class="row d-flex mt-3">
<div class="col">
<input type="number" id="quantity{{item.id}}" name="quantity" min="1" value="1" class="form-control form-control-sm">
</div>
<div class="col text-end">
<button class="btn btn-outline-success add-to-cart-button"
data-product-id="{{item.id}}"
data-csrf-token="{{csrf_token}}"
data-add-to-cart-url={% url "cart-action-add-product" %}
data-bs-toggle="modal" data-bs-target="#addToCartModal"
>
<img src="{% static 'images/icons/cart.svg' %}" alt="Koszyk"/>
</button>
</div>
</div>
</div>
</div>
</div>

Wyświetl plik

@ -0,0 +1,25 @@
{% load static %}
<div class="card rounded-3 mb-4">
<div class="card-body p-4">
<div class="row d-flex justify-content-between align-items-center">
<div class="col-md-2 col-lg-2 col-xl-2">
<img
src="{{item.product.main_image.url}}"
class="img-fluid rounded-3">
</div>
<div class="col-md-3 col-lg-3 col-xl-3">
<p class="lead fw-normal mb-2">{{item.product.title}}</p>
</div>
<div class="col-md-3 col-lg-3 col-xl-2 d-flex">
{{item.quantity}}
</div>
<div class="col-md-3 col-lg-2 col-xl-2 offset-lg-1">
<h5 class="mb-0">{{item.product.price}} ZŁ</h5>
</div>
</div>
</div>
</div>

Wyświetl plik

@ -1,8 +1,28 @@
{% extends 'base.html'%}
{% load static %}
{% block content %}
<div class="modal" tabindex="-1" role="dialog" id="addToCartModal" aria-labelledby="addToCartLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Added to cart</h5>
<button type="button" class="close" data-bs-dismiss="modal" aria-label="Close" >
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>Item has been added to cart.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Continue shopping</button>
<a href="{% url 'cart' %}" class="btn btn-primary">Go to cart</a>
</div>
</div>
</div>
</div>
<div class="container mb-3">
<div class="row row-cols-1 row-cols-md-3 g-4">
{% for item in items %}
@ -11,7 +31,6 @@
</div>
{% endfor %}
</div>
</div>
<nav aria-label="Page navigation example">
@ -27,4 +46,5 @@
</li>
</ul>
</nav>
{% endblock %}

Wyświetl plik

@ -1,3 +1,36 @@
from django.test import TestCase
from django.urls import reverse
from store.models import ProductAuthor, ProductCategory, ProductTemplate, Product
# Create your tests here.
# TODO - this is fine for now, but we'll want to use factoryboy for this:
# https://factoryboy.readthedocs.io/en/stable/
# TODO - test have to rewritten - I'll do it tommorow
class CartTestCase(TestCase):
def setUp(self):
self.productid = 1
self.author = ProductAuthor.objects.create(name='Test Author')
self.category = ProductCategory.objects.create(name='Test Category')
self.template = ProductTemplate.objects.create(category=self.category,
author=self.author,
title='Test title',
code='Test code',
description='Test description'
)
self.product = Product.objects.create(template=self.template,
price=10.99)
self.cart_url = reverse('view_cart')
def test_add_to_cart(self):
response = self.client.post(reverse('add_to_cart',
args=[self.productid]))
self.assertEqual(response.status_code, 302)
def test_remove_from_cart(self):
response = self.client.post(reverse('remove_from_cart',
args=[self.productid]))
self.assertEqual(response.status_code, 302)
def test_view_cart(self):
response = self.client.get(self.cart_url)
self.assertEqual(response.status_code, 200)

Wyświetl plik

@ -0,0 +1,15 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from store import views as store_views
router = DefaultRouter()
router.register("cart-action", store_views.CartActionView, "cart-action")
urlpatterns = [
path('cart/', store_views.CartView.as_view(), name='cart'),
path("order/", store_views.OrderView.as_view(), name="order"),
path("order/confirm/", store_views.OrderConfirmView.as_view(), name="order-confirm"),
] + router.urls

Wyświetl plik

@ -0,0 +1,144 @@
from typing import Any, Dict
from django.views.generic import (
TemplateView,
View
)
from django.shortcuts import render
from django.urls import reverse
from django.http import HttpResponseRedirect
from rest_framework.viewsets import ViewSet
from rest_framework.decorators import action
from rest_framework.response import Response
from store.cart import SessionCart
from store.serializers import (
CartProductSerializer,
CartProductAddSerializer
)
from store.forms import CustomerDataForm
from store.models import (
CustomerData,
Order,
OrderProduct
)
class CartView(TemplateView):
"""
This view should simply render cart with initial data, it'll do that each refresh, for
making actions on cart (using jquery) we will use CartActionView, which will
be prepared to return JsonResponse.
"""
template_name = 'store/cart.html'
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
context = super().get_context_data(**kwargs)
context["cart"] = SessionCart(self.request)
return context
class CartActionView(ViewSet):
@action(detail=False, methods=["get"], url_path="list-products")
def list_products(self, request):
# get cart items
cart = SessionCart(self.request)
items = cart.get_items()
serializer = CartProductSerializer(instance=items, many=True)
return Response(serializer.data)
@action(detail=False, methods=["post"])
def add_product(self, request):
cart = SessionCart(self.request)
serializer = CartProductAddSerializer(data=request.POST)
if not serializer.is_valid():
return Response(serializer.errors, status=400)
serializer.save(cart)
items = cart.get_items()
serializer = CartProductSerializer(instance=items, many=True)
return Response(serializer.data, status=201)
@action(detail=False, methods=["post"])
def remove_product(self, request):
cart = SessionCart(self.request)
product_id = request.POST.get("product_id")
cart.remove_item(product_id)
items = cart.get_items()
serializer = CartProductSerializer(instance=items, many=True)
return Response(serializer.data, status=201)
@action(detail=True, methods=["put"])
def update_product(self, request, product_id):
cart = SessionCart(self.request)
product_id = request.POST.get("product_id")
cart.update_item_quantity(product_id, request.PUT["quantity"])
items = cart.get_items()
serializer = CartProductSerializer(instance=items, many=True)
return Response(serializer.data, status=201)
class OrderView(View):
template_name = "store/order.html"
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
context = {}
context["form"] = CustomerDataForm()
return context
def get(self, request, *args, **kwargs):
cart = SessionCart(self.request)
if cart.is_empty():
# TODO - messages
return HttpResponseRedirect(reverse("cart"))
return render(request, self.template_name, self.get_context_data())
def post(self, request, *args, **kwargs):
# TODO - messages
cart = SessionCart(self.request)
if cart.is_empty():
return HttpResponseRedirect(reverse("cart"))
form = CustomerDataForm(request.POST)
if not form.is_valid():
print(form.errors)
context = self.get_context_data()
context["form"] = form
return render(request, self.template_name, context)
customer_data = form.save()
request.session["customer_data_id"] = customer_data.id
# TODO - add this page
return HttpResponseRedirect(reverse("order-confirm"))
class OrderConfirmView(View):
template_name = "store/order_confirm.html"
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
customer_data = CustomerData.objects.get(id=self.request.session["customer_data_id"])
return {
"cart": SessionCart(self.request),
"customer_data": customer_data
}
def get(self, request, *args, **kwargs):
cart = SessionCart(self.request)
if cart.is_empty():
# TODO - messages
return HttpResponseRedirect(reverse("cart"))
return render(request, self.template_name, self.get_context_data())
def post(self, request):
customer_data = CustomerData.objects.get(id=self.request.session["customer_data_id"])
cart = SessionCart(self.request)
order = Order.objects.create(
customer=customer_data,
)
OrderProduct.objects.create_from_cart(order, cart)
cart.clear()
self.request.session.pop("customer_data_id")
# TODO - to be tested
# TODO - messages
return HttpResponseRedirect(reverse("cart"))