Skip to main content

A simple session-backed shopping cart for modern Django.

Project description

GitHub Actions Workflow Status PyPI - Version

django-cart

A simple, session-backed shopping cart for modern Django (4.2+) and Python 3.10+.

django-cart uses Django's content-type framework so you can use any model as a product — no changes required to your existing code.


Table of Contents


Features

  • Session-linked cart backed by a lightweight DB record
  • Works with any product model via Django's generic foreign keys
  • add, remove, update, clear, checkout operations
  • count, summary, is_empty, cart_serializable helpers
  • Django signals for extensibility (item added, removed, updated, cart checked out, cart cleared)
  • Template tags for easy cart display in templates (item count, summary, is_empty, cart link)
  • Session adapters for flexible session storage (Django sessions, cookies)
  • Management command clean_carts with configurable retention window
  • Full test suite (159 tests) covering success, error, integration, and performance cases
  • Type hints for full IDE and static analysis support
  • Product caching to avoid N+1 queries when iterating
  • Pre-commit hooks for code quality (black, isort, flake8, mypy)
  • Automated dependency updates via Dependabot

Requirements

Dependency Version
Python 3.10+
Django 4.2+

The django.contrib.contenttypes app must be in INSTALLED_APPS (it is by default).


Installation

pip install django-cart

Then add cart to INSTALLED_APPS in your settings.py:

INSTALLED_APPS = [
    ...
    "django.contrib.contenttypes",  # must be present
    "cart",
]

Run the migrations:

python manage.py migrate cart

Quick Start

1. Add to your views

# views.py
from decimal import Decimal
from django.shortcuts import get_object_or_404, redirect, render

from cart.cart import Cart, ItemDoesNotExist, InvalidQuantity
from shop.models import Product  # your own product model


def cart_add(request, product_id):
    product = get_object_or_404(Product, pk=product_id)
    quantity = int(request.POST.get("quantity", 1))
    cart = Cart(request)
    cart.add(product, unit_price=product.price, quantity=quantity)
    return redirect("cart_detail")


def cart_remove(request, product_id):
    product = get_object_or_404(Product, pk=product_id)
    cart = Cart(request)
    try:
        cart.remove(product)
    except ItemDoesNotExist:
        pass  # already gone — not an error in most UX flows
    return redirect("cart_detail")


def cart_update(request, product_id):
    product = get_object_or_404(Product, pk=product_id)
    quantity = int(request.POST.get("quantity", 1))
    cart = Cart(request)
    try:
        cart.update(product, quantity=quantity)
    except InvalidQuantity:
        pass
    return redirect("cart_detail")


def cart_detail(request):
    cart = Cart(request)
    return render(request, "cart/detail.html", {"cart": cart})


def cart_checkout(request):
    cart = Cart(request)
    # … process payment …
    cart.checkout()
    return redirect("order_complete")

2. URL configuration

# urls.py
from django.urls import path
from shop import views

urlpatterns = [
    path("cart/", views.cart_detail, name="cart_detail"),
    path("cart/add/<int:product_id>/", views.cart_add, name="cart_add"),
    path("cart/remove/<int:product_id>/", views.cart_remove, name="cart_remove"),
    path("cart/update/<int:product_id>/", views.cart_update, name="cart_update"),
    path("cart/checkout/", views.cart_checkout, name="cart_checkout"),
]

Cart API Reference

from cart.cart import Cart, ItemDoesNotExist, InvalidQuantity

cart = Cart(request)
Method / Property Description
cart.add(product, unit_price, quantity=1) Add a product. If already present, increments quantity and updates price. Raises InvalidQuantity if quantity < 1.
cart.remove(product) Remove a product entirely. Raises ItemDoesNotExist if not in cart.
cart.update(product, quantity, unit_price=None) Set exact quantity (0 removes the item). Raises ItemDoesNotExist or InvalidQuantity.
cart.count() Total number of units across all items.
cart.unique_count() Number of distinct products.
cart.summary() Grand total as Decimal.
cart.is_empty() True if the cart has no items.
cart.clear() Delete all items (keeps the cart record).
cart.checkout() Mark the cart as checked out.
cart.cart_serializable() Returns a JSON-safe dict keyed by object_id.
product in cart True if the product is in the cart (__contains__).
len(cart) Equivalent to cart.count().
for item in cart Iterate over Item instances.

Each Item exposes:

item.product        # the product instance (via generic FK)
item.quantity       # int
item.unit_price     # Decimal
item.total_price    # Decimal  (quantity × unit_price)

Template Example

{# templates/cart/detail.html #}
{% extends "base.html" %}

{% block content %}
<h1>Your Cart</h1>

{% if cart.is_empty %}
  <p>Your cart is empty.</p>
{% else %}
  <table>
    <thead>
      <tr>
        <th>Product</th>
        <th>Unit Price</th>
        <th>Qty</th>
        <th>Total</th>
        <th></th>
      </tr>
    </thead>
    <tbody>
      {% for item in cart %}
      <tr>
        <td>{{ item.product.name }}</td>
        <td>{{ item.unit_price }}</td>
        <td>{{ item.quantity }}</td>
        <td>{{ item.total_price }}</td>
        <td>
          <a href="{% url 'cart_remove' item.product.pk %}">Remove</a>
        </td>
      </tr>
      {% endfor %}
    </tbody>
    <tfoot>
      <tr>
        <td colspan="3"><strong>Total</strong></td>
        <td colspan="2"><strong>{{ cart.summary }}</strong></td>
      </tr>
    </tfoot>
  </table>

  <a href="{% url 'cart_checkout' %}">Proceed to Checkout</a>
{% endif %}
{% endblock %}

Django Signals

django-cart emits Django signals at key cart events, enabling easy integration with analytics, notifications, and custom logic.

Available Signals

Signal Description
cart_item_added Emitted when an item is added to the cart
cart_item_removed Emitted when an item is removed from the cart
cart_item_updated Emitted when an item quantity is updated
cart_checked_out Emitted when checkout is completed
cart_cleared Emitted when the cart is cleared

Signal Payloads

All signals provide the same sender (Cart instance) and keyword arguments:

  • cart: The cart instance
  • product: The product instance
  • quantity: The quantity involved
  • unit_price: The unit price
  • total_price: The total price for the item

Example: Connect a Signal Handler

# signals.py
from django.dispatch import receiver
from cart.signals import cart_item_added, cart_item_removed, cart_checked_out


@receiver(cart_item_added)
def log_cart_addition(sender, cart, product, quantity, unit_price, total_price, **kwargs):
    print(f"Added {quantity}x {product} to cart (total: {total_price})")
    # Track analytics, send notifications, etc.


@receiver(cart_item_removed)
def log_cart_removal(sender, cart, product, **kwargs):
    print(f"Removed {product} from cart")


@receiver(cart_checked_out)
def handle_checkout(sender, cart, **kwargs):
    print(f"Cart {cart.id} checked out with total: {cart.summary}")
    # Trigger order processing, send confirmation email, etc.

Connecting Signals

In your Django app's ready method:

# myapp/apps.py
from django.apps import AppConfig


class MyAppConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "myapp"

    def ready(self):
        import myapp.signals  # noqa: F401

Signals Are Optional

The cart works perfectly fine without signals. If the signals module is not available, cart operations continue without errors.


Template Tags

django-cart provides template tags for easy cart display in your templates.

Loading Template Tags

{% load cart_tags %}

Available Tags

cart_item_count

Returns the total number of items in the cart:

<p>Items in cart: {% cart_item_count request %}</p>

cart_summary

Returns the grand total of the cart:

<p>Total: {% cart_summary request %}</p>

cart_is_empty

Returns True if the cart is empty:

{% if cart_is_empty request %}
    <p>Your cart is empty</p>
{% else %}
    <a href="{% url 'cart_detail' %}">View Cart</a>
{% endif %}

cart_link

Renders a link to the cart with optional CSS class and text:

{# Basic usage #}
{% cart_link request %}

{# With CSS class #}
{% cart_link request "btn btn-primary" %}

{# With custom text and CSS class #}
{% cart_link request "btn" "View Shopping Cart" %}

Session Adapters

django-cart uses session adapters to store the cart ID in the session. You can use the built-in adapters or create custom ones.

Available Adapters

Adapter Description
DjangoSessionAdapter Stores cart ID in Django's default session backend
CookieSessionAdapter Stores cart ID in a signed cookie (no server-side session required)

Using CookieSessionAdapter

# settings.py
from cart.session import CookieSessionAdapter

CARTS_SESSION_ADAPTER_CLASS = CookieSessionAdapter

Custom Session Adapter

Create your own adapter by subclassing CartSessionAdapter:

# myapp/session.py
from cart.session import CartSessionAdapter


class RedisSessionAdapter(CartSessionAdapter):
    """Store cart ID in Redis instead of Django sessions."""

    def __init__(self, request):
        super().__init__(request)
        import redis
        self.redis = redis.Redis(host="localhost", port=6379)

    def _get_session_key(self):
        return f"cart:{self.request.session.session_key}"

    def _set_session_key(self, value):
        self.redis.set(self._get_session_key(), value)

    def _del_session_key(self):
        self.redis.delete(self._get_session_key())

Then configure it in settings:

# settings.py
CARTS_SESSION_ADAPTER_CLASS = "myapp.session.RedisSessionAdapter"

CartSessionAdapter API

All adapters must implement these methods:

Method Description
get() Get the cart ID from storage
set(cart_id) Store the cart ID
delete() Remove the cart ID
get_or_create_cart_id() Get existing or create new cart ID
cart_id (property) Get or set cart ID via property

Cleaning Old Carts

Over time, abandoned sessions leave orphaned Cart rows in your database. The clean_carts management command removes them.

Basic usage

Delete all unchecked-out carts older than 90 days (the default):

python manage.py clean_carts

Custom retention window

Delete abandoned carts older than 30 days:

python manage.py clean_carts --days 30

Include checked-out carts

Remove all carts older than 60 days, including those that were checked out:

python manage.py clean_carts --days 60 --include-checked-out

Dry run

Preview what would be deleted without actually deleting anything:

python manage.py clean_carts --days 30 --dry-run
# [DRY RUN] Would delete 142 cart(s) older than 30 day(s).

All options

Flag Default Description
--days N 90 Delete carts older than N days
--include-checked-out off Also delete checked-out carts
--dry-run off Preview only — no deletions

Scheduling with Cron

Run clean_carts automatically so your database stays clean without manual intervention.

Standard crontab

Open your crontab:

crontab -e

Add a line. For example, to run every day at 2:00 AM and delete carts older than 30 days:

0 2 * * * /path/to/venv/bin/python /path/to/project/manage.py clean_carts --days 30 >> /var/log/clean_carts.log 2>&1

Replace /path/to/venv and /path/to/project with the actual paths on your server.

With environment variables

If your Django project needs environment variables (e.g. DATABASE_URL), load them before calling the command:

0 2 * * * /bin/bash -c 'source /etc/environment && /path/to/venv/bin/python /path/to/project/manage.py clean_carts --days 30' >> /var/log/clean_carts.log 2>&1

Using a Makefile target (optional convenience)

.PHONY: clean-carts
clean-carts:
    python manage.py clean_carts --days 30

Django management commands from Celery (alternative)

If you already use Celery Beat for periodic tasks you can call the command from a task instead:

# tasks.py
from celery import shared_task
from django.core.management import call_command

@shared_task
def clean_old_carts():
    call_command("clean_carts", days=30)

Running the Tests

Install the development dependencies:

pip install django

Run all tests with the standalone runner:

python runtests.py

Run a specific test class:

python runtests.py tests.test_cart.CartAddTest

The test suite covers:

  • CartModel — creation, ordering, defaults
  • ItemManager — filter/get by product instance
  • Item model — total_price, unique_together constraint, unit price validation
  • Cart class — all public methods (success, error, and edge cases)
  • Cart class — atomic operations, session persistence, serialization
  • clean_carts command — deletion, dry-run, boundary conditions, cascade behaviour
  • Integration tests — session handling, cart operations, serialization
  • Performance benchmarks — add, summary, and iteration timing
  • Admin operations — changelist, search, and filtering
  • Signals — cart_item_added, cart_item_removed, cart_item_updated, cart_checked_out, cart_cleared
  • Template tags — cart_item_count, cart_summary, cart_is_empty, cart_link
  • Session adapters — DjangoSessionAdapter, CookieSessionAdapter

Running Code Coverage

Install coverage tool:

pip install coverage

Run tests with coverage:

coverage run runtests.py

Generate a coverage report in the terminal:

coverage report

Generate an HTML coverage report (results saved to htmlcov/):

coverage html

Open the HTML report in your browser:

open htmlcov/index.html  # macOS
# or
xdg-open htmlcov/index.html  # Linux
# or
start htmlcov/index.html  # Windows

Developer Setup

Pre-commit Hooks

This project uses pre-commit hooks to maintain code quality. Install them with:

pip install pre-commit
pre-commit install

The hooks include:

  • black — Code formatting
  • isort — Import sorting
  • flake8 — Linting
  • mypy — Type checking

Automated Dependencies

This project uses Dependabot for automated dependency updates:

  • Python packages (weekly schedule)
  • GitHub Actions (weekly schedule)

Performance Considerations

The cart uses product caching to avoid N+1 queries when iterating over items. The Item.product property caches the product instance on first access.

# Efficient - single query per item, then cached
for item in cart:
    print(item.product.name)  # Cached after first access

Serialization

The cart_serializable() method returns a JSON-safe dictionary for API responses:

cart = Cart(request)
data = cart.cart_serializable()
# Returns: {'123': {'quantity': 2, 'unit_price': '9.99', 'total_price': '19.98'}, ...}

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_cart-2.5.1.tar.gz (33.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_cart-2.5.1-py3-none-any.whl (19.9 kB view details)

Uploaded Python 3

File details

Details for the file django_cart-2.5.1.tar.gz.

File metadata

  • Download URL: django_cart-2.5.1.tar.gz
  • Upload date:
  • Size: 33.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.13

File hashes

Hashes for django_cart-2.5.1.tar.gz
Algorithm Hash digest
SHA256 a349c0fb41c71f7903cb4c84fec81e6d9536f143f5e8e5ccada43366414d6e38
MD5 26a309fda2b6b17af3040ac53d012bb8
BLAKE2b-256 e3f17b98acbe0f24c2e6e8b9cd3ab9b5d8c5b21a06b89ecbbf975c1485fbd1f3

See more details on using hashes here.

File details

Details for the file django_cart-2.5.1-py3-none-any.whl.

File metadata

  • Download URL: django_cart-2.5.1-py3-none-any.whl
  • Upload date:
  • Size: 19.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.13

File hashes

Hashes for django_cart-2.5.1-py3-none-any.whl
Algorithm Hash digest
SHA256 34fb1e54053bddeda82918bef65972b33a040f041e23d93c8ecd00dd6b832ea3
MD5 55cdfaebff54934f1f91bcb06227ddff
BLAKE2b-256 0a79a206b1b6be73d754849630391ed8b4011fdd62c06ddd25e1b30dc310992e

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page