├── tests ├── __init__.py ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── factories.py ├── models.py ├── settings.py ├── conftest.py ├── test_storages.py └── test_cart.py ├── dj_shop_cart ├── py.typed ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0002_rename_items_cart_data.py │ └── 0001_initial.py ├── apps.py ├── context_processors.py ├── utils.py ├── models.py ├── protocols.py ├── modifiers.py ├── conf.py ├── storages.py └── cart.py ├── example ├── __init__.py ├── core │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ ├── __pycache__ │ │ │ ├── __init__.cpython-310.pyc │ │ │ └── 0001_initial.cpython-310.pyc │ │ ├── 0002_rename_productmodel_product_and_more.py │ │ └── 0001_initial.py │ ├── apps.py │ ├── admin.py │ ├── models.py │ └── views.py ├── example │ ├── __init__.py │ ├── asgi.py │ ├── wsgi.py │ ├── urls.py │ └── settings.py ├── db.sqlite3 ├── pyproject.toml ├── manage.py ├── templates │ └── index.html └── uv.lock ├── setup.cfg ├── docs ├── codeofconduct.md ├── contributing.md ├── license.md ├── conf.py ├── index.md └── usage.md ├── .github ├── workflows │ ├── docs.yml │ └── ci.yml └── dependabot.yml ├── justfile ├── .pre-commit-config.yaml ├── LICENSE ├── pyproject.toml ├── CONTRIBUTING.md ├── README.md ├── CODE_OF_CONDUCT.md └── .gitignore /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dj_shop_cart/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dj_shop_cart/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dj_shop_cart/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/core/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | -------------------------------------------------------------------------------- /docs/codeofconduct.md: -------------------------------------------------------------------------------- 1 | ```{include} ../CODE_OF_CONDUCT.md 2 | ``` 3 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | ```{include} ../CONTRIBUTING.md 2 | ``` 3 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | # License 2 | 3 | ```{include} ../LICENSE 4 | ``` 5 | -------------------------------------------------------------------------------- /example/db.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tobi-De/dj-shop-cart/HEAD/example/db.sqlite3 -------------------------------------------------------------------------------- /example/core/migrations/__pycache__/__init__.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tobi-De/dj-shop-cart/HEAD/example/core/migrations/__pycache__/__init__.cpython-310.pyc -------------------------------------------------------------------------------- /example/core/migrations/__pycache__/0001_initial.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tobi-De/dj-shop-cart/HEAD/example/core/migrations/__pycache__/0001_initial.cpython-310.pyc -------------------------------------------------------------------------------- /example/core/apps.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class CoreConfig(AppConfig): 7 | default_auto_field = "django.db.models.BigAutoField" 8 | name = "core" 9 | -------------------------------------------------------------------------------- /example/core/admin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.contrib import admin 4 | 5 | from .models import Product, ProductVariant 6 | 7 | admin.site.register(Product) 8 | admin.site.register(ProductVariant) 9 | -------------------------------------------------------------------------------- /dj_shop_cart/apps.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class DjShopCartConfig(AppConfig): 7 | default_auto_field = "django.db.models.BigAutoField" 8 | name = "dj_shop_cart" 9 | -------------------------------------------------------------------------------- /dj_shop_cart/context_processors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.http import HttpRequest 4 | 5 | from .cart import get_cart 6 | 7 | 8 | def cart(request: HttpRequest) -> dict: 9 | return {"cart": get_cart(request)} 10 | -------------------------------------------------------------------------------- /example/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "example" 3 | version = "0.1.1" 4 | description = "" 5 | authors = [{ name = "Tobi DEGNON", email = "degnonfrancis@gmail.com" }] 6 | requires-python = ">=3.10,<4" 7 | dependencies = ["Django>=5.2"] 8 | 9 | [dependency-groups] 10 | dev = [] 11 | 12 | [build-system] 13 | requires = ["hatchling"] 14 | build-backend = "hatchling.build" 15 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: docs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-python@v2 12 | with: 13 | python-version: 3.7 14 | - run: pip install -r docs/requirements.txt 15 | - run: mkdocs gh-deploy --force 16 | -------------------------------------------------------------------------------- /tests/factories.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from factory import Faker 4 | from factory.django import DjangoModelFactory 5 | 6 | from tests.models import Product 7 | 8 | 9 | class ProductFactory(DjangoModelFactory): 10 | name = Faker("name") 11 | description = "" 12 | price = Faker("pydecimal", positive=True, left_digits=3, right_digits=2) 13 | 14 | class Meta: 15 | model = Product 16 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.db import models 4 | 5 | 6 | class Product(models.Model): 7 | name = models.CharField(max_length=255) 8 | description = models.TextField(blank=True) 9 | price = models.DecimalField(max_digits=5, decimal_places=2) 10 | 11 | def __str__(self): 12 | return self.name 13 | 14 | def get_price(self, item): 15 | return self.price 16 | -------------------------------------------------------------------------------- /dj_shop_cart/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | from typing import Any 5 | 6 | from django.core.exceptions import ImproperlyConfigured 7 | from django.utils.module_loading import import_string 8 | 9 | 10 | def import_class(path: str) -> Any: 11 | value = import_string(path) 12 | if not inspect.isclass(value): 13 | raise ImproperlyConfigured(f"Specified `{value}` is not a class.") 14 | return value 15 | -------------------------------------------------------------------------------- /dj_shop_cart/migrations/0002_rename_items_cart_data.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.6 on 2023-02-27 16:50 2 | 3 | from __future__ import annotations 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("dj_shop_cart", "0001_initial"), 11 | ] 12 | 13 | operations = [ 14 | migrations.RenameField( 15 | model_name="cart", 16 | old_name="items", 17 | new_name="data", 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /example/example/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for example project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/ 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | import os 13 | 14 | from django.core.asgi import get_asgi_application 15 | 16 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 17 | 18 | application = get_asgi_application() 19 | -------------------------------------------------------------------------------- /example/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | import os 13 | 14 | from django.core.wsgi import get_wsgi_application 15 | 16 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 17 | 18 | application = get_wsgi_application() 19 | -------------------------------------------------------------------------------- /dj_shop_cart/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.conf import settings 4 | from django.db import models 5 | from django.utils import timezone 6 | 7 | 8 | class Cart(models.Model): 9 | data = models.JSONField() 10 | customer = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) 11 | created = models.DateTimeField(default=timezone.now) 12 | modified = models.DateTimeField(auto_now_add=True) 13 | 14 | def __str__(self): 15 | return f"Cart - {self.customer}" 16 | -------------------------------------------------------------------------------- /dj_shop_cart/protocols.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from decimal import Decimal 4 | 5 | from django.http import HttpRequest 6 | 7 | try: 8 | from typing import Protocol, Union 9 | except ImportError: 10 | from typing_extensions import Protocol 11 | 12 | 13 | Numeric = Union[float, int, Decimal] 14 | 15 | 16 | class Storage(Protocol): 17 | request: HttpRequest 18 | 19 | def load(self) -> dict: ... 20 | 21 | def save(self, data: dict) -> None: ... 22 | 23 | def clear(self) -> None: ... 24 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Example 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | uv-example: 11 | name: python 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v5 16 | 17 | - name: "Set up Python" 18 | uses: actions/setup-python@v6 19 | with: 20 | python-version-file: "pyproject.toml" 21 | 22 | - name: Install uv 23 | uses: astral-sh/setup-uv@v7 24 | 25 | - name: Install project and test dependencies (via uv) 26 | run: uv run pytest -q 27 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | # List all available commands 2 | _default: 3 | @just --list 4 | 5 | # Install dependencies 6 | @bootstrap: 7 | hatch env create 8 | hatch env create docs 9 | 10 | # Run tests using pytest 11 | @test *ARGS: 12 | hatch run pytest {{ ARGS }} 13 | 14 | # Build documentation using Sphinx 15 | @docs-build LOCATION="docs/_build/html": 16 | sphinx-build docs {{ LOCATION }} 17 | 18 | # Install documentation dependencies 19 | @docs-install: 20 | hatch run docs:python --version 21 | 22 | # Serve documentation locally 23 | @docs-serve: 24 | hatch run docs:sphinx-autobuild docs docs/_build/html --port 8001 25 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from core.views import (add_product, decrement_product, empty_cart, index, 4 | remove_product) 5 | from django.contrib import admin 6 | from django.urls import path 7 | 8 | urlpatterns = [ 9 | path("", index, name="index"), 10 | path("add-product/", add_product, name="add_product"), 11 | path("decrement-product/", decrement_product, name="decrement_product"), 12 | path("remove-product/", remove_product, name="remove_product"), 13 | path("empty-cart/", empty_cart, name="empty_cart"), 14 | path("admin/", admin.site.urls), 15 | ] 16 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | from __future__ import annotations 4 | 5 | import os 6 | import sys 7 | 8 | 9 | def main(): 10 | """Run administrative tasks.""" 11 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 12 | try: 13 | from django.core.management import execute_from_command_line 14 | except ImportError as exc: 15 | raise ImportError( 16 | "Couldn't import Django. Are you sure it's installed and " 17 | "available on your PYTHONPATH environment variable? Did you " 18 | "forget to activate a virtual environment?" 19 | ) from exc 20 | execute_from_command_line(sys.argv) 21 | 22 | 23 | if __name__ == "__main__": 24 | main() 25 | -------------------------------------------------------------------------------- /example/core/migrations/0002_rename_productmodel_product_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-02-27 20:05 2 | 3 | from __future__ import annotations 4 | 5 | import django.db.models.deletion 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("core", "0001_initial"), 12 | ] 13 | 14 | operations = [ 15 | migrations.RenameModel( 16 | old_name="ProductModel", 17 | new_name="Product", 18 | ), 19 | migrations.AlterField( 20 | model_name="productvariant", 21 | name="product", 22 | field=models.ForeignKey( 23 | on_delete=django.db.models.deletion.CASCADE, 24 | related_name="variations", 25 | to="core.product", 26 | ), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | SECRET_KEY = "NOTASECRET" 6 | 7 | ALLOWED_HOSTS: list[str] = [] 8 | 9 | DATABASES: dict[str, dict[str, Any]] = { 10 | "default": { 11 | "ENGINE": "django.db.backends.sqlite3", 12 | } 13 | } 14 | 15 | CACHES = { 16 | "default": { 17 | "BACKEND": "django.core.cache.backends.locmem.LocMemCache", 18 | "LOCATION": "unique-snowflake", 19 | } 20 | } 21 | 22 | INSTALLED_APPS = [ 23 | "django.contrib.auth", 24 | "django.contrib.contenttypes", 25 | "django.contrib.sessions", 26 | "tests", 27 | "dj_shop_cart", 28 | ] 29 | 30 | MIDDLEWARE: list[str] = [] 31 | 32 | TEMPLATES = [ 33 | { 34 | "BACKEND": "django.template.backends.django.DjangoTemplates", 35 | "DIRS": [], 36 | "OPTIONS": {"context_processors": []}, 37 | } 38 | ] 39 | 40 | USE_TZ = True 41 | -------------------------------------------------------------------------------- /tests/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.3 on 2022-03-03 11:45 2 | 3 | from __future__ import annotations 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Product", 17 | fields=[ 18 | ( 19 | "id", 20 | models.BigAutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("name", models.CharField(max_length=255)), 28 | ("description", models.TextField(blank=True)), 29 | ("price", models.DecimalField(decimal_places=2, max_digits=5)), 30 | ], 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /example/core/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.db import models 4 | from django.utils import timezone 5 | 6 | 7 | class Product(models.Model): 8 | name = models.CharField(max_length=255) 9 | description = models.TextField(blank=True) 10 | price = models.DecimalField(max_digits=5, decimal_places=2) 11 | created = models.DateTimeField(default=timezone.now) 12 | modified = models.DateTimeField(auto_now_add=True) 13 | 14 | def __str__(self): 15 | return self.name 16 | 17 | def get_price(self, *arg, **kwargs): 18 | return self.price 19 | 20 | 21 | class ProductVariant(models.Model): 22 | product = models.ForeignKey( 23 | Product, on_delete=models.CASCADE, related_name="variations" 24 | ) 25 | size = models.IntegerField() 26 | color = models.CharField(max_length=255) 27 | created = models.DateTimeField(default=timezone.now) 28 | modified = models.DateTimeField(auto_now_add=True) 29 | 30 | def __str__(self): 31 | return f"{self.product.name} - {self.size} - {self.color}" 32 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.12 3 | 4 | repos: 5 | - repo: https://github.com/pre-commit/pre-commit-hooks 6 | rev: v6.0.0 7 | hooks: 8 | - id: check-added-large-files 9 | - id: check-case-conflict 10 | - id: check-json 11 | - id: check-merge-conflict 12 | - id: check-symlinks 13 | - id: check-toml 14 | - id: end-of-file-fixer 15 | - id: trailing-whitespace 16 | - repo: https://github.com/asottile/pyupgrade 17 | rev: v3.21.2 18 | hooks: 19 | - id: pyupgrade 20 | args: [--py310-plus] 21 | - repo: https://github.com/psf/black 22 | rev: 25.12.0 23 | hooks: 24 | - id: black 25 | - repo: https://github.com/pycqa/isort 26 | rev: 7.0.0 27 | hooks: 28 | - id: isort 29 | - repo: https://github.com/PyCQA/flake8 30 | rev: 7.3.0 31 | hooks: 32 | - id: flake8 33 | additional_dependencies: 34 | - flake8-bugbear 35 | - flake8-comprehensions 36 | - flake8-tidy-imports 37 | #- repo: https://github.com/pre-commit/mirrors-mypy 38 | # rev: v0.931 39 | # hooks: 40 | # - id: mypy 41 | # additional_dependencies: 42 | # - types-python-dateutil 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022, DEGNON Tobi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /dj_shop_cart/modifiers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from .conf import conf 6 | 7 | if TYPE_CHECKING: 8 | from .cart import Cart, CartItem 9 | 10 | 11 | class CartModifier: 12 | def before_add(self, cart: Cart, item: CartItem, quantity: int) -> None: ... 13 | 14 | def after_add(self, cart: Cart, item: CartItem) -> None: ... 15 | 16 | def before_remove( 17 | self, cart: Cart, item: CartItem, quantity: int | None = None 18 | ) -> None: ... 19 | 20 | def after_remove(self, cart: Cart, item: CartItem) -> None: ... 21 | 22 | 23 | class CartModifiersPool: 24 | """ 25 | Pool for storing modifier instances. 26 | """ 27 | 28 | def __init__(self) -> None: 29 | self._modifiers: list[CartModifier] | None = None 30 | 31 | def get_modifiers(self) -> list[CartModifier]: 32 | """ 33 | Returns modifier instances. 34 | 35 | Returns: 36 | list: Modifier instances 37 | """ 38 | if self._modifiers is None: 39 | self._modifiers = [M() for M in conf.CART_MODIFIERS] 40 | return self._modifiers 41 | 42 | 43 | cart_modifiers_pool = CartModifiersPool() 44 | -------------------------------------------------------------------------------- /dj_shop_cart/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.3 on 2022-03-03 11:42 2 | 3 | 4 | from __future__ import annotations 5 | 6 | import django.db.models.deletion 7 | import django.utils.timezone 8 | from django.conf import settings 9 | from django.db import migrations, models 10 | 11 | 12 | class Migration(migrations.Migration): 13 | 14 | initial = True 15 | 16 | dependencies = [ 17 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 18 | ] 19 | 20 | operations = [ 21 | migrations.CreateModel( 22 | name="Cart", 23 | fields=[ 24 | ( 25 | "id", 26 | models.BigAutoField( 27 | auto_created=True, 28 | primary_key=True, 29 | serialize=False, 30 | verbose_name="ID", 31 | ), 32 | ), 33 | ("items", models.JSONField()), 34 | ("created", models.DateTimeField(default=django.utils.timezone.now)), 35 | ("modified", models.DateTimeField(auto_now_add=True)), 36 | ( 37 | "customer", 38 | models.OneToOneField( 39 | on_delete=django.db.models.deletion.CASCADE, 40 | to=settings.AUTH_USER_MODEL, 41 | ), 42 | ), 43 | ], 44 | ), 45 | ] 46 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | project = "dj-shop-cart" 10 | copyright = "2024, Tobi DEGNON" 11 | author = "Tobi DEGNON" 12 | release = "2022" 13 | 14 | # -- General configuration --------------------------------------------------- 15 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 16 | 17 | extensions = ["myst_parser", "sphinx_design"] 18 | myst_enable_extensions = ["colon_fence"] 19 | 20 | templates_path = ["_templates"] 21 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 22 | 23 | 24 | # -- Options for HTML output ------------------------------------------------- 25 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 26 | 27 | html_theme = "shibuya" 28 | html_static_path = ["_static"] 29 | 30 | html_theme_options = { 31 | "accent_color": "orange", 32 | "nav_links": [ 33 | {"title": "Usage", "url": "usage"}, 34 | {"title": "Contributing", "url": "contributing"}, 35 | {"title": "Code of Conduct", "url": "codeofconduct"}, 36 | {"title": "License", "url": "license"}, 37 | ], 38 | "page_layout": "compact", 39 | } 40 | -------------------------------------------------------------------------------- /example/core/views.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.shortcuts import get_object_or_404, redirect, render 4 | from django.views.decorators.http import require_POST 5 | 6 | from dj_shop_cart.cart import get_cart_class 7 | 8 | from .models import Product, ProductVariant 9 | 10 | Cart = get_cart_class() 11 | 12 | 13 | def get_variant(request): 14 | if request.POST["product_variant"]: 15 | product_variant = get_object_or_404( 16 | ProductVariant, id=request.POST["product_variant"] 17 | ) 18 | variant = {"size": product_variant.size, "color": product_variant.color} 19 | else: 20 | variant = None 21 | return variant 22 | 23 | 24 | def index(request): 25 | return render(request, "index.html", {"product": Product.objects.all()}) 26 | 27 | 28 | @require_POST 29 | def add_product(request): 30 | cart = Cart.new(request) 31 | product = get_object_or_404(Product.objects.all(), id=request.POST["product"]) 32 | variant = get_variant(request) 33 | quantity = int(request.POST.get("quantity", 0)) 34 | cart.add(product, variant=variant, quantity=quantity) 35 | return redirect("index") 36 | 37 | 38 | @require_POST 39 | def decrement_product(request): 40 | cart = Cart.new(request) 41 | product = get_object_or_404(Product.objects.all(), id=request.POST["product"]) 42 | quantity = int(request.POST.get("quantity", 0)) 43 | cart.add(product, variant=get_variant(request), quantity=quantity) 44 | return redirect("index") 45 | 46 | 47 | @require_POST 48 | def remove_product(request, item_id: str): 49 | cart = Cart.new(request) 50 | cart.remove(item_id) 51 | return redirect("index") 52 | 53 | 54 | @require_POST 55 | def empty_cart(request): 56 | cart = Cart.new(request) 57 | cart.empty() 58 | return redirect("index") 59 | -------------------------------------------------------------------------------- /example/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Shop 9 | 10 | 11 |
12 |

Shop - ADMIN

13 |
14 |
15 |
16 | ... 17 |
18 |
Card title
19 |

This is a longer card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.

20 |
21 |
22 |
23 |
24 | 25 |

Cart

26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 |
FirstLastHandle
1MarkOtto@mdo
2JacobThornton@fat
50 |
51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /dj_shop_cart/conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from django.conf import settings 6 | from django.core.exceptions import ImproperlyConfigured 7 | 8 | from .utils import import_class 9 | 10 | if TYPE_CHECKING: 11 | from .modifiers import CartModifier 12 | 13 | 14 | class Settings: 15 | """ 16 | Shadow Django's settings with a little logic 17 | """ 18 | 19 | @property 20 | def CART_SESSION_KEY(self) -> str: 21 | return getattr(settings, "CART_SESSION_KEY", "CART-ID") 22 | 23 | @property 24 | def CART_CACHE_TIMEOUT(self) -> int: 25 | # default to 5 days 26 | return getattr(settings, "CART_CACHE_TIMEOUT", 60 * 60 * 24 * 5) 27 | 28 | @property 29 | def CART_MODIFIERS(self) -> list[type[CartModifier]]: 30 | from .modifiers import CartModifier 31 | 32 | cart_modifiers = getattr(settings, "CART_MODIFIERS", []) 33 | modifiers_classes = [] 34 | 35 | for value in cart_modifiers: 36 | modifier: type[CartModifier] = import_class(value) 37 | 38 | if not issubclass(modifier, CartModifier): 39 | raise ImproperlyConfigured( 40 | f"Modifier `{modifier}` must subclass `{CartModifier}`." 41 | ) 42 | 43 | modifiers_classes.append(modifier) 44 | return modifiers_classes 45 | 46 | @property 47 | def CART_PRODUCT_GET_PRICE_METHOD(self) -> str: 48 | return getattr(settings, "CART_PRODUCT_GET_PRICE_METHOD", "get_price") 49 | 50 | @property 51 | def CART_STORAGE_BACKEND(self) -> object: 52 | backend = getattr(settings, "CART_STORAGE_BACKEND", None) 53 | if backend == "dj_shop_cart.storages.DBStorage": 54 | assert ( 55 | "dj_shop_cart" in settings.INSTALLED_APPS 56 | ), "You need to add dj_shop_cart to INSTALLED_APPS to use the DBStorage" 57 | return import_class(backend or "dj_shop_cart.storages.SessionStorage") 58 | 59 | @property 60 | def app_is_installed(self) -> bool: 61 | return "dj_shop_cart" in settings.INSTALLED_APPS 62 | 63 | 64 | conf = Settings() 65 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # dj-shop-cart 2 | 3 | A simple and flexible cart manager for your django projects. 4 | 5 | [![pypi](https://badge.fury.io/py/dj-shop-cart.svg)](https://pypi.org/project/dj-shop-cart/) 6 | [![python](https://img.shields.io/pypi/pyversions/dj-shop-cart)](https://github.com/Tobi-De/dj-shop-cart) 7 | [![django](https://img.shields.io/pypi/djversions/dj-shop-cart)](https://github.com/Tobi-De/dj-shop-cart) 8 | [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?)](https://github.com/Tobi-De/dj-shop-cart/blob/master/LICENSE) 9 | [![black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 10 | 11 | ## {octicon}`zap;1em;sd-text-danger` Features 12 | 13 | - Add, remove, increase quantities, and clear items from the shopping cart. 14 | - Manage multiple carts for the same user by using prefixes. 15 | - Attach metadata to both cart items and the cart itself. 16 | - Support adding different variants of the same product to a cart. 17 | - Write custom modifiers to hook into the item addition/removal flow. 18 | - Save authenticated users' carts to the database. 19 | - Implement a custom **get_price** method to ensure that the cart always has up-to-date product prices. 20 | - Maintain a reference to the associated product for each item in the cart. 21 | - Provide a context processor for easy access to the user's cart in all Django templates. 22 | - Offer swappable backend storage, with session and database options provided by default. 23 | 24 | 25 | ## {octicon}`package;1em;sd-text-danger` Installation 26 | 27 | Install **dj-shop-cart** with your favorite package manager: 28 | 29 | ```bash 30 | pip install dj-shop-cart 31 | ``` 32 | 33 | ## {octicon}`rocket;1em;sd-text-danger` Quickstart 34 | 35 | ```python3 36 | from dj_shop_cart.cart import get_cart 37 | from django.http import HttpRequest 38 | from django.views.decorators.http import require_POST 39 | 40 | from .models import Product 41 | 42 | @require_POST 43 | def add_product(request: HttpRequest): 44 | product = get_object_or_404(Product, pk=request.POST['product_id']) 45 | cart = get_cart(request) 46 | cart.add(product, quantity=int(request.POST['quantity'])) 47 | ... 48 | ``` 49 | -------------------------------------------------------------------------------- /example/core/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.3 on 2022-03-03 11:45 2 | 3 | 4 | from __future__ import annotations 5 | 6 | import django.db.models.deletion 7 | import django.utils.timezone 8 | from django.db import migrations, models 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | initial = True 14 | 15 | dependencies = [] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="ProductModel", 20 | fields=[ 21 | ( 22 | "id", 23 | models.BigAutoField( 24 | auto_created=True, 25 | primary_key=True, 26 | serialize=False, 27 | verbose_name="ID", 28 | ), 29 | ), 30 | ("name", models.CharField(max_length=255)), 31 | ("description", models.TextField(blank=True)), 32 | ("price", models.DecimalField(decimal_places=2, max_digits=5)), 33 | ("created", models.DateTimeField(default=django.utils.timezone.now)), 34 | ("modified", models.DateTimeField(auto_now_add=True)), 35 | ], 36 | ), 37 | migrations.CreateModel( 38 | name="ProductVariant", 39 | fields=[ 40 | ( 41 | "id", 42 | models.BigAutoField( 43 | auto_created=True, 44 | primary_key=True, 45 | serialize=False, 46 | verbose_name="ID", 47 | ), 48 | ), 49 | ("size", models.IntegerField()), 50 | ("color", models.CharField(max_length=255)), 51 | ("created", models.DateTimeField(default=django.utils.timezone.now)), 52 | ("modified", models.DateTimeField(auto_now_add=True)), 53 | ( 54 | "product", 55 | models.OneToOneField( 56 | on_delete=django.db.models.deletion.CASCADE, to="core.product" 57 | ), 58 | ), 59 | ], 60 | ), 61 | ] 62 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["uv_build>=0.9.17,<0.10.0"] 3 | build-backend = "uv_build" 4 | 5 | [project] 6 | name = "dj-shop-cart" 7 | version = "8.0.0" 8 | description = "Simple django cart manager for your django projects." 9 | readme = "README.md" 10 | requires-python = ">=3.10" 11 | license = "MIT" 12 | keywords = ["django", "python", "cart", "shop", "ecommerce"] 13 | authors = [{ name = "Tobi-De", email = "tobidegnon@proton.me" }] 14 | classifiers = [ 15 | "Development Status :: 5 - Production/Stable", 16 | "Programming Language :: Python", 17 | "Programming Language :: Python :: 3.10", 18 | "Programming Language :: Python :: 3.11", 19 | "Programming Language :: Python :: 3.12", 20 | "Programming Language :: Python :: 3.13", 21 | "Programming Language :: Python :: Implementation :: CPython", 22 | "Programming Language :: Python :: Implementation :: PyPy", 23 | "Framework :: Django :: 3.0", 24 | "Framework :: Django :: 3.1", 25 | "Framework :: Django :: 3.2", 26 | "Framework :: Django :: 4.0", 27 | "Framework :: Django :: 4.1", 28 | "Framework :: Django :: 4.2", 29 | "Framework :: Django :: 5.0", 30 | "Framework :: Django :: 5.2", 31 | "Intended Audience :: Developers", 32 | "Natural Language :: English", 33 | ] 34 | dependencies = ["Django>=5.2", "attrs>=23.2.0"] 35 | 36 | [project.urls] 37 | Documentation = "https://github.com/Tobi-De/dj_shop_cart#readme" 38 | Issues = "https://github.com/Tobi-De/dj_shop_cart/issues" 39 | Source = "https://github.com/Tobi-De/dj_shop_cart" 40 | 41 | [dependency-groups] 42 | dev = [ 43 | "pytest", 44 | "pytest-django", 45 | "mypy", 46 | "pre-commit", 47 | "django-stubs", 48 | "factory-boy", 49 | "ipython", 50 | "redis", 51 | "hiredis", 52 | "pytest-pretty", 53 | ] 54 | docs = ["shibuya", "myst-parser", "sphinx-autobuild", "sphinx-design"] 55 | 56 | [tool.coverage.run] 57 | source_pkgs = ["dj_shop_cart", "tests"] 58 | branch = true 59 | parallel = true 60 | 61 | [tool.coverage.paths] 62 | dj_shop_cart = ["dj_shop_cart"] 63 | tests = ["tests"] 64 | 65 | [tool.coverage.report] 66 | exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"] 67 | 68 | [tool.mypy] 69 | mypy_path = "dj_shop_cart/" 70 | no_implicit_optional = true 71 | show_error_codes = true 72 | warn_unreachable = true 73 | warn_unused_ignores = true 74 | 75 | [[tool.mypy.overrides]] 76 | module = "tests.*" 77 | allow_untyped_defs = true 78 | 79 | [tool.pytest.ini_options] 80 | addopts = "--ds=tests.settings --reuse-db" 81 | python_files = ["tests.py", "test_*.py"] 82 | 83 | [tool.uv.build-backend] 84 | module-name = "dj_shop_cart" 85 | module-root = "" 86 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributor Guide 2 | 3 | Thank you for your interest in improving this project. 4 | This project is open-source under the [MIT license] and 5 | welcomes contributions in the form of bug reports, feature requests, and pull requests. 6 | 7 | Here is a list of important resources for contributors: 8 | 9 | - [Source Code] 10 | - [Documentation] 11 | - [Issue Tracker] 12 | - [Code of Conduct] 13 | 14 | [mit license]: https://opensource.org/licenses/MIT 15 | [source code]: https://github.com/Tobi-De/dj-shop-cart 16 | [documentation]: https://tobi-de.github.io/dj-shop-cart/ 17 | [issue tracker]: https://github.com/Tobi-De/dj-shop-cart/issues 18 | 19 | ## How to report a bug 20 | 21 | Report bugs on the [Issue Tracker]. 22 | 23 | When filing an issue, make sure to answer these questions: 24 | 25 | - Which operating system and Python version are you using? 26 | - Which version of this project are you using? 27 | - What did you do? 28 | - What did you expect to see? 29 | - What did you see instead? 30 | 31 | The best way to get your bug fixed is to provide a test case, 32 | and/or steps to reproduce the issue. 33 | 34 | ## How to request a feature 35 | 36 | Request features on the [Issue Tracker]. 37 | 38 | ## How to set up your development environment 39 | 40 | You need Python 3.8+ and the following tools: 41 | 42 | - [Poetry] 43 | 44 | Install the package with development requirements: 45 | 46 | ```console 47 | $ poetry install 48 | ``` 49 | [poetry]: https://python-poetry.org/ 50 | 51 | ## How to test the project 52 | 53 | Run the full test suite: 54 | 55 | ```console 56 | $ pytest 57 | ``` 58 | 59 | Unit tests are located in the _tests_ directory, 60 | and are written using the [pytest] testing framework. 61 | 62 | [pytest]: https://pytest.readthedocs.io/ 63 | 64 | ## How to submit changes 65 | 66 | Open a [pull request] to submit changes to this project. 67 | 68 | Your pull request needs to meet the following guidelines for acceptance: 69 | 70 | - The Nox test suite must pass without errors and warnings. 71 | - Include unit tests. This project maintains 100% code coverage. 72 | - If your changes add functionality, update the documentation accordingly. 73 | 74 | Feel free to submit early, though—we can always iterate on this. 75 | 76 | To run linting and code formatting checks before committing your change, you can install pre-commit as a Git hook by running the following command: 77 | 78 | ```console 79 | $ pre-commit -- install 80 | ``` 81 | 82 | It is recommended to open an issue before starting work on anything. 83 | This will allow a chance to talk it over with the owners and validate your approach. 84 | 85 | [pull request]: https://github.com/Tobi-De/dj-shop-cart/pulls 86 | 87 | 88 | 89 | [code of conduct]: CODE_OF_CONDUCT.md 90 | -------------------------------------------------------------------------------- /dj_shop_cart/storages.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from django.core.cache import cache 6 | from django.http import HttpRequest 7 | 8 | from .conf import conf 9 | 10 | if conf.app_is_installed: 11 | from .models import Cart 12 | 13 | 14 | @dataclass 15 | class SessionStorage: 16 | """ 17 | Save the cart data to the user session 18 | """ 19 | 20 | request: HttpRequest 21 | session_key: str = conf.CART_SESSION_KEY 22 | 23 | def load(self) -> dict: 24 | return self.request.session.get(self.session_key, {}) 25 | 26 | def save(self, data: dict) -> None: 27 | self.request.session[self.session_key] = data 28 | self.request.session.modified = True 29 | 30 | def clear(self) -> None: 31 | self.request.session.pop(self.session_key, None) 32 | self.request.session.modified = True 33 | 34 | 35 | @dataclass 36 | class DBStorage: 37 | """ 38 | Save the cart data to the database, use the session for unauthenticated users 39 | """ 40 | 41 | request: HttpRequest 42 | 43 | def load(self) -> dict: 44 | data = SessionStorage(self.request).load() 45 | if not self.request.user.is_authenticated: 46 | return data 47 | cart, _ = Cart.objects.get_or_create( 48 | customer=self.request.user, defaults={"data": data} 49 | ) 50 | return cart.data 51 | 52 | def save(self, data: dict) -> None: 53 | if not self.request.user.is_authenticated: 54 | SessionStorage(self.request).save(data) 55 | else: 56 | Cart.objects.update_or_create( 57 | customer=self.request.user, 58 | defaults={"data": data}, 59 | ) 60 | 61 | def clear(self) -> None: 62 | if not self.request.user.is_authenticated: 63 | SessionStorage(self.request).clear() 64 | else: 65 | Cart.objects.filter(customer=self.request.user).delete() 66 | 67 | 68 | @dataclass 69 | class CacheStorage: 70 | """Use django cache backend to store cart details""" 71 | 72 | request: HttpRequest 73 | timeout: int = conf.CART_CACHE_TIMEOUT 74 | _cache_key: str = conf.CART_SESSION_KEY 75 | 76 | @property 77 | def _cart_id(self) -> str: 78 | id_ = ( 79 | str(self.request.user.pk) 80 | if self.request.user.is_authenticated 81 | else str(self.request.session.session_key) 82 | ) 83 | return f"{self._cache_key}-{id_}" 84 | 85 | def load(self) -> dict: 86 | return cache.get(self._cart_id, {}) 87 | 88 | def save(self, data: dict) -> None: 89 | cache.set(self._cart_id, data, timeout=self.timeout) 90 | 91 | def clear(self) -> None: 92 | cache.delete(self._cart_id) 93 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from importlib import import_module 4 | 5 | import pytest 6 | from django.contrib.auth import get_user_model 7 | from django.contrib.auth.models import AnonymousUser 8 | from django.contrib.sessions.backends.base import SessionBase 9 | from django.test import RequestFactory 10 | 11 | from dj_shop_cart.cart import Cart, get_cart 12 | from dj_shop_cart.storages import CacheStorage, DBStorage, SessionStorage 13 | from tests.factories import ProductFactory 14 | 15 | User = get_user_model() 16 | 17 | PREFIXED_CART_KEY = "dj_shop_cart_a" 18 | 19 | 20 | @pytest.fixture() 21 | def session(settings) -> SessionBase: 22 | engine = import_module(settings.SESSION_ENGINE) 23 | return engine.SessionStore() 24 | 25 | 26 | @pytest.fixture() 27 | def cart(rf: RequestFactory, session: SessionBase, settings) -> Cart: 28 | settings.CART_STORAGE_BACKEND = "dj_shop_cart.storages.SessionStorage" 29 | request = rf.get("/") 30 | request.user = AnonymousUser() 31 | request.session = session 32 | return get_cart(request) 33 | 34 | 35 | @pytest.fixture() 36 | def prefixed_cart(rf: RequestFactory, session: SessionBase, settings) -> Cart: 37 | settings.CART_STORAGE_BACKEND = "dj_shop_cart.storages.SessionStorage" 38 | request = rf.get("/") 39 | request.user = AnonymousUser() 40 | request.session = session 41 | return get_cart(request, prefix=PREFIXED_CART_KEY) 42 | 43 | 44 | @pytest.fixture() 45 | def cart_db(rf: RequestFactory, user: User, session: SessionBase, settings): 46 | settings.CART_STORAGE_BACKEND = "dj_shop_cart.storages.DBStorage" 47 | request = rf.get("/") 48 | request.user = user 49 | request.session = session 50 | return get_cart(request) 51 | 52 | 53 | @pytest.fixture() 54 | def product(): 55 | return ProductFactory() 56 | 57 | 58 | @pytest.fixture() 59 | def user(django_user_model: type[User]): 60 | return django_user_model.objects.create(username="someone", password="password") 61 | 62 | 63 | @pytest.fixture() 64 | def session_storage(rf: RequestFactory, session: SessionBase): 65 | request = rf.get("/") 66 | request.session = session 67 | return SessionStorage(request) 68 | 69 | 70 | @pytest.fixture() 71 | def db_storage(rf: RequestFactory, session: SessionBase, user: User): 72 | request = rf.get("/") 73 | request.session = session 74 | request.user = user 75 | return DBStorage(request) 76 | 77 | 78 | @pytest.fixture() 79 | def cache_storage_auth(rf: RequestFactory, user: User): 80 | request = rf.get("/") 81 | request.user = user 82 | return CacheStorage(request) 83 | 84 | 85 | @pytest.fixture() 86 | def cache_storage(rf: RequestFactory, session: SessionBase): 87 | request = rf.get("/") 88 | request.user = AnonymousUser() 89 | request.session = session 90 | return CacheStorage(request) 91 | -------------------------------------------------------------------------------- /tests/test_storages.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | from django.contrib.auth import get_user_model 5 | from django.contrib.sessions.backends.base import SessionBase 6 | from django.core.cache import cache 7 | 8 | from dj_shop_cart.cart import DEFAULT_CART_PREFIX 9 | from dj_shop_cart.conf import conf 10 | from dj_shop_cart.models import Cart 11 | from dj_shop_cart.storages import CacheStorage, DBStorage, SessionStorage 12 | 13 | pytestmark = pytest.mark.django_db 14 | 15 | User = get_user_model() 16 | 17 | data = { 18 | DEFAULT_CART_PREFIX: {"items": [{"1": "item1"}], "metadata": {"1": "metadata1"}} 19 | } 20 | 21 | 22 | def test_session_storage_load(session_storage: SessionStorage, session: SessionBase): 23 | assert len(session_storage.load()) == 0 24 | session[conf.CART_SESSION_KEY] = data 25 | assert session_storage.load() == data 26 | 27 | 28 | def test_session_storage_save(session_storage: SessionStorage, session: SessionBase): 29 | session_storage.save(data) 30 | assert session[conf.CART_SESSION_KEY] == data 31 | 32 | 33 | def test_session_storage_clear(session_storage: SessionStorage, session: SessionBase): 34 | session[conf.CART_SESSION_KEY] = data 35 | session_storage.clear() 36 | assert session.get(conf.CART_SESSION_KEY) is None 37 | assert not session_storage.load() 38 | 39 | 40 | def test_db_storage_load_from_session( 41 | db_storage: DBStorage, session: SessionBase, user: User 42 | ): 43 | session[conf.CART_SESSION_KEY] = data 44 | assert db_storage.load() == data 45 | 46 | 47 | def test_db_storage_load(db_storage: DBStorage, user: User): 48 | assert len(db_storage.load()) == 0 49 | Cart.objects.update_or_create( 50 | customer=user, 51 | defaults={"data": data}, 52 | ) 53 | assert db_storage.load() == data 54 | 55 | 56 | def test_db_storage_save(db_storage: DBStorage, user: User): 57 | db_storage.save(data) 58 | assert db_storage.load() == data 59 | 60 | 61 | def test_db_storage_empty(db_storage: DBStorage): 62 | db_storage.save(data) 63 | assert db_storage.load() == data 64 | db_storage.clear() 65 | assert len(db_storage.load()) == 0 66 | 67 | 68 | def test_cache_storage_load(cache_storage: CacheStorage): 69 | assert len(cache_storage.load()) == 0 70 | cache.set(cache_storage._cart_id, data, timeout=None) 71 | assert cache_storage.load() == data 72 | 73 | 74 | def test_cache_storage_save(cache_storage: CacheStorage): 75 | cache_storage.save(data) 76 | assert cache.get(cache_storage._cart_id) == data 77 | 78 | 79 | def test_cache_storage_clear(cache_storage: CacheStorage): 80 | cache.set(cache_storage._cart_id, data, timeout=None) 81 | cache_storage.clear() 82 | assert cache.get(cache_storage._cart_id) is None 83 | assert not cache_storage.load() 84 | 85 | 86 | def test_cache_storage_load_auth(cache_storage_auth: CacheStorage): 87 | assert len(cache_storage_auth.load()) == 0 88 | cache.set(cache_storage_auth._cart_id, data, timeout=None) 89 | assert cache_storage_auth.load() == data 90 | 91 | 92 | def test_cache_storage_save_auth(cache_storage_auth: CacheStorage): 93 | cache_storage_auth.save(data) 94 | assert cache.get(cache_storage_auth._cart_id) == data 95 | 96 | 97 | def test_cache_storage_clear_auth(cache_storage_auth: CacheStorage): 98 | cache.set(cache_storage_auth._cart_id, data, timeout=None) 99 | cache_storage_auth.clear() 100 | assert cache.get(cache_storage_auth._cart_id) is None 101 | assert not cache_storage_auth.load() 102 | -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.0.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.0/ref/settings/ 11 | """ 12 | 13 | from __future__ import annotations 14 | 15 | from pathlib import Path 16 | 17 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 18 | BASE_DIR = Path(__file__).resolve().parent.parent 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | SECRET_KEY = "django-insecure-%dse$u8(&oa8_7rb06h$nm-pzd#t335z^mp$*7tpqia%9-$z$7" 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = True 28 | 29 | ALLOWED_HOSTS = [] 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "django.contrib.admin", 35 | "django.contrib.auth", 36 | "django.contrib.contenttypes", 37 | "django.contrib.sessions", 38 | "django.contrib.messages", 39 | "django.contrib.staticfiles", 40 | "core", 41 | "dj_shop_cart", 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | "django.middleware.security.SecurityMiddleware", 46 | "django.contrib.sessions.middleware.SessionMiddleware", 47 | "django.middleware.common.CommonMiddleware", 48 | "django.middleware.csrf.CsrfViewMiddleware", 49 | "django.contrib.auth.middleware.AuthenticationMiddleware", 50 | "django.contrib.messages.middleware.MessageMiddleware", 51 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 52 | ] 53 | 54 | ROOT_URLCONF = "example.urls" 55 | 56 | TEMPLATES = [ 57 | { 58 | "BACKEND": "django.template.backends.django.DjangoTemplates", 59 | "DIRS": [BASE_DIR / "templates"], 60 | "APP_DIRS": True, 61 | "OPTIONS": { 62 | "context_processors": [ 63 | "django.template.context_processors.debug", 64 | "django.template.context_processors.request", 65 | "django.contrib.auth.context_processors.auth", 66 | "django.contrib.messages.context_processors.messages", 67 | "cart.context_processors.cart", 68 | ], 69 | }, 70 | }, 71 | ] 72 | 73 | WSGI_APPLICATION = "example.wsgi.application" 74 | 75 | # Database 76 | # https://docs.djangoproject.com/en/4.0/ref/settings/#databases 77 | 78 | DATABASES = { 79 | "default": { 80 | "ENGINE": "django.db.backends.sqlite3", 81 | "NAME": BASE_DIR / "db.sqlite3", 82 | } 83 | } 84 | 85 | # Password validation 86 | # https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators 87 | 88 | AUTH_PASSWORD_VALIDATORS = [ 89 | { 90 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 91 | }, 92 | { 93 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 94 | }, 95 | { 96 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 97 | }, 98 | { 99 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 100 | }, 101 | ] 102 | 103 | # Internationalization 104 | # https://docs.djangoproject.com/en/4.0/topics/i18n/ 105 | 106 | LANGUAGE_CODE = "en-us" 107 | 108 | TIME_ZONE = "UTC" 109 | 110 | USE_I18N = True 111 | 112 | USE_TZ = True 113 | 114 | # Static files (CSS, JavaScript, Images) 115 | # https://docs.djangoproject.com/en/4.0/howto/static-files/ 116 | 117 | STATIC_URL = "static/" 118 | 119 | # Default primary key field type 120 | # https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field 121 | 122 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dj-shop-cart 2 | 3 | A simple and flexible cart manager for your django projects. 4 | 5 | [![pypi](https://badge.fury.io/py/dj-shop-cart.svg)](https://pypi.org/project/dj-shop-cart/) 6 | [![python](https://img.shields.io/pypi/pyversions/dj-shop-cart)](https://github.com/Tobi-De/dj-shop-cart) 7 | [![django](https://img.shields.io/pypi/djversions/dj-shop-cart)](https://github.com/Tobi-De/dj-shop-cart) 8 | [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?)](https://github.com/Tobi-De/dj-shop-cart/blob/master/LICENSE) 9 | [![black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 10 | 11 | ✨📚✨ [Read the full documentation](https://tobi-de.github.io/dj-shop-cart/) 12 | 13 | ## Features 14 | 15 | - Add, remove, decrement and clear items from cart 16 | - Authenticated users cart can be saved to database 17 | - Write custom methods to easily hook into the items add / remove flow 18 | - Custom **get_price** method to ensure that the cart always have an up-to-date products price 19 | - Each item in the cart hold a reference to the associated product 20 | - Metadata data can be attached to cart items 21 | - Supports specification of product variation details 22 | - Available context processor for easy access to the user cart in all your django templates 23 | - Swappable backend storage, with session and database provided by default 24 | 25 | 26 | ## Installation 27 | 28 | Install **dj-shop-cart** with pip or poetry. 29 | 30 | ```bash 31 | pip install dj-shop-cart 32 | ``` 33 | 34 | ## Quickstart 35 | 36 | ```python3 37 | 38 | # settings.py 39 | 40 | TEMPLATES = [ 41 | { 42 | "OPTIONS": { 43 | "context_processors": [ 44 | ..., 45 | "dj_shop_cart.context_processors.cart", # If you want access to the cart instance in all templates 46 | ], 47 | }, 48 | } 49 | ] 50 | 51 | # models.py 52 | 53 | from django.db import models 54 | from dj_shop_cart.cart import CartItem 55 | from dj_shop_cart.protocols import Numeric 56 | 57 | class Product(models.Model): 58 | ... 59 | 60 | def get_price(self, item:CartItem) -> Numeric: 61 | """The only requirements of the dj_shop_cart package apart from the fact that the products you add 62 | to the cart must be instances of django based models. You can use a different name for this method 63 | but be sure to update the corresponding setting (see Configuration). Even if you change the name the 64 | function signature should match this one. 65 | """ 66 | 67 | 68 | # views.py 69 | 70 | from dj_shop_cart.cart import Cart 71 | from django.http import HttpRequest 72 | from django.views.decorators.http import require_POST 73 | from django.shortcuts import get_object_or_404 74 | 75 | from .models import Product 76 | 77 | 78 | @require_POST 79 | def add_product(request: HttpRequest, product_id:int): 80 | product = get_object_or_404(Product.objects.all(), pk=product_id) 81 | quantity = int(request.POST.get("quantity")) 82 | cart = Cart.new(request) 83 | cart.add(product, quantity=quantity) 84 | ... 85 | 86 | 87 | @require_POST 88 | def remove_product(request: HttpRequest): 89 | item_id = request.POST.get("item_id") 90 | quantity = int(request.POST.get("quantity")) 91 | cart = Cart.new(request) 92 | cart.remove(item_id=item_id, quantity=quantity) 93 | ... 94 | 95 | 96 | @require_POST 97 | def empty_cart(request: HttpRequest): 98 | Cart.new(request).empty() 99 | ... 100 | 101 | ``` 102 | 103 | ## Used By 104 | 105 | This project is used by the following companies: 106 | 107 | - [Fêmy bien être](https://www.femybienetre.com/) 108 | - [Bjørn Art](https://bjornart.dk/) 109 | 110 | ## Development 111 | 112 | Poetry is required (not really, you can set up the environment however you want and install the requirements 113 | manually) to set up a virtualenv, install it then run the following: 114 | 115 | ```sh 116 | poetry install 117 | pre-commit install --install-hooks 118 | ``` 119 | 120 | Tests can then be run quickly in that environment: 121 | 122 | ```sh 123 | pytest 124 | ``` 125 | 126 | ## Feedback 127 | 128 | If you have any feedback, please reach out to me at tobidegnon@proton.me. 129 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [tobidegnon@proton.me](mailto:tobidegnon@proton.me). 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][mozilla coc]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][faq]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [mozilla coc]: https://github.com/mozilla/diversity 131 | [faq]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /example/uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 3 3 | requires-python = ">=3.10, <4" 4 | resolution-markers = [ 5 | "python_full_version >= '3.12'", 6 | "python_full_version < '3.12'", 7 | ] 8 | 9 | [[package]] 10 | name = "asgiref" 11 | version = "3.11.0" 12 | source = { registry = "https://pypi.org/simple" } 13 | dependencies = [ 14 | { name = "typing-extensions", marker = "python_full_version < '3.11'" }, 15 | ] 16 | sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" } 17 | wheels = [ 18 | { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, 19 | ] 20 | 21 | [[package]] 22 | name = "django" 23 | version = "5.2.9" 24 | source = { registry = "https://pypi.org/simple" } 25 | resolution-markers = [ 26 | "python_full_version < '3.12'", 27 | ] 28 | dependencies = [ 29 | { name = "asgiref", marker = "python_full_version < '3.12'" }, 30 | { name = "sqlparse", version = "0.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, 31 | { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, 32 | ] 33 | sdist = { url = "https://files.pythonhosted.org/packages/eb/1c/188ce85ee380f714b704283013434976df8d3a2df8e735221a02605b6794/django-5.2.9.tar.gz", hash = "sha256:16b5ccfc5e8c27e6c0561af551d2ea32852d7352c67d452ae3e76b4f6b2ca495", size = 10848762, upload-time = "2025-12-02T14:01:08.418Z" } 34 | wheels = [ 35 | { url = "https://files.pythonhosted.org/packages/17/b0/7f42bfc38b8f19b78546d47147e083ed06e12fc29c42da95655e0962c6c2/django-5.2.9-py3-none-any.whl", hash = "sha256:3a4ea88a70370557ab1930b332fd2887a9f48654261cdffda663fef5976bb00a", size = 8290652, upload-time = "2025-12-02T14:01:03.485Z" }, 36 | ] 37 | 38 | [[package]] 39 | name = "django" 40 | version = "6.0" 41 | source = { registry = "https://pypi.org/simple" } 42 | resolution-markers = [ 43 | "python_full_version >= '3.12'", 44 | ] 45 | dependencies = [ 46 | { name = "asgiref", marker = "python_full_version >= '3.12'" }, 47 | { name = "sqlparse", version = "0.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, 48 | { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, 49 | ] 50 | sdist = { url = "https://files.pythonhosted.org/packages/15/75/19762bfc4ea556c303d9af8e36f0cd910ab17dff6c8774644314427a2120/django-6.0.tar.gz", hash = "sha256:7b0c1f50c0759bbe6331c6a39c89ae022a84672674aeda908784617ef47d8e26", size = 10932418, upload-time = "2025-12-03T16:26:21.878Z" } 51 | wheels = [ 52 | { url = "https://files.pythonhosted.org/packages/d7/ae/f19e24789a5ad852670d6885f5480f5e5895576945fcc01817dfd9bc002a/django-6.0-py3-none-any.whl", hash = "sha256:1cc2c7344303bbfb7ba5070487c17f7fc0b7174bbb0a38cebf03c675f5f19b6d", size = 8339181, upload-time = "2025-12-03T16:26:16.231Z" }, 53 | ] 54 | 55 | [[package]] 56 | name = "example" 57 | version = "0.1.1" 58 | source = { editable = "." } 59 | dependencies = [ 60 | { name = "django", version = "5.2.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, 61 | { name = "django", version = "6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, 62 | ] 63 | 64 | [package.metadata] 65 | requires-dist = [{ name = "django", specifier = ">=5.2" }] 66 | 67 | [package.metadata.requires-dev] 68 | dev = [] 69 | 70 | [[package]] 71 | name = "sqlparse" 72 | version = "0.4.3" 73 | source = { registry = "https://pypi.org/simple" } 74 | resolution-markers = [ 75 | "python_full_version < '3.12'", 76 | ] 77 | sdist = { url = "https://files.pythonhosted.org/packages/ba/fa/5b7662b04b69f3a34b8867877e4dbf2a37b7f2a5c0bbb5a9eed64efd1ad1/sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268", size = 70771, upload-time = "2022-09-23T18:41:23.241Z" } 78 | wheels = [ 79 | { url = "https://files.pythonhosted.org/packages/97/d3/31dd2c3e48fc2060819f4acb0686248250a0f2326356306b38a42e059144/sqlparse-0.4.3-py3-none-any.whl", hash = "sha256:0323c0ec29cd52bceabc1b4d9d579e311f3e4961b98d174201d5622a23b85e34", size = 42768, upload-time = "2022-09-23T18:41:20.351Z" }, 80 | ] 81 | 82 | [[package]] 83 | name = "sqlparse" 84 | version = "0.5.4" 85 | source = { registry = "https://pypi.org/simple" } 86 | resolution-markers = [ 87 | "python_full_version >= '3.12'", 88 | ] 89 | sdist = { url = "https://files.pythonhosted.org/packages/18/67/701f86b28d63b2086de47c942eccf8ca2208b3be69715a1119a4e384415a/sqlparse-0.5.4.tar.gz", hash = "sha256:4396a7d3cf1cd679c1be976cf3dc6e0a51d0111e87787e7a8d780e7d5a998f9e", size = 120112, upload-time = "2025-11-28T07:10:18.377Z" } 90 | wheels = [ 91 | { url = "https://files.pythonhosted.org/packages/25/70/001ee337f7aa888fb2e3f5fd7592a6afc5283adb1ed44ce8df5764070f22/sqlparse-0.5.4-py3-none-any.whl", hash = "sha256:99a9f0314977b76d776a0fcb8554de91b9bb8a18560631d6bc48721d07023dcb", size = 45933, upload-time = "2025-11-28T07:10:19.73Z" }, 92 | ] 93 | 94 | [[package]] 95 | name = "typing-extensions" 96 | version = "4.15.0" 97 | source = { registry = "https://pypi.org/simple" } 98 | sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } 99 | wheels = [ 100 | { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, 101 | ] 102 | 103 | [[package]] 104 | name = "tzdata" 105 | version = "2022.7" 106 | source = { registry = "https://pypi.org/simple" } 107 | sdist = { url = "https://files.pythonhosted.org/packages/5b/30/b7abfb11be6642d26de1c1840d25e8d90333513350ad0ebc03101d55e13b/tzdata-2022.7.tar.gz", hash = "sha256:fe5f866eddd8b96e9fcba978f8e503c909b19ea7efda11e52e39494bad3a7bfa", size = 185615, upload-time = "2022-11-30T19:31:43.827Z" } 108 | wheels = [ 109 | { url = "https://files.pythonhosted.org/packages/fa/5e/f99a7df3ae2079211d31ec23b1d34380c7870c26e99159f6e422dcbab538/tzdata-2022.7-py2.py3-none-any.whl", hash = "sha256:2b88858b0e3120792a3c0635c23daf36a7d7eeeca657c323da299d2094402a0d", size = 340147, upload-time = "2022-11-30T19:31:42.467Z" }, 110 | ] 111 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | staticfiles/ 55 | 56 | # Sphinx documentation 57 | docs/_build/ 58 | 59 | # PyBuilder 60 | target/ 61 | 62 | # pyenv 63 | .python-version 64 | 65 | 66 | 67 | # Environments 68 | .venv 69 | venv/ 70 | ENV/ 71 | 72 | # Rope project settings 73 | .ropeproject 74 | 75 | # mkdocs documentation 76 | /site 77 | 78 | # mypy 79 | .mypy_cache/ 80 | 81 | 82 | ### Node template 83 | # Logs 84 | logs 85 | *.log 86 | npm-debug.log* 87 | yarn-debug.log* 88 | yarn-error.log* 89 | 90 | # Runtime data 91 | pids 92 | *.pid 93 | *.seed 94 | *.pid.lock 95 | 96 | # Directory for instrumented libs generated by jscoverage/JSCover 97 | lib-cov 98 | 99 | # Coverage directory used by tools like istanbul 100 | coverage 101 | 102 | # nyc test coverage 103 | .nyc_output 104 | 105 | # Bower dependency directory (https://bower.io/) 106 | bower_components 107 | 108 | # node-waf configuration 109 | .lock-wscript 110 | 111 | # Compiled binary addons (http://nodejs.org/api/addons.html) 112 | build/Release 113 | 114 | # Dependency directories 115 | node_modules/ 116 | jspm_packages/ 117 | 118 | # Typescript v1 declaration files 119 | typings/ 120 | 121 | # Optional npm cache directory 122 | .npm 123 | 124 | # Optional eslint cache 125 | .eslintcache 126 | 127 | # Optional REPL history 128 | .node_repl_history 129 | 130 | # Output of 'npm pack' 131 | *.tgz 132 | 133 | # Yarn Integrity file 134 | .yarn-integrity 135 | 136 | 137 | ### Linux template 138 | *~ 139 | 140 | # temporary files which can be created if a process still has a handle open of a deleted file 141 | .fuse_hidden* 142 | 143 | # KDE directory preferences 144 | .directory 145 | 146 | # Linux trash folder which might appear on any partition or disk 147 | .Trash-* 148 | 149 | # .nfs files are created when an open file is removed but is still being accessed 150 | .nfs* 151 | 152 | 153 | ### VisualStudioCode template 154 | .vscode/* 155 | !.vscode/settings.json 156 | !.vscode/tasks.json 157 | !.vscode/launch.json 158 | !.vscode/extensions.json 159 | *.code-workspace 160 | 161 | # Local History for Visual Studio Code 162 | .history/ 163 | 164 | 165 | # Provided default Pycharm Run/Debug Configurations should be tracked by git 166 | # In case of local modifications made by Pycharm, use update-index command 167 | # for each changed file, like this: 168 | # git update-index --assume-unchanged .idea/bft.iml 169 | ### JetBrains template 170 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 171 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 172 | 173 | # User-specific stuff: 174 | .idea/**/workspace.xml 175 | .idea/**/tasks.xml 176 | .idea/dictionaries 177 | 178 | # Sensitive or high-churn files: 179 | .idea/**/dataSources/ 180 | .idea/**/dataSources.ids 181 | .idea/**/dataSources.xml 182 | .idea/**/dataSources.local.xml 183 | .idea/**/sqlDataSources.xml 184 | .idea/**/dynamic.xml 185 | .idea/**/uiDesigner.xml 186 | 187 | # Gradle: 188 | .idea/**/gradle.xml 189 | .idea/**/libraries 190 | 191 | # CMake 192 | cmake-build-debug/ 193 | 194 | # Mongo Explorer plugin: 195 | .idea/**/mongoSettings.xml 196 | 197 | ## File-based project format: 198 | *.iws 199 | 200 | ## Plugin-specific files: 201 | 202 | # IntelliJ 203 | out/ 204 | 205 | # mpeltonen/sbt-idea plugin 206 | .idea_modules/ 207 | 208 | # JIRA plugin 209 | atlassian-ide-plugin.xml 210 | 211 | # Cursive Clojure plugin 212 | .idea/replstate.xml 213 | 214 | # Crashlytics plugin (for Android Studio and IntelliJ) 215 | com_crashlytics_export_strings.xml 216 | crashlytics.properties 217 | crashlytics-build.properties 218 | fabric.properties 219 | 220 | 221 | 222 | ### Windows template 223 | # Windows thumbnail cache files 224 | Thumbs.db 225 | ehthumbs.db 226 | ehthumbs_vista.db 227 | 228 | # Dump file 229 | *.stackdump 230 | 231 | # Folder config file 232 | Desktop.ini 233 | 234 | # Recycle Bin used on file shares 235 | $RECYCLE.BIN/ 236 | 237 | # Windows Installer files 238 | *.cab 239 | *.msi 240 | *.msm 241 | *.msp 242 | 243 | # Windows shortcuts 244 | *.lnk 245 | 246 | 247 | ### macOS template 248 | # General 249 | *.DS_Store 250 | .AppleDouble 251 | .LSOverride 252 | 253 | # Icon must end with two \r 254 | Icon 255 | 256 | # Thumbnails 257 | ._* 258 | 259 | # Files that might appear in the root of a volume 260 | .DocumentRevisions-V100 261 | .fseventsd 262 | .Spotlight-V100 263 | .TemporaryItems 264 | .Trashes 265 | .VolumeIcon.icns 266 | .com.apple.timemachine.donotpresent 267 | 268 | # Directories potentially created on remote AFP share 269 | .AppleDB 270 | .AppleDesktop 271 | Network Trash Folder 272 | Temporary Items 273 | .apdisk 274 | 275 | 276 | ### SublimeText template 277 | # Cache files for Sublime Text 278 | *.tmlanguage.cache 279 | *.tmPreferences.cache 280 | *.stTheme.cache 281 | 282 | # Workspace files are user-specific 283 | *.sublime-workspace 284 | 285 | # Project files should be checked into the repository, unless a significant 286 | # proportion of contributors will probably not be using Sublime Text 287 | # *.sublime-project 288 | 289 | # SFTP configuration file 290 | sftp-config.json 291 | 292 | # Package control specific files 293 | Package Control.last-run 294 | Package Control.ca-list 295 | Package Control.ca-bundle 296 | Package Control.system-ca-bundle 297 | Package Control.cache/ 298 | Package Control.ca-certs/ 299 | Package Control.merged-ca-bundle 300 | Package Control.user-ca-bundle 301 | oscrypto-ca-bundle.crt 302 | bh_unicode_properties.cache 303 | 304 | # Sublime-github package stores a github token in this file 305 | # https://packagecontrol.io/packages/sublime-github 306 | GitHub.sublime-settings 307 | 308 | 309 | ### Vim template 310 | # Swap 311 | [._]*.s[a-v][a-z] 312 | [._]*.sw[a-p] 313 | [._]s[a-v][a-z] 314 | [._]sw[a-p] 315 | 316 | # Session 317 | Session.vim 318 | 319 | # Temporary 320 | .netrwhist 321 | 322 | # Auto-generated tag files 323 | tags 324 | 325 | ### Project template 326 | 327 | .pytest_cache/ 328 | .env 329 | .idea/* 330 | -------------------------------------------------------------------------------- /tests/test_cart.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | from dataclasses import dataclass 5 | 6 | import pytest 7 | from django.contrib.auth import get_user_model 8 | from django.contrib.auth.models import AnonymousUser 9 | from django.contrib.sessions.backends.base import SessionBase 10 | from django.test import RequestFactory 11 | 12 | from dj_shop_cart.cart import Cart, CartItem, get_cart 13 | from dj_shop_cart.modifiers import CartModifier, cart_modifiers_pool 14 | from dj_shop_cart.storages import DBStorage, SessionStorage 15 | from tests.factories import ProductFactory 16 | from tests.models import Product 17 | 18 | from .conftest import PREFIXED_CART_KEY 19 | 20 | pytestmark = pytest.mark.django_db 21 | 22 | User = get_user_model() 23 | 24 | 25 | def test_cart_init_session_storage(cart: Cart): 26 | assert isinstance(cart.storage, SessionStorage) 27 | assert len(cart) == cart.unique_count == cart.count == 0 28 | 29 | 30 | def test_cart_init_db_storage(cart_db: Cart, settings): 31 | assert isinstance(cart_db.storage, DBStorage) 32 | assert len(cart_db) == cart_db.unique_count == cart_db.count == 0 33 | 34 | 35 | def add_product_to_cart(cart: Cart): 36 | product = ProductFactory() 37 | cart.add(product, quantity=10) 38 | assert len(cart) == cart.unique_count == 1 39 | assert cart.count == 10 40 | assert cart.find_one(product=product).product == product 41 | assert product in cart.products 42 | 43 | 44 | def test_cart_add_session_storage(cart: Cart): 45 | add_product_to_cart(cart=cart) 46 | 47 | 48 | def test_cart_add_db_storage(cart_db: Cart): 49 | add_product_to_cart(cart=cart_db) 50 | 51 | 52 | def add_product_multiple_to_cart(cart: Cart): 53 | product_a = ProductFactory() 54 | product_b = ProductFactory() 55 | cart.add(product_a, quantity=10) 56 | cart.add(product_b, quantity=5) 57 | cart.add(product_a, quantity=10) 58 | assert len(cart) == cart.unique_count == 2 59 | assert cart.count == 25 60 | assert product_a in cart.products 61 | assert product_a in cart.products 62 | assert cart.find_one(product=product_a).quantity == 20 63 | assert cart.find_one(product=product_b).quantity == 5 64 | 65 | 66 | def test_cart_add_multiple_session_storage(cart: Cart): 67 | add_product_multiple_to_cart(cart=cart) 68 | 69 | 70 | def test_cart_add_multiple_db_storage(cart_db: Cart): 71 | add_product_multiple_to_cart(cart=cart_db) 72 | 73 | 74 | def add_product_override_quantity(cart: Cart): 75 | product = ProductFactory() 76 | cart.add(product, quantity=5) 77 | cart.add(product, quantity=5, override_quantity=True) 78 | assert len(cart) == cart.unique_count == 1 79 | assert cart.count == 5 80 | 81 | 82 | def test_cart_add_override_quantity_session_storage(cart: Cart): 83 | add_product_override_quantity(cart=cart) 84 | 85 | 86 | def test_cart_add_override_quantity_db_storage(cart_db: Cart): 87 | add_product_override_quantity(cart=cart_db) 88 | 89 | 90 | def test_cart_increase_quantity(cart: Cart): 91 | product = ProductFactory() 92 | item = cart.add(product, quantity=10) 93 | item = cart.increase(item.id, quantity=10) 94 | assert item.quantity == 20 95 | 96 | 97 | def test_cart_increase_quantity_fake_item(cart: Cart): 98 | item = cart.increase(str(uuid.uuid4()), quantity=10) 99 | assert item is None 100 | 101 | 102 | def cart_is_empty(cart: Cart): 103 | assert cart.is_empty 104 | product = ProductFactory() 105 | cart.add(product, quantity=2) 106 | assert not cart.is_empty 107 | 108 | 109 | def test_cart_is_empty_session_storage(cart: Cart): 110 | cart_is_empty(cart=cart) 111 | 112 | 113 | def test_cart_is_empty_db_storage(cart_db): 114 | cart_is_empty(cart=cart_db) 115 | 116 | 117 | def test_cart_empty_clear_metadata(cart: Cart): 118 | product = ProductFactory() 119 | cart.add(product=product, quantity=2, metadata={"something": 1}) 120 | cart.update_metadata({"something": 1}) 121 | cart.empty(clear_metadata=True) 122 | assert cart.is_empty 123 | assert not cart.metadata 124 | 125 | 126 | def test_cart_empty_not_clear_metadata(cart: Cart): 127 | product = ProductFactory() 128 | cart.add(product=product, quantity=2, metadata={"something": 1}) 129 | cart.update_metadata({"something": 1}) 130 | cart.empty(clear_metadata=False) 131 | assert cart.is_empty 132 | assert cart.metadata["something"] == 1 133 | 134 | 135 | def cart_remove_product(cart: Cart): 136 | product = ProductFactory() 137 | item = cart.add(product, quantity=10) 138 | assert cart.count == 10 139 | cart.remove(item.id, quantity=2) 140 | assert cart.count == 8 141 | cart.remove(item.id) 142 | assert cart.is_empty 143 | 144 | 145 | def test_cart_remove_session_storage(cart: Cart): 146 | cart_remove_product(cart) 147 | 148 | 149 | def test_cart_remove_db_storage(cart_db: Cart): 150 | cart_remove_product(cart_db) 151 | 152 | 153 | def empty_cart(cart: Cart): 154 | product = ProductFactory() 155 | cart.add(product, quantity=10) 156 | cart.empty() 157 | assert cart.is_empty 158 | assert len(cart) == cart.count == cart.unique_count == 0 159 | 160 | 161 | def test_empty_cart_session_storage(cart: Cart): 162 | empty_cart(cart=cart) 163 | 164 | 165 | def test_empty_cart_db_storage(cart_db: Cart): 166 | empty_cart(cart=cart_db) 167 | 168 | 169 | def test_cart_item_subtotal(cart: Cart, product: Product): 170 | cart.add(product, quantity=2) 171 | assert [item.subtotal for item in cart][0] == product.price * 2 172 | assert cart.total == product.price * 2 173 | 174 | 175 | def test_cart_total(cart: Cart): 176 | product_a = ProductFactory() 177 | product_b = ProductFactory() 178 | cart.add(product_a, quantity=10) 179 | cart.add(product_b, quantity=5) 180 | assert cart.total == (product_a.price * 10) + (product_b.price * 5) 181 | 182 | 183 | def test_cart_multiple_variants(cart: Cart, product: Product): 184 | variant_a = "imavarianta" 185 | variant_b = "imamvariantb" 186 | cart.add(product, quantity=2, variant=variant_a) 187 | cart.add(product, quantity=5, variant=variant_b) 188 | assert cart.unique_count == len(cart) == 2 189 | assert cart.find_one(product=product, variant=variant_a).quantity == 2 190 | assert cart.find_one(product=product, variant=variant_b).quantity == 5 191 | assert cart.count == 7 192 | 193 | 194 | def test_cart_variants_group_by_product(cart: Cart, product: Product): 195 | variant_a = "imavarianta" 196 | variant_b = "imamvariantb" 197 | item_a = cart.add(product, quantity=2, variant=variant_a) 198 | item_b = cart.add(product, quantity=5, variant=variant_b) 199 | assert cart.variants_group_by_product() == {str(product.pk): [item_a, item_b]} 200 | 201 | 202 | def test_cart_item_with_metadata(cart: Cart, product: Product): 203 | metadata = {"comment": "for some reason this item is special"} 204 | cart.add(product, quantity=2, metadata=metadata) 205 | assert metadata == cart.find_one(product=product).metadata 206 | 207 | 208 | def test_prefixed_cart(cart: Cart, prefixed_cart: Cart): 209 | product = ProductFactory() 210 | product_2 = ProductFactory() 211 | cart.add(product, quantity=2) 212 | prefixed_cart.add(product_2, quantity=2) 213 | assert cart.count == 2 214 | assert prefixed_cart.count == 2 215 | assert product_2 not in cart.products 216 | assert product not in prefixed_cart.products 217 | 218 | 219 | def test_cart_with_metadata(cart: Cart, product: Product): 220 | metadata = {"comment": "for some reason this cart is special"} 221 | cart.update_metadata(metadata) 222 | cart.add(product, quantity=2, metadata=metadata) 223 | assert metadata == cart.metadata 224 | 225 | 226 | def test_prefixed_cart_with_metadata( 227 | rf: RequestFactory, session: SessionBase, settings 228 | ): 229 | settings.CART_STORAGE_BACKEND = "dj_shop_cart.storages.SessionStorage" 230 | 231 | metadata = {"simple_cart": "metadata for simple cart"} 232 | metadata_2 = {"prefixed_cart": "metadata for prefixed cart"} 233 | 234 | user = AnonymousUser() 235 | 236 | first_request = rf.get("/") 237 | first_request.user = user 238 | first_request.session = session 239 | 240 | cart = get_cart(first_request) 241 | cart.update_metadata(metadata) 242 | 243 | prefixed_cart = get_cart(first_request, prefix=PREFIXED_CART_KEY) 244 | prefixed_cart.update_metadata(metadata_2) 245 | 246 | # reload both carts 247 | second_request = rf.get("/") 248 | second_request.user = user 249 | second_request.session = session 250 | 251 | new_cart = get_cart(second_request) 252 | new_prefixed_cart = get_cart(second_request, prefix=PREFIXED_CART_KEY) 253 | 254 | assert new_cart.metadata == metadata 255 | assert new_prefixed_cart.metadata == metadata_2 256 | 257 | 258 | class TestModifier(CartModifier): 259 | def before_add(self, cart, item, quantity): 260 | print("is run") 261 | item.metadata["hooks"] = ["before_add"] 262 | 263 | def after_add(self, cart, item): 264 | item.metadata["hooks"] = item.metadata["hooks"] + ["after_add"] 265 | 266 | def before_remove(self, cart, item=None, quantity=None): 267 | if item: 268 | item.metadata["hooks"] = item.metadata["hooks"] + ["before_remove"] 269 | 270 | def after_remove(self, cart, item=None): 271 | item.metadata["hooks"] = item.metadata["hooks"] + ["after_remove"] 272 | 273 | 274 | def test_cart_custom_modifier(rf, session, cart, product, monkeypatch): 275 | monkeypatch.setattr(cart_modifiers_pool, "get_modifiers", lambda: [TestModifier()]) 276 | request = rf.get("/") 277 | request.user = AnonymousUser() 278 | request.session = session 279 | item = cart.add(product) 280 | assert "before_add" in item.metadata["hooks"] 281 | assert "after_add" in item.metadata["hooks"] 282 | item = cart.remove(item.id) 283 | assert "before_remove" in item.metadata["hooks"] 284 | assert "after_remove" in item.metadata["hooks"] 285 | 286 | 287 | db = {} 288 | 289 | 290 | @dataclass 291 | class P: 292 | pk: str 293 | name: str 294 | 295 | @classmethod 296 | def get_cart_object(cls, item: CartItem): 297 | return db[item.product_pk] 298 | 299 | 300 | def test_add_not_django_model(rf, session, cart): 301 | p_pk = str(uuid.uuid4()) 302 | p = P(name="product not in db", pk=p_pk) 303 | db[p_pk] = p 304 | 305 | cart.add(p, quantity=1) 306 | assert p == list(cart)[0].product 307 | -------------------------------------------------------------------------------- /dj_shop_cart/cart.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import itertools 5 | from collections.abc import Iterator 6 | from functools import cached_property 7 | from typing import TypeVar, cast 8 | from uuid import uuid4 9 | 10 | from attrs import Factory, asdict, define, field 11 | from django.core.exceptions import ObjectDoesNotExist 12 | from django.db import models 13 | from django.http import HttpRequest 14 | 15 | from .conf import conf 16 | from .modifiers import cart_modifiers_pool 17 | from .protocols import Numeric, Storage 18 | from .utils import import_class 19 | 20 | __all__ = ("Cart", "CartItem", "get_cart_class", "get_cart") 21 | 22 | DjangoModel = TypeVar("DjangoModel", bound=models.Model) 23 | DEFAULT_CART_PREFIX = "default" 24 | 25 | 26 | @define(kw_only=True) 27 | class CartItem: 28 | id: str = field(factory=uuid4, converter=str) 29 | quantity: int = field(eq=False, converter=int) 30 | variant: str | None = field(default=None) 31 | product_pk: str = field(converter=str) 32 | product_model_path: str 33 | metadata: dict = field(factory=dict, eq=False) 34 | 35 | @cached_property 36 | def product(self) -> DjangoModel: 37 | model = cast(type[DjangoModel], import_class(self.product_model_path)) 38 | if func := getattr(model, "get_cart_object", None): 39 | # this is a hack to allow to use a product that is not a django 40 | # model instance / instance not already save in db 41 | return func(self) # noqa 42 | return model.objects.get(pk=self.product_pk) 43 | 44 | @property 45 | def subtotal(self) -> Numeric: 46 | return self.price * self.quantity 47 | 48 | @property 49 | def price(self) -> Numeric: 50 | return getattr(self.product, conf.CART_PRODUCT_GET_PRICE_METHOD)(self) 51 | 52 | @classmethod 53 | def from_product( 54 | cls, 55 | product: DjangoModel, 56 | quantity: int, 57 | variant: str | None = None, 58 | metadata: dict | None = None, 59 | ) -> CartItem: 60 | metadata = metadata or {} 61 | return cls( 62 | quantity=quantity, 63 | variant=variant, 64 | product_pk=product.pk, 65 | product_model_path=f"{product.__class__.__module__}.{product.__class__.__name__}", 66 | metadata=metadata, 67 | ) 68 | 69 | 70 | @define(kw_only=True) 71 | class Cart: 72 | storage: Storage 73 | prefix: str = field(default=DEFAULT_CART_PREFIX) 74 | _metadata: dict = field(factory=dict) 75 | _items: list[CartItem] = Factory(list) 76 | 77 | def __len__(self) -> int: 78 | return self.unique_count 79 | 80 | def __iter__(self) -> Iterator[CartItem]: 81 | yield from self._items 82 | 83 | def __contains__(self, item: CartItem) -> bool: 84 | return item in self 85 | 86 | @property 87 | def items(self) -> list[CartItem]: 88 | return self._items 89 | 90 | @property 91 | def total(self) -> Numeric: 92 | return sum(item.subtotal for item in self) 93 | 94 | @property 95 | def is_empty(self) -> bool: 96 | return self.unique_count == 0 97 | 98 | @property 99 | def count(self) -> int: 100 | """ 101 | The number of items in the cart, that's the sum of quantities. 102 | """ 103 | return sum(item.quantity for item in self) 104 | 105 | @property 106 | def unique_count(self) -> int: 107 | """ 108 | The number of unique items in the cart, regardless of the quantity. 109 | """ 110 | return len(self._items) 111 | 112 | @property 113 | def products(self) -> list[DjangoModel]: 114 | """ 115 | The list of associated products. 116 | """ 117 | return [item.product for item in self] 118 | 119 | def find(self, **criteria) -> list[CartItem]: 120 | """ 121 | Returns a list of cart items matching the given criteria. 122 | """ 123 | 124 | def get_item_dict(item: CartItem) -> dict: 125 | return {key: getattr(item, key) for key in criteria} 126 | 127 | return [item for item in self if get_item_dict(item) == criteria] 128 | 129 | def find_one(self, **criteria) -> CartItem | None: 130 | """ 131 | Returns the first cart item that matches the given criteria, if no match is found return None. 132 | """ 133 | try: 134 | return self.find(**criteria)[0] 135 | except IndexError: 136 | return None 137 | 138 | def add( 139 | self, 140 | product: DjangoModel, 141 | *, 142 | quantity: int = 1, 143 | variant: str | None = None, 144 | override_quantity: bool = False, 145 | metadata: dict | None = None, 146 | ) -> CartItem: 147 | """ 148 | Add a new item to the cart 149 | 150 | :param product: An instance of a database product 151 | :param quantity: The quantity that will be added to the dj_shop_cart (default to 1) 152 | :param variant: Variant details of the product 153 | :param override_quantity: If true will overwrite the quantity of the item if it already exists 154 | :param metadata: Optional metadata that is attached to the item, this dictionary can contain 155 | anything that you would want to attach to the created item in cart, the only requirements about 156 | it is that it needs to be json serializable 157 | :return: An instance of the item added 158 | """ 159 | assert ( 160 | quantity >= 1 161 | ), f"Item quantity must be greater than or equal to 1: {quantity}" 162 | item = self.find_one(product=product, variant=variant) 163 | if not item: 164 | item = CartItem.from_product( 165 | product, quantity=0, variant=variant, metadata=metadata 166 | ) 167 | self._items.append(item) 168 | if metadata: 169 | item.metadata.update(metadata) 170 | 171 | for modifier in cart_modifiers_pool.get_modifiers(): 172 | modifier.before_add(cart=self, item=item, quantity=quantity) 173 | 174 | if override_quantity: 175 | item.quantity = quantity 176 | else: 177 | item.quantity += quantity 178 | 179 | for modifier in cart_modifiers_pool.get_modifiers(): 180 | modifier.after_add(cart=self, item=item) 181 | 182 | self.save() 183 | return item 184 | 185 | def increase( 186 | self, 187 | item_id: str, 188 | *, 189 | quantity: int = 1, 190 | ) -> CartItem | None: 191 | """ 192 | Increase the quantity of an item already in the cart 193 | 194 | :param item_id: The cart item id 195 | :param quantity: The quantity to add 196 | :return: The updated item or None 197 | """ 198 | item = self.find_one(id=item_id) 199 | if not item: 200 | return 201 | 202 | for modifier in cart_modifiers_pool.get_modifiers(): 203 | modifier.before_add(cart=self, item=item, quantity=quantity) 204 | 205 | item.quantity += quantity 206 | 207 | for modifier in cart_modifiers_pool.get_modifiers(): 208 | modifier.after_add(cart=self, item=item) 209 | 210 | self.save() 211 | return item 212 | 213 | def remove( 214 | self, 215 | item_id: str, 216 | *, 217 | quantity: int | None = None, 218 | ) -> CartItem | None: 219 | """ 220 | Remove an item from the cart entirely or partially based on the quantity 221 | 222 | :param item_id: The cart item id 223 | :param quantity: The quantity of the product to remove from the cart 224 | :return: The removed item with an updated quantity or None 225 | """ 226 | item = self.find_one(id=item_id) 227 | if not item: 228 | return None 229 | 230 | for modifier in cart_modifiers_pool.get_modifiers(): 231 | modifier.before_remove(cart=self, item=item, quantity=quantity) 232 | 233 | if quantity: 234 | item.quantity -= int(quantity) 235 | else: 236 | item.quantity = 0 237 | if item.quantity <= 0: 238 | self._items.pop(self._items.index(item)) 239 | 240 | for modifier in cart_modifiers_pool.get_modifiers(): 241 | modifier.after_remove(cart=self, item=item) 242 | 243 | self.save() 244 | return item 245 | 246 | def save(self) -> None: 247 | items = [] 248 | for item in self._items: 249 | try: 250 | _ = item.product 251 | except ObjectDoesNotExist: 252 | # If the product associated with the item is no longer in the database, we skip it 253 | continue 254 | items.append(asdict(item)) 255 | # load storage old data to avoid overwriting 256 | data = self.storage.load() 257 | data[self.prefix] = {"items": items, "metadata": self.metadata} 258 | self.storage.save(data) 259 | 260 | def variants_group_by_product(self) -> dict[str, list[CartItem]]: 261 | """ 262 | Return a dictionary with the products ids as keys and a list of variant as values. 263 | """ 264 | return { 265 | key: list(items) 266 | for key, items in itertools.groupby(self, lambda item: item.product_pk) 267 | } 268 | 269 | @property 270 | def metadata(self) -> dict: 271 | return self._metadata 272 | 273 | def update_metadata(self, metadata: dict) -> None: 274 | self._metadata.update(metadata) 275 | self.save() 276 | 277 | def clear_metadata(self, *keys: list[str]) -> None: 278 | if keys: 279 | for key in keys: 280 | self._metadata.pop(key, None) 281 | else: 282 | self._metadata = {} 283 | self.save() 284 | 285 | def empty(self, clear_metadata: bool = True) -> None: 286 | """Delete all items in the cart, and optionally clear the metadata.""" 287 | self._items = [] 288 | if clear_metadata: 289 | self._metadata = {} 290 | data = self.storage.load() 291 | data[self.prefix] = {"items": self._items, "metadata": self.metadata} 292 | self.storage.save(data) 293 | 294 | def empty_all(self) -> None: 295 | """Empty all carts for the current user""" 296 | self.storage.clear() 297 | 298 | @classmethod 299 | def new(cls, storage: Storage, prefix: str = DEFAULT_CART_PREFIX) -> Cart: 300 | """Appropriately create a new cart instance. This builder load existing cart if needed.""" 301 | instance = cls(storage=storage, prefix=prefix) 302 | try: 303 | data = storage.load().get(prefix, {}) 304 | except AttributeError: 305 | data = {} 306 | 307 | metadata = data.get("metadata", {}) 308 | items = data.get("items", []) 309 | for val in items: 310 | try: 311 | item = CartItem(**val) 312 | _ = item.product 313 | except ObjectDoesNotExist: 314 | # If the product associated with the item is no longer in the database, we skip it 315 | continue 316 | instance._items.append(item) 317 | instance._metadata = metadata 318 | return instance 319 | 320 | 321 | def get_cart(request: HttpRequest, prefix: str = DEFAULT_CART_PREFIX) -> Cart: 322 | storage = conf.CART_STORAGE_BACKEND(request) 323 | return Cart.new(storage=storage, prefix=prefix) 324 | 325 | 326 | def get_cart_class() -> type[Cart]: 327 | """ 328 | Returns the correct cart manager class 329 | """ 330 | return Cart 331 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ## Configuration 4 | 5 | Configure the cart behaviour in your Django settings. All settings are optional and must be strings if defined. 6 | 7 | | Name | Description | Default | 8 | |-------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------| 9 | | CART_SESSION_KEY | The key used to store the cart in session | `CART-ID` | 10 | | CART_CLASS | The path to the `Cart` class to use. If you are using a custom class it must subclass `dj_shop_cart.cart.Cart` | `dj_shop_cart.cart.Cart` | 11 | | CART_PRODUCT_GET_PRICE_METHOD | The method name to use to dynamically get the price on the product instance | `get_price` | 12 | | CART_STORAGE_BACKEND | The path to the storage backend to use. If you define a custom storage backend, it should follow the `Storage` protocol, see the **Backend Storage section** below | `dj_shop_cart.storages.SessionStorage` | 13 | | CART_CACHE_TIMEOUT | The cache timeout when using the **CartStorage** backend, default to 5 days. | 60 * 60 * 24 * 5 | 14 | 15 | ## Examples 16 | 17 | ### Instantiate a new cart 18 | 19 | ```python 20 | from dj_shop_cart.cart import get_cart_class 21 | from django.http import HttpRequest 22 | 23 | Cart = get_cart_class() 24 | 25 | def view(request:HttpRequest): 26 | cart = Cart.new(request) 27 | ... 28 | ``` 29 | 30 | The `new` method create a new cart and load existing data via the specified storage backend. 31 | 32 | ### Add a product to the cart 33 | 34 | ```python 35 | cart.add(product, quantity=20) 36 | ``` 37 | 38 | Parameters 39 | 40 | - **product**: An instance of a database product. 41 | - **quantity**: The quantity to add. 42 | - **variant**: The product variation details, when specified, are used to uniquely identify items in the cart related to the same product, 43 | can be a python dictionary, a set, an integer or a string. 44 | - **override_quantity** : Default to `False`, if `True` instead of adding to the existing quantity, will override it 45 | - **metadata**: Optional metadata that is attached to the item, this dictionary can contain 46 | anything that you would want to attach to the created item in cart, the only requirements about 47 | it is that it needs to be json serializable. 48 | 49 | Returns a `CartItem`. 50 | 51 | ### Increase an item quantity 52 | 53 | ```python 54 | cart.increase(item_id, quantity=20) 55 | ``` 56 | 57 | This method increase the quantity of an item that is already in the cart. It triggers the same `before_add` 58 | and `after_add` hooks as the `cart.add` method. You can think of this as a shortcut to `cart.add` for 59 | product that are already in the cart. 60 | 61 | Parameters 62 | 63 | - **item_id**: The cart item id. 64 | - **quantity**: The quantity to add. 65 | 66 | Returns a `CartItem` or `None` if no item to increase was found. 67 | 68 | ### Remove / Decrement a product from the cart 69 | 70 | ```python 71 | # Remove 10 from the quantity 72 | cart.remove(item_id, quantity=10) 73 | # Remove the whole item 74 | cart.remove(item_id) 75 | ``` 76 | 77 | Parameters 78 | 79 | - **item_id** : The cart item id. 80 | - **quantity** : An optional quantity of the product to remove from the cart. 81 | Indicate if you do not want to delete the item completely, if the quantity ends up being zero after the quantity is decreased, the item is completely removed. 82 | - **variant** : Variant details of the product. 83 | 84 | Returns a `CartItem` or `None` if no item to remove was found. 85 | 86 | **Note**: An item is automatically removed from the cart when the associated database product is no longer available (delete from the database). 87 | 88 | ### Empty the cart 89 | 90 | ```python 91 | cart.empty() 92 | ``` 93 | This method take no arguments. 94 | 95 | ### Attributes of a `Cart` instance 96 | 97 | ```python 98 | def my_view(request): 99 | cart = Cart.new(request) 100 | # by looping through the cart, we return all the CartItem objects. 101 | for item in cart: 102 | print(item.subtotal) 103 | 104 | # find items based on product_pk, cart item id, variant details, quantity, etc. 105 | item = cart.find_one(product_pk=1) 106 | assert item in cart 107 | 108 | # the number of items in the cart 109 | print(cart.count) 110 | 111 | # the number of unique items 112 | print(cart.unique_count) 113 | 114 | # calling len on the cart returns the number of unique items in the cart, regardless of the quantity. 115 | print(len(cart)) 116 | 117 | # attach some metadata to the cart 118 | cart.update_metadata({"discount": "10%"}) 119 | 120 | ``` 121 | 122 | - **total** : The total cost of the cart. 123 | - **is_empty** : A boolean value indicating whether the cart is empty or not. 124 | - **count** : The number of items in the cart, that's the sum of quantities. 125 | - **unique_count** : The number of unique items in the cart, regardless of the quantity. 126 | - **products** : A list of associated products. 127 | - **metadata** : A dictionary containing the metadata of the cart. 128 | - **empty(clear_metadata=True)** : Empty the cart. Takes an optional argument `clear_metadata` that defaults to `True`, if set to `False` the metadata of the cart will not be cleared. 129 | - **update_metadata(metadata:dict)** : Update the metadata of the cart. 130 | - **clear_metadata(\*keys:list[str])** : Clear the metadata of the cart. Takes an optional list of keys to clear, if no keys are specified, all metadata is cleared. 131 | - **find(\*\*criteria)** : Returns a list of cart items matching the given criteria. 132 | - **find_one(\*\*criteria)** : Returns the first cart item that matches the given criteria, if no match is found return None. 133 | - **variants_group_by_product()** : Return a dictionary with the products ids as keys and a list of variant as values. 134 | 135 | ### Classmethods of `Cart` 136 | 137 | - **new(request:HttpRequest, prefix="default")** : Create a new cart instance and load existing data from the storage backend. 138 | - **empty_all(request:HttpRequest)** : Empty all carts for the current user. 139 | 140 | ## Multiple Carts 141 | 142 | You can manage multiple carts at the same time with the same storage using the `prefix` argument of the `Cart.new` method. 143 | 144 | ```python 145 | cart_a = Cart.new(request, prefix="cart_a") 146 | cart_a.add(product_a, quantity=10) 147 | assert product_a in cart_a 148 | 149 | cart_b = Cart.new(request, prefix="cart_b") 150 | cart_b.add(product_b, quantity=10) 151 | assert product_b in cart_b 152 | 153 | assert product_a not in cart_b 154 | assert product_b not in cart_a 155 | ``` 156 | A little tip if you don't want to have to remember to pass the right prefix each time you instantiate a new cart, 157 | use the `partial` method from the `functools` module. 158 | 159 | ```python 160 | from functools import partial 161 | 162 | get_cart_a = partial(Cart.new, prefix="cart_a") 163 | cart_a = get_cart_a(request) 164 | cart_a.add(product_a, quantity=10) 165 | ``` 166 | ### Cart Modifiers 167 | 168 | ### Properties of `CartItem` 169 | 170 | - **id** : A unique id for the item. 171 | - **price** : The item price calculated via the `get_price` method. 172 | - **subtotal** : Item price x quantity. 173 | - **product** : The associated product. 174 | - **variant** : Variant info specified when the product was added to the cart, default to `None`, is used to compare items in the cart. 175 | - **metadata** : A dictionary containing the metadata specified when the product was added to the cart, not used when comparing two cart items. 176 | 177 | ## Storage Backend 178 | 179 | The storage backend are used to store the cart items data. Two backends are provided by default, `SessionStorage` and 180 | `DBStorage`. 181 | 182 | ### SessionStorage 183 | 184 | ```python 185 | # settings.py 186 | 187 | CART_STORAGE_BACKEND = "dj_shop_cart.storages.SessionStorage" 188 | ``` 189 | 190 | This is the default backend used when no one is specified. It uses the django sessions app to store carts in the user 191 | session. Carts only live for the duration of the user session and each new session generates a new cart. By default, django 192 | stores the session in the database, but you can [configure your session engine to use your cache backend](https://docs.djangoproject.com/en/dev/topics/http/sessions/#using-cached-sessions) to speed things up, 193 | especially if you're using something like redis or memcached as your cache backend. 194 | 195 | 196 | ### CacheStorage 197 | 198 | ```python 199 | # settings.py 200 | 201 | CART_STORAGE_BACKEND = "dj_shop_cart.storages.CacheStorage" 202 | ``` 203 | 204 | This is the recommended backend if you want to store your customers' shopping carts (especially authenticated ones) beyond the duration of their 205 | sessions. This backend stores the cart details using [Django's cache framework which](https://docs.djangoproject.com/en/dev/topics/cache/), depending on how it is configured, could be 206 | much faster than **SessionStorage** and **DBStorage** which are both database dependent. There are a few things to keep in mind when using this backend: 207 | 208 | - This backend storage handles both authenticated and unauthenticated users. 209 | - Unauthenticated users' cart details are retained after the end of the current user's session but there is no way to identify that a cart belongs to a specific unauthenticated user between sessions, so if an unauthenticated user lives without login-in the cart data is lost. 210 | - There is a timeout after which the data in a cart will be automatically deleted, the default value is 5 days, and it can be configured with the **CART_CACHE_TIMEOUT** settings. 211 | 212 | ### DBStorage 213 | 214 | ```python 215 | # settings.py 216 | 217 | INSTALLED_APPS = [ 218 | ..., 219 | "dj_shop_cart", 220 | ..., 221 | ] 222 | 223 | CART_STORAGE_BACKEND = "dj_shop_cart.storages.DBStorage" 224 | ``` 225 | 226 | This backend persists users carts in the database but only when they are authenticated. There is no point in saving 227 | a cart that is linked to a user with no account in your system, your database will be filled with carts that 228 | can't be associated with a specific user. This backend works by using `SessionStorage` when users are not authenticated, 229 | and then saving their cart to the database when the user authenticates. There is always only one Cart object associated with 230 | a user at a time, so be sure to empty the cart after the checkout process to avoid reusing data from a previously processed 231 | cart. Cart objects in the database are not automatically deleted. 232 | 233 | 234 | ### Custom storage backend 235 | 236 | You can also create your own custom storage backend, a redis storage backend for example. You can also import and use 237 | the provided backend storages when building your own (like the DBStorage does). You don't need to inherit a specific class, 238 | all you need to do is write a class that defines some specific methods and attributes, a class that follows a protocol. 239 | Now that your custom storage backend is ready, all you have to do is specify it via the `CART_STORAGE_BACKEND` settings. 240 | The protocol to be implemented is described as follows: 241 | 242 | ```python 243 | from typing import Protocol 244 | 245 | from django.http import HttpRequest 246 | 247 | class Storage(Protocol): 248 | request: HttpRequest 249 | 250 | def load(self) -> list[dict]: 251 | ... 252 | 253 | def save(self, items: list[dict]) -> None: 254 | ... 255 | 256 | def clear(self) -> None: 257 | ... 258 | ``` 259 | --------------------------------------------------------------------------------