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>CARTS_SESSION_ADAPTER_CLASS"]:::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 usesselect_for_update()on theDiscountrow — 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).
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_user_carts(request.user).filter(
checked_out=False,
).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"
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()
# {"42": {"content_type_id": 7, "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]
content_type_idis required to restore into a fresh cart The payload emitted bycart_serializable()includescontent_type_idper item (added in v3.0.11). This letsfrom_serializable()create items in a brand-new cart. Legacy payloads without the field can still update items already present in the target cart, but attempting to create new items from them raisesValueErrorwith a clear message — never a silent no-op.
Checkout
can, message = cart.can_checkout()
if not can:
return render(request, "cart/detail.html", {"cart": cart, "error": message})
try:
cart.checkout()
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:
- Atomic. Marks the cart checked-out and (if a discount is
applied) increments
Discount.current_usesin the same transaction. - Race-safe at the discount level. Takes a
SELECT … FOR UPDATEon theDiscountrow, revalidates, and increments via anF()expression. Two concurrent checkouts of the last remaining use result in one success and oneInvalidDiscountError. - Idempotent. Calling
checkout()twice on the same cart is a no-op — no second counter bump, no duplicate signal.
[!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 |
defined but not raised by the library itself; surfaced via can_checkout() as (False, message) |
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 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
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] Silent fallback on misconfiguration The three subsystem factories swallow
ImportError/AttributeErrorand fall back to the default implementation. A typo inCART_TAX_CALCULATORyields "tax is always 0.00" at runtime — no exception, no warning. Validate your dotted paths in a startup check. The session adapter factory is the exception; it raises loudly.
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
CARTS_SESSION_ADAPTER_CLASS = "cart.session.CookieSessionAdapter"
# or — class object
from cart.session import CookieSessionAdapter
CARTS_SESSION_ADAPTER_CLASS = 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.
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
CARTS_SESSION_ADAPTER_CLASS = "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.
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. |
CARTS_SESSION_ADAPTER_CLASS |
dotted path or class | DjangoSessionAdapter |
Where the integer cart id is stored. See Session Storage. |
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. |
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+.
The CI matrix exercises the full range up to Python 3.14 and Django 6.0.
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. Details and candidate shapes indocs/ROADMAP_2026_04.md§P3-10.This is a scope marker, not a commitment. See the roadmap for the authoritative per-release plan.
Changelog, Roadmap, License
- Changelog:
CHANGELOG.md— Keep-a-Changelog format. - Roadmap of record:
docs/ROADMAP_2026_04.md. - 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.0.11.tar.gz.
File metadata
- Download URL: django_cart-3.0.11.tar.gz
- Upload date:
- Size: 76.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
13391d49b4e587fae9b5db9c8b3eb80a20aebdab545379aa952b1187d402764b
|
|
| MD5 |
cab53a7acfd9a6529ad1f27af2ea7195
|
|
| BLAKE2b-256 |
476eed4d6f5d6cb33ef6c6a3984c6d099cb6d95131afbbc27190c3051e0217d7
|
File details
Details for the file django_cart-3.0.11-py3-none-any.whl.
File metadata
- Download URL: django_cart-3.0.11-py3-none-any.whl
- Upload date:
- Size: 34.9 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 |
2f3674b17e21e7db03f78657675e855022e0949a2c330440e5556c9fc5d816de
|
|
| MD5 |
f91aa442e3d22e3c42a0c4941b967185
|
|
| BLAKE2b-256 |
14422164298bce4e105d4651a14eb281caf1df618e14d9750244eb33f955c9b9
|