A simple session-backed shopping cart for modern Django.
Project description
django-cart
A lightweight, session-backed shopping cart for Django. One thin
Cart facade over one database row, extended through explicit
pluggable subsystems for tax, shipping, inventory, and session
storage.
[!tip] Why reach for django-cart
- Prototype-fast. A working cart in three lines of view code and a
pip install. No schema changes to your product models.- Small-store friendly. Discounts, tax, and shipping ship in-box and plug in through one setting each. Scale out later by swapping the session adapter.
- Agent-ready. The public API fits in one context window. Coding agents (Claude, Cursor, Copilot) can generate correct extensions on the first pass — see
docs/AGENTS.md.
graph TB
classDef facade fill:#1f2937,stroke:#60a5fa,color:#f9fafb,stroke-width:2px
classDef plug fill:#0f172a,stroke:#a78bfa,color:#e2e8f0,stroke-dasharray:4 2
classDef store fill:#0b1220,stroke:#34d399,color:#d1fae5
classDef opt fill:#0b1220,stroke:#f59e0b,color:#fef3c7,stroke-dasharray:2 2
View["Django view<br>or DRF endpoint"]
Cart["Cart facade<br>cart.cart.Cart"]:::facade
Session["Session Adapter<br>CART_SESSION_ADAPTER"]:::store
DB[("Cart, Item, Discount<br>database rows")]:::store
Tax["Tax Calculator<br>CART_TAX_CALCULATOR"]:::plug
Ship["Shipping Calculator<br>CART_SHIPPING_CALCULATOR"]:::plug
Inv["Inventory Checker<br>CART_INVENTORY_CHECKER"]:::plug
Signals["Django signals<br>cart_item_added, ..."]:::opt
Tags["Template tags<br>cart_item_count, ..."]:::opt
View --> Cart
Cart --> Session
Cart --> DB
Cart -.->|plug| Tax
Cart -.->|plug| Ship
Cart -.->|plug| Inv
Cart -.->|emit| Signals
View -.->|render| Tags
Contents
- Installation
- Quick Start
- Core Concepts
- Using the Cart
- Discounts
- Pluggable Subsystems
- Session Storage
- Signals
- Template Tags
- Settings Reference
- Admin Integration
- Operations
- Agent-Ready
- Data Model
- Testing
- Requirements
- Changelog, Roadmap, License
Installation
pip install django-cart
Add to INSTALLED_APPS and run migrations. The library ships five
migrations; no product-model changes are required.
# settings.py
INSTALLED_APPS = [
# ...
"django.contrib.contenttypes", # already default in Django
"cart",
]
python manage.py migrate cart
[!note] About
uvThe maintainer usesuvfor local development of this library. Downstream projects can use any installer (pip,poetry,pipenv,uv,rye). The public API is installer-agnostic.
Quick Start
A complete add-to-cart flow is three view functions and a template.
# views.py
from decimal import Decimal
from django.shortcuts import get_object_or_404, redirect, render
from cart.cart import Cart
from shop.models import Product
def cart_add(request, product_id):
product = get_object_or_404(Product, pk=product_id)
Cart(request).add(product, unit_price=product.price, quantity=1)
return redirect("cart_detail")
def cart_remove(request, product_id):
product = get_object_or_404(Product, pk=product_id)
Cart(request).remove(product)
return redirect("cart_detail")
def cart_detail(request):
return render(request, "cart/detail.html", {"cart": Cart(request)})
{# cart/detail.html #}
{% load cart_tags %}
{% if cart.is_empty %}
<p>Your cart is empty.</p>
{% else %}
<ul>
{% for item in cart %}
<li>{{ item.quantity }} × {{ item.product }} —
{{ item.total_price }}</li>
{% endfor %}
</ul>
<p><strong>Total: {{ cart.summary }}</strong></p>
{% endif %}
What just happened:
Cart(request)looked upCART-IDin the current session. It did not find one, so it created a newcart.models.Cartrow and wrote the row id back into the session..add(...)created acart.models.Itembound to the cart, with a generic foreign key to yourProduct— no changes required to theProductmodel itself.- In the template, iterating
cartwalks items with products prefetched;cart.summaryreturns aDecimalsum of allquantity × unit_price.
Core Concepts
The Cart facade
cart.cart.Cart is a thin wrapper around a single
cart.models.Cart row associated with the current session. The
class holds two pieces of state:
self.cart— the DB row.self._cache— an in-memory cache forcount()andsummary(), invalidated on every mutation.
No hidden threads, no background work, no module-level registry.
Items and generic foreign keys
Each Item references its product through
(content_type, object_id) — Django's
contenttypes framework.
This is what lets django-cart work with any product model
without schema changes.
cart.add(coffee_bean, Decimal("12.00")) # Coffee model
cart.add(digital_course, Decimal("99.00")) # Course model
# Same cart, different product classes.
Session-backed lifecycle
flowchart LR
classDef state fill:#0b1220,stroke:#60a5fa,color:#e2e8f0
classDef money fill:#0b1220,stroke:#34d399,color:#d1fae5
classDef final fill:#1f2937,stroke:#a78bfa,color:#f9fafb,stroke-width:2px
Empty["Cart(request)<br>empty"]:::state
Add["add(product, price)"]:::state
Inspect["summary / count /<br>is_empty / iteration"]:::state
Discount["apply_discount(code)"]:::money
Math["tax() + shipping() −<br>discount_amount() → total()"]:::money
Checkout["checkout()<br>atomic, idempotent"]:::final
Empty --> Add
Add --> Inspect
Inspect --> Discount
Discount --> Math
Math --> Checkout
The only thing that lives in the HTTP session is the integer cart
id. Everything else is a database row. A cart survives page loads
but does not become an order — that is your checkout flow's
responsibility. checkout() simply marks the cart
checked_out=True and (if a discount was applied) bumps its usage
counter atomically.
Using the Cart
Adding, updating, removing
from cart.cart import Cart, InvalidQuantity, ItemDoesNotExist
cart = Cart(request)
item = cart.add(product, unit_price=Decimal("12.00"), quantity=2)
cart.update(product, quantity=5) # new quantity (0 removes)
cart.update(product, quantity=3, unit_price=Decimal("9.99"))
cart.remove(product) # raises ItemDoesNotExist
cart.clear() # empties everything
All mutations wrap the DB work in transaction.atomic() and
invalidate the internal summary cache on success.
[!warning] Concurrency
add()/update()/merge()are transactional but do not hold row locks during their read phase. Under concurrent requests on Postgres or MySQL, two workers readingquantity=Ncan both writeN+q, clobbering one of the adds. For code paths that must be concurrent-safe, wrap the mutation in your ownselect_for_update()block or serialise upstream (idempotency keys, queue-per-cart, etc.).checkout()already locks theCartrow and (when a discount is applied) theDiscountrow viaselect_for_update()— use it as a template.
Bulk operations
Add or update many items at once, inside a single transaction:
cart.add_bulk([
{"product": p1, "unit_price": Decimal("10.00"), "quantity": 2},
{"product": p2, "unit_price": Decimal("20.00"), "quantity": 1},
])
Iteration and introspection
len(cart) # int — total units (alias of cart.count())
cart.count() # int — same as len(cart)
cart.unique_count() # int — number of distinct products
cart.is_empty() # bool
product in cart # bool — uses __contains__
for item in cart: ... # iterates items, content_type preloaded
Each item exposes .product, .quantity, .unit_price,
.total_price (a Decimal property).
[!tip] Avoid the N+1 when rendering line-item tables Plain iteration (
for item in cart) leavesitem.productas a lazy property — touching.producton each item in a template issues one SELECT per item. For render paths that read the concrete product (name, image, SKU), usecart.items_with_products():for item in cart.items_with_products(): # item.product is already prefetched — zero extra queries. ...Batches products by content type under the hood: one SELECT on
Itemplus onein_bulkper distinct product model. A 100-item cart split across 3 product models drops from ~100 queries to 4.
Money math
cart.summary() # Decimal — Σ quantity × unit_price
cart.tax() # Decimal — uses configured TaxCalculator
cart.shipping() # Decimal — uses configured ShippingCalculator
cart.discount_amount() # Decimal — 0.00 if no discount applied
cart.total() # Decimal — summary − discount + tax + shipping
summary() and count() are cached on the Cart instance and
invalidated on every mutation. tax() / shipping() call out to
the configured calculators on each call — if they are expensive,
memoise on the calculator side.
User binding and merging
Bind a guest cart to a user on login, and merge the guest cart into any pre-existing user cart:
from cart.cart import Cart
def on_login(request):
guest = Cart(request) # the current session cart
prior = Cart.get_active_user_carts(request.user).first()
if prior is None:
guest.bind_to_user(request.user)
return
user_cart = Cart(request)
user_cart.cart = prior
user_cart.merge(guest, strategy="add") # or "replace" / "keep_higher"
[!note]
get_user_carts()vsget_active_user_carts()Useget_active_user_carts(user)for login-merge flows — it pre-filterschecked_out=False, so a forgotten.filter(…)can't silently resurrect items from a past order. Useget_user_carts(user)when you genuinely need every cart the user has ever had (order history, admin dashboards).
Available merge strategies:
| Strategy | Result per product |
|---|---|
add (default) |
quantity = old + new |
replace |
quantity = new |
keep_higher |
quantity = max(old, new) |
Serialisation
Freeze a cart to a JSON-safe dict and restore it later — useful for cross-device sync or for passing through an API:
payload = cart.cart_serializable()
# {"7:42": {"content_type_id": 7, "object_id": 42, "quantity": 2,
# "unit_price": "9.99", "total_price": "19.98"}, ...}
# ...later, possibly in a different request or worker...
restored = Cart.from_serializable(new_request, payload)
[!important] Keys are
"<content_type_id>:<object_id>"v3.0.13 changed the payload key shape from the barestr(object_id)to a composite"content_type_id:object_id"so two products with the same primary key across different content types no longer collide.from_serializable()accepts both formats — payloads stored before v3.0.13 keep working as long as each value carriescontent_type_id. Consumers that iterated keys to pullobject_idshould switch to reading it from the value (it's now emitted explicitly).
Checkout
from cart.cart import CartException, InvalidDiscountError, MinimumOrderNotMet
try:
cart.checkout()
except CartException as e:
# Empty cart.
return render(request, "cart/detail.html", {"cart": cart, "error": str(e)})
except MinimumOrderNotMet as e:
# Subtotal below CART_MIN_ORDER_AMOUNT.
return render(request, "cart/detail.html", {"cart": cart, "error": str(e)})
except InvalidDiscountError as e:
# A discount was applied earlier but is no longer valid
# (expired, deactivated, or max_uses reached between apply
# and checkout). The whole operation rolled back — the cart
# is still open, the user can remove the discount and retry.
...
checkout() is:
- Validated. Enforces
can_checkout()before touching the DB (v3.1.0). An empty cart raisesCartException; a cart belowsettings.CART_MIN_ORDER_AMOUNTraisesMinimumOrderNotMet. Pre-v3.1 both were silent no-ops — downstream code had to callcan_checkout()explicitly. - Atomic. Marks the cart checked-out and (if a discount is
applied) increments
Discount.current_usesin the same transaction. - Race-safe. Takes a
SELECT … FOR UPDATEon theCartrow first, then (when a discount is applied) on theDiscountrow. Two concurrent checkouts of the same cart produce exactly one counter increment; two concurrent checkouts of the last remaining use of a discount code result in one success and oneInvalidDiscountError. - Idempotent across facades. Calling
checkout()twice on the same cart — even from separateCart(request)instances or workers with stale in-memory state — is a no-op on the second call. No second counter bump, no duplicatecart_checked_outsignal.
[!note]
checkout()does not reserve inventory Stock reservation is the consuming project's responsibility. TheInventoryCheckerinterface has areserve()method you can call from your own checkout flow; the library's built-incheckout()does not call it. This is deliberate — reservation semantics (timeout, release on failed payment, retry) vary too much per project to bake a default.
Exceptions
All cart exceptions subclass cart.cart.CartException.
| Exception | Raised when |
|---|---|
InvalidQuantity |
quantity < 1 on add, < 0 on update, or > CART_MAX_QUANTITY_PER_ITEM |
ItemDoesNotExist |
remove() / update() called for a product not in the cart |
PriceMismatchError |
validate_price=True and unit_price != product.price |
InsufficientStock |
check_inventory=True and the configured InventoryChecker.check() returns False |
InvalidDiscountError |
bad code, already-applied discount, failed validity check, revalidation failure at checkout |
MinimumOrderNotMet |
raised by checkout() when cart.summary() < settings.CART_MIN_ORDER_AMOUNT (v3.1.0+) |
Discounts
Discount codes are first-class. The Discount model supports
percentage and fixed-amount discounts, validity windows, usage caps,
and minimum cart values.
from decimal import Decimal
from cart.models import Discount, DiscountType
Discount.objects.create(
code="SUMMER25",
discount_type=DiscountType.PERCENT,
value=Decimal("25.00"),
min_cart_value=Decimal("50.00"),
max_uses=500,
valid_from=start_date,
valid_until=end_date,
)
Apply, inspect, remove:
from cart.cart import InvalidDiscountError
try:
cart.apply_discount("SUMMER25")
except InvalidDiscountError as e:
print(f"Cannot apply: {e}")
cart.discount_code() # "SUMMER25"
cart.discount_amount() # Decimal — computed against cart.summary()
cart.remove_discount()
How usage caps are enforced
sequenceDiagram
participant V as View
participant C as Cart
participant DB as Database
V->>C: apply_discount("SUMMER25")
C->>DB: Discount.objects.get(code=...)
C->>C: discount.is_valid_for_cart(self)
C->>DB: cart.discount = discount
Note over V,DB: Some time passes — user browses, adds items,<br>another user may also be checking out the last use.
V->>C: checkout()
C->>DB: BEGIN
C->>DB: SELECT ... FROM Cart WHERE pk=? FOR UPDATE
alt cart already checked_out<br>(another facade won the race)
C->>DB: COMMIT
C-->>V: ok (no-op)
else proceed
C->>DB: SELECT ... FROM Discount WHERE pk=? FOR UPDATE
C->>C: discount.is_valid_for_cart(self)
alt still valid
C->>DB: UPDATE Discount SET current_uses = current_uses + 1
C->>DB: UPDATE Cart SET checked_out = true
C->>DB: COMMIT
C-->>V: ok
else no longer valid<br>(expired, deactivated, cap reached)
C->>DB: ROLLBACK
C-->>V: raises InvalidDiscountError
end
end
Two concurrent checkouts of the last remaining use of a discount
code result in exactly one success and exactly one
InvalidDiscountError. The counter is never exceeded.
Pluggable Subsystems
Tax, shipping, and inventory checking each follow the same shape: an abstract base class, a no-op default, and a factory that reads a dotted path from settings.
cart/<subsystem>.py:
class <Subsystem>Base(ABC): # your subclass inherits from this
class Default<Subsystem>(Base): # safe no-op default
def get_<subsystem>() -> Base: # factory, reads settings
[!warning] Fallback on misconfiguration is a
RuntimeWarningA bad dotted path inCART_TAX_CALCULATOR,CART_SHIPPING_CALCULATOR, orCART_INVENTORY_CHECKERfalls back to the default implementation rather than raising — but each factory now emits aRuntimeWarningnaming the setting, the bad path, and the underlyingImportError/AttributeError. Promote those to errors in dev withpython -W error::RuntimeWarningor Django's logging config. The session adapter factory is still the strict exception — it raisesImportErrorloudly.
Tax
# settings.py
CART_TAX_CALCULATOR = "myapp.tax.FlatRateTax"
# myapp/tax.py
from decimal import Decimal
from cart.tax import TaxCalculator
from cart.cart import Cart
class FlatRateTax(TaxCalculator):
def calculate(self, cart: Cart) -> Decimal:
return cart.summary() * Decimal("0.08")
Usage:
cart.tax() # → Decimal
The default (DefaultTaxCalculator) always returns
Decimal("0.00").
Shipping
ShippingCalculator has two methods: calculate(cart) for the
total cost, and get_options(cart) for the UI to show the user a
list of choices.
# settings.py
CART_SHIPPING_CALCULATOR = "myapp.shipping.FlatRateShipping"
# myapp/shipping.py
from decimal import Decimal
from cart.shipping import ShippingCalculator, ShippingOption
from cart.cart import Cart
class FlatRateShipping(ShippingCalculator):
def calculate(self, cart: Cart) -> Decimal:
return Decimal("0.00") if cart.summary() >= 100 else Decimal("9.99")
def get_options(self, cart: Cart) -> list[ShippingOption]:
return [
ShippingOption(id="standard", name="Standard (3–5 days)",
price=str(self.calculate(cart))),
ShippingOption(id="express", name="Express (next-day)",
price="19.99"),
]
cart.shipping() # → Decimal
cart.shipping_options() # → list[dict]
Inventory
Opt-in per-call with check_inventory=True:
# settings.py
CART_INVENTORY_CHECKER = "myapp.inventory.StockChecker"
# myapp/inventory.py
from cart.inventory import InventoryChecker
class StockChecker(InventoryChecker):
def check(self, product, quantity: int) -> bool:
return product.stock >= quantity
def reserve(self, product, quantity: int) -> bool:
# Atomic decrement; use F() in real code.
if product.stock < quantity:
return False
product.stock -= quantity
product.save(update_fields=["stock"])
return True
from cart.cart import InsufficientStock
try:
cart.add(product, unit_price=p.price, quantity=5, check_inventory=True)
except InsufficientStock:
return HttpResponseBadRequest("Not enough stock")
The default (DefaultInventoryChecker) always returns True.
reserve() is a method you call from your own checkout flow; the
library's checkout() does not call it — see the Checkout
note above.
Session Storage
The only state the library puts in the HTTP session is the integer
CART-ID. Everything else is a database row. Which backend holds
that integer is configurable via a single setting.
flowchart LR
classDef q fill:#0b1220,stroke:#f59e0b,color:#fef3c7
classDef a fill:#1f2937,stroke:#60a5fa,color:#f9fafb
Q1{"Using Django's<br>session framework?"}:::q
Q2{"Fully stateless<br>(no server session)?"}:::q
Q3{"Distributed cache<br>or JWT required?"}:::q
D["DjangoSessionAdapter<br>(default)"]:::a
C["CookieSessionAdapter"]:::a
X["Custom adapter<br>(Redis, JWT, Dynamo, ...)"]:::a
Q1 -->|Yes| D
Q1 -->|No| Q2
Q2 -->|Yes| C
Q2 -->|No| Q3
Q3 -->|Yes| X
Q3 -->|No| D
Built-in adapters
| Adapter | Use case |
|---|---|
DjangoSessionAdapter (default) |
Standard Django sessions (DB, cache, signed cookies — any SESSION_ENGINE). |
CookieSessionAdapter |
Fully stateless: stores the cart id in an HTTP cookie, reads it back on the next request. |
Selecting an adapter
# settings.py — dotted string
CART_SESSION_ADAPTER = "cart.session.CookieSessionAdapter"
# or — class object
from cart.session import CookieSessionAdapter
CART_SESSION_ADAPTER = CookieSessionAdapter
Both forms work. A typo in the dotted path raises ImportError
loudly — this is the one subsystem factory that does not fall
back silently.
[!note] Migrating from
CARTS_SESSION_ADAPTER_CLASSv3.1.0 renamed this setting from the pluralCARTS_SESSION_ADAPTER_CLASSto the singularCART_SESSION_ADAPTERfor symmetry with the otherCART_*settings. The legacy name is still honoured but emits aDeprecationWarningand will be removed in v4.0. If both are set, the new singular setting wins.
[!important]
CookieSessionAdapter(and any custom cookie-backed adapter) also requiresCartCookieMiddlewareinMIDDLEWAREso pending cookies are written to the response.DjangoSessionAdapter(the default) does not need this — Django'sSessionMiddlewarehandles it.MIDDLEWARE = [ # ... existing middleware ... "cart.middleware.CartCookieMiddleware", ]
Custom adapter
Subclass CartSessionAdapter and implement its five abstract
methods. The real interface is get / set / delete /
get_or_create_cart_id / set_cart_id — each documented on the
base class.
# myapp/session.py
from typing import Any
from cart.session import CartSessionAdapter
from cart.cart import CART_ID
class RedisSessionAdapter(CartSessionAdapter):
def __init__(self, request):
import redis
self._r = redis.StrictRedis(host="localhost", port=6379, db=0)
self._key = f"cart:{request.session.session_key}"
def get(self, key: str, default: Any = None) -> Any:
value = self._r.hget(self._key, key)
return value.decode() if value else default
def set(self, key: str, value: Any) -> None:
self._r.hset(self._key, key, str(value))
def delete(self, key: str) -> None:
self._r.hdel(self._key, key)
def get_or_create_cart_id(self) -> int | None:
value = self.get(CART_ID)
try:
return int(value) if value else None
except (ValueError, TypeError):
return None
def set_cart_id(self, cart_id: int) -> None:
self.set(CART_ID, cart_id)
# settings.py
CART_SESSION_ADAPTER = "myapp.session.RedisSessionAdapter"
See docs/AGENTS.md for a prompt-ready version
of this pattern.
Signals
Five optional signals let you observe cart events without
monkey-patching. Importing cart.signals is not required — if the
module is missing at import time, the cart still works and no
signals fire.
| Signal | Payload (kwargs) |
Fired by |
|---|---|---|
cart_item_added |
cart, item |
Cart.add() on success |
cart_item_removed |
cart, product |
Cart.remove() on success |
cart_item_updated |
cart, item, deleted (bool) |
Cart.update() on success |
cart_checked_out |
cart |
Cart.checkout() — only once per cart |
cart_cleared |
cart |
Cart.clear() on success |
Wire handlers in your app's ready():
# myapp/apps.py
from django.apps import AppConfig
class MyAppConfig(AppConfig):
name = "myapp"
def ready(self):
import myapp.signals # noqa: F401
# myapp/signals.py
from django.dispatch import receiver
from cart.signals import cart_item_added, cart_checked_out
@receiver(cart_item_added)
def record_add(sender, cart, item, **kwargs):
# Analytics, audit log, inventory decrement, etc.
...
@receiver(cart_checked_out)
def send_confirmation(sender, cart, **kwargs):
...
Template Tags
Load once per template, then use in any context that has request
(the default Django context processor makes this automatic if
django.template.context_processors.request is in TEMPLATES → OPTIONS → context_processors).
{% load cart_tags %}
| Tag | Signature | Returns |
|---|---|---|
{% cart_item_count %} |
no arguments | integer |
{% cart_summary %} |
no arguments | formatted string, e.g. $19.98 |
{% cart_is_empty %} |
no arguments | boolean |
{% cart_link "Label" "css-class" %} |
text, css_class — both optional |
HTML <a> tag |
All four tags declare takes_context=True and read request from
the template context. Do not pass request positionally.
[!note] Tags do not create abandoned cart rows The three read-only tags (
cart_item_count,cart_summary,cart_is_empty) query the cart directly from the DB when the session already carries aCART-ID, and return defaults otherwise.cart_linknever queries the cart at all. Loading any of these in a site-wide header is safe to serve to crawlers, bots, and pre-login visitors — none of them will materialise a DB row.
[!tip] Route
cart_linkthrough your own URL conf SetCART_DETAIL_URL_NAMEto a URL name you've defined, andcart_linkwill resolve the anchor viareverse():# settings.py CART_DETAIL_URL_NAME = "cart_detail"With the setting absent — or the URL name unresolvable — the tag falls back to a static
/cart/. The cart's integer primary key is never embedded in the URL.
Example header snippet:
{% load cart_tags %}
<header>
<nav>
<a href="/">Shop</a>
{% cart_link "Cart" "nav-btn" %}
<span class="badge">{% cart_item_count %}</span>
<span class="total">{% cart_summary %}</span>
</nav>
</header>
Capture a tag's return value with as:
{% cart_item_count as count %}
{% if count %}
<span class="badge">{{ count }}</span>
{% endif %}
Settings Reference
All settings are optional. Defaults apply when a setting is absent
or None.
| Setting | Type | Default | Purpose |
|---|---|---|---|
CART_TAX_CALCULATOR |
dotted path or class | DefaultTaxCalculator → Decimal("0.00") |
Tax calculator class. See Tax. |
CART_SHIPPING_CALCULATOR |
dotted path or class | DefaultShippingCalculator → Decimal("0.00"), one "free" option |
Shipping calculator class. See Shipping. |
CART_INVENTORY_CHECKER |
dotted path or class | DefaultInventoryChecker → always True |
Inventory checker class. See Inventory. |
CART_SESSION_ADAPTER |
dotted path or class | DjangoSessionAdapter |
Where the integer cart id is stored. See Session Storage. The legacy CARTS_SESSION_ADAPTER_CLASS is still honoured with a DeprecationWarning through v3.x. |
CART_MAX_QUANTITY_PER_ITEM |
int or None |
None (unlimited) |
Cap on item.quantity. Exceeding raises InvalidQuantity. |
CART_MIN_ORDER_AMOUNT |
Decimal or None |
None (no minimum) |
Minimum cart.summary() required for can_checkout() to return True. |
CART_DETAIL_URL_NAME |
str or None |
None |
URL name passed to reverse() by the {% cart_link %} template tag. Falls back to a static /cart/ when unset or unresolvable. See Template Tags. |
Admin Integration
cart/admin.py registers Cart with an inline Item editor.
Item is visible through the cart edit page — not as a top-level
model — which matches how you typically want to inspect a cart.
[!note]
Discountis not registered TheDiscountmodel is intentionally not registered in the library's admin, because a storefront often wants custom admin views (bulk CSV import, per-campaign grouping, voucher generators). Register it yourself:# myapp/admin.py from django.contrib import admin from cart.models import Discount @admin.register(Discount) class DiscountAdmin(admin.ModelAdmin): list_display = ("code", "discount_type", "value", "current_uses", "max_uses", "active", "valid_until") list_filter = ("active", "discount_type") search_fields = ("code",) readonly_fields = ("current_uses",)
Operations
Pruning abandoned carts
Abandoned carts accumulate. The clean_carts management command
removes them:
python manage.py clean_carts # default: unchecked-out, >90 days old
python manage.py clean_carts --days 30 # custom retention
python manage.py clean_carts --days 30 --dry-run
python manage.py clean_carts --days 60 --include-checked-out
Schedule with cron:
# Nightly at 02:00
0 2 * * * /path/to/venv/bin/python /path/to/project/manage.py clean_carts --days 30
Or Celery:
# myapp/tasks.py
from celery import shared_task
from django.core.management import call_command
@shared_task
def prune_abandoned_carts():
call_command("clean_carts", days=30)
Agent-Ready
django-cart is designed to be extended by coding agents on the
first pass. Three properties make that possible:
- Small surface. The public API is under 1000 lines across four files and fits entirely in a single agent context window.
- Explicit extension points. Every subsystem is a settings dotted-path pointing at a subclass of a clearly-typed abstract base. No registries, no decorators, no magic.
- Stable contracts. Public names are preserved across patch and minor releases. The same prompt that works today works on the next minor.
A minimum working example — generating a custom tax calculator with Claude:
Prompt:
In my Django project I use django-cart. Generate a TaxCalculator
subclass that applies 7.25% tax if the cart's `summary()` is
above $100 and 5% otherwise. Wire it in through settings and
write a pytest that asserts both branches.
Expected output:
- myapp/tax.py with `class Tiered(TaxCalculator)` returning Decimal
- settings.py with CART_TAX_CALCULATOR set to its dotted path
- tests/test_tax.py with two test functions exercising both branches
For the full agentic extension guide — prompt templates, review
checklist, sharp edges, verification steps — see
docs/AGENTS.md.
Data Model
Three models, one generic FK, a handful of indexes.
erDiagram
Cart ||--o{ Item : "has many"
Cart }o--|| Discount : "optional FK (nullable)"
Item }o..|| PRODUCT : "GenericFK<br>content_type + object_id"
Cart {
int id PK
datetime creation_date
bool checked_out
int user_id FK "nullable"
int discount_id FK "nullable"
}
Item {
int id PK
int cart_id FK
int content_type_id FK
int object_id
int quantity "PositiveInteger"
decimal unit_price "max_digits=18, dp=2"
}
Discount {
int id PK
string code UK
string discount_type "percent or fixed"
decimal value
decimal min_cart_value "nullable"
int max_uses "nullable"
int current_uses
bool active
datetime valid_from "nullable"
datetime valid_until "nullable"
}
PRODUCT {
any_model your_existing_product "no changes required"
}
Item has unique_together on (cart, content_type, object_id)
and a composite index on the same triple — so cart.add(p) is
always one primary-key lookup.
Testing django-cart
[!note] Contributors only This section is for working on the library. Application code consuming
django-cartdoes not need any of this.
django-cart uses pytest + pytest-django exclusively — there
is no unittest.TestCase subclassing, no runtests.py. Fixtures
live in tests/conftest.py; helpers are never defined inside test
files.
uv venv
uv pip install -e ".[dev]"
uv run pytest
Run a single file or test:
uv run pytest tests/test_cart_add.py
uv run pytest tests/test_cart_add.py::test_add_new_product_stores_the_quantity
Coverage:
uv run coverage run -m pytest
uv run coverage report # advisory floor: 90% (local only)
uv run coverage html # → htmlcov/
See tests/README.md for the full test pattern,
fixture catalogue, and guidance on writing behavioural (not
reflection) tests.
Requirements
Python 3.10+, Django 4.2+.
Compatibility matrix
| Django 4.2 | Django 5.0 | Django 5.1 | Django 6.0 | |
|---|---|---|---|---|
| Python 3.10 | ✅ | ✅ | ✅ | — |
| Python 3.11 | ✅ | ✅ | ✅ | — |
| Python 3.12 | ✅ | ✅ | ✅ | ✅ |
| Python 3.13 | ✅ | ✅ | ✅ | ✅ |
| Python 3.14 | ❌ | ❌ | ❌ | ✅ |
- ✅ exercised in CI.
- ❌ unsupported — see the callout below.
- — this Python version is outside the upstream Django release's supported Python range.
[!warning] Python 3.14 requires Django 6.0+
django-cartdoes not support Python 3.14 paired with Django 4.2, 5.0, or 5.1 — and will not. The incompatibility is upstream in Django itself.Why it breaks. Django's
django.template.Context.__copy__(pre-6.0) assignsduplicate.dicts = self.dicts[:]onto a value returned bycopy(super()), i.e. asuper()proxy. Python 3.14 no longer permits attribute assignment onsuper()proxies, so any template render under Py3.14 + Django<6 raisesAttributeError: 'super' object has no attribute 'dicts' and no __dict__ for setting new attributes.Django fixed
Context.__copy__in 6.0. There is nothingdjango-cartcan patch on its side — the break is in Django's template engine, not in this library.What to do. On Python 3.14, upgrade to Django 6.0+. On earlier Django, stay on Python 3.13 or below.
What's Next
[!tip] Near-future roadmap item — high-precision decimal representation A roadmap slot is reserved for cryptocurrency-style fractional quantities — representing tiny fractions of a product (e.g. a
Coinmodel) denominated in long decimals with satoshi- or wei-level precision. The cart stays a collection of(product, quantity, unit_price)triples; only the numeric precision changes. Design doc required before implementation. Broader prioritisation lives indocs/ANALYSIS.md.This is a scope marker, not a commitment.
Changelog, Roadmap, License
- Changelog:
CHANGELOG.md— Keep-a-Changelog format. - Analysis & remediation plan:
docs/ANALYSIS.md— bug-by-bug priorities, design gaps, and the suggested per-release scope. - License: MIT. See
LICENSE. (Relicensed from LGPL-3.0 in v3.0.11 — seeCHANGELOG.md.)
Contributions welcome. The library is small on purpose — if a
feature fits the "session-backed cart" mission, open an issue or
PR. For experimental / speculative work, prefer a downstream
package that extends django-cart via its public API rather than
forking.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_cart-3.1.0.tar.gz.
File metadata
- Download URL: django_cart-3.1.0.tar.gz
- Upload date:
- Size: 101.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a7b29ce34e70d21ffb16fc7b64e94850d0a3b5c692244b445ed6627cd3af78e6
|
|
| MD5 |
629b39ec241176f0ccd70b1dab3554a3
|
|
| BLAKE2b-256 |
86d456e1d5ca856060d6705ffe51da3fdb419e5b5d544d933ea1601f80ebb690
|
File details
Details for the file django_cart-3.1.0-py3-none-any.whl.
File metadata
- Download URL: django_cart-3.1.0-py3-none-any.whl
- Upload date:
- Size: 46.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
21e0c1840f4997a578813b683d6723a379c5ec254087ecbfdc45dd9931ab6767
|
|
| MD5 |
8ebc77a1e1cbb931b36d5bf6365bcd4f
|
|
| BLAKE2b-256 |
6d1dbe97c25347c4533cdbc392dcbdb87362b22b025cabe434d16ebea40465b7
|