Skip to content

profilsoftware/django-matrix

Repository files navigation

django-matrix

PyPI CI Python License: MIT

A reusable Django admin widget for editing 2D matrices of values - add or remove rows and columns inline, persisted as JSON. Drop it on any model, no JS build step, no opinions about your storage.

MatrixWidget rendered in the Django admin change form

Table of contents

What you get

  • A MatrixWidget (form widget) that renders an editable <table> with + row / - row / + col / - col buttons.
  • A MatrixField (model field, JSONField subclass) that wires the widget by default and validates shape + dimension bounds + cell types.
  • Reusable validators (RectangularMatrixValidator, MatrixBoundsValidator, NumericCellsValidator) if you want to skip the field and use the widget directly.
  • Vanilla JS - no npm, no bundler, no React. Configuration travels per-instance via data-* attributes so multiple widgets on a page just work.

Install

pip install django-matrix

Add to INSTALLED_APPS:

INSTALLED_APPS = [
    # ...
    "django.contrib.admin",
    "django_matrix",
]

Quickstart

# models.py
from django.db import models
from django_matrix import MatrixField


class Sheet(models.Model):
    name = models.CharField(max_length=255)
    grid = MatrixField(
        initial_rows=3,
        initial_cols=4,
        min_rows=1,
        max_rows=20,
        min_cols=1,
        max_cols=20,
        cell_input_type="number",
    )
# admin.py
from django.contrib import admin
from .models import Sheet


@admin.register(Sheet)
class SheetAdmin(admin.ModelAdmin):
    list_display = ("name",)

That's it. The change form for Sheet will render an editable grid for the grid field.

Stored shape

[
  [0,    100, 200, 300, 400],
  [100,  0,   0,   0,   0  ],
  [200,  0,   0,   0,   0  ]
]
  • Top level: list[list[scalar | null]].
  • Empty cells in numeric mode serialize as null (rendered as a blank input).
  • In text mode (cell_input_type="text"), empty cells serialize as "".
  • Rows are guaranteed equal-length when stored via MatrixField (rectangular).

API

MatrixWidget(forms.Widget)

from django_matrix import MatrixWidget

class MySheetForm(forms.ModelForm):
    grid = forms.JSONField(widget=MatrixWidget(
        cell_input_type="number",   # or "text"
        initial_rows=2,
        initial_cols=2,
        min_rows=1, max_rows=None,
        min_cols=1, max_cols=None,
    ))

Override render_cell(row_idx, col_idx, value) in a subclass for selects, checkboxes, or anything else - the default returns a single <input> tag.

MatrixField(models.JSONField)

Same constructor kwargs as MatrixWidget plus the usual models.JSONField kwargs. formfield() returns a forms.JSONField with the widget pre-attached.

MatrixField.clean() raises ValidationError when:

  • value is not a list of lists,
  • rows have unequal length,
  • dimensions fall outside min_rows / max_rows / min_cols / max_cols,
  • cells fail to coerce to float and cell_input_type="number" (None is allowed and represents an empty cell).

Recipes

Adding visual headers

The library treats every cell identically - it has no built-in "header row / header column" concept. If you want the first row and column to stand out as labels, scope a CSS rule and validate them separately in your form:

.dm-matrix tbody tr:first-child td,
.dm-matrix tbody td:first-child {
    background: #f3f3f3;
    font-weight: 600;
}
class PriceTableForm(forms.ModelForm):
    def clean_grid(self):
        data = self.cleaned_data["grid"]
        # row 0 = distance breakpoints, col 0 = weight breakpoints
        headers_row = data[0]
        if any(v is None or v < 0 for v in headers_row[1:]):
            raise forms.ValidationError("Distance headers must be non-negative.")
        return data

Splitting matrix cells into related models

The widget is just a 2D editor - nothing forces you to persist the grid as JSON. A common pattern is using the matrix as a bulk editor for a related model (one row per cell). For a shipping rate table where column 0 holds weight classes, row 0 holds zone names, and inner cells hold prices that should fan out into ShippingRate(zone, weight_class, price) rows:

# models.py
class ShippingRate(models.Model):
    zone = models.CharField(max_length=50)
    weight_class = models.CharField(max_length=50)
    price = models.DecimalField(max_digits=10, decimal_places=2)

    class Meta:
        unique_together = ("zone", "weight_class")

Keep the grid-to-model translation in two small helpers so the form stays a thin orchestrator:

# forms.py
from decimal import Decimal, InvalidOperation
from django import forms
from django.db import transaction
from django_matrix import MatrixWidget
from .models import ShippingRate


def rates_to_grid(rates):
    rates = list(rates)
    if not rates:
        return None
    zones = sorted({r.zone for r in rates})
    weights = sorted({r.weight_class for r in rates})
    price_at = {(r.zone, r.weight_class): str(r.price) for r in rates}
    header = ["", *zones]
    body = [[w, *(price_at.get((z, w), "") for z in zones)] for w in weights]
    return [header, *body]


def grid_to_rates(grid):
    header, *body = grid
    zones = header[1:]
    for weight_class, *cells in body:
        for zone, cell in zip(zones, cells):
            if cell:
                yield ShippingRate(
                    zone=zone,
                    weight_class=weight_class,
                    price=Decimal(cell),
                )


class ShippingRateMatrixForm(forms.Form):
    grid = forms.JSONField(widget=MatrixWidget(
        cell_input_type="text",   # text so zone/weight labels and prices coexist
        initial_rows=3, initial_cols=3,
        min_rows=2, min_cols=2,
    ))

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if not self.is_bound:
            self.initial.setdefault("grid", rates_to_grid(ShippingRate.objects.all()))

    def clean_grid(self):
        grid = self.cleaned_data["grid"]
        try:
            list(grid_to_rates(grid))
        except InvalidOperation as exc:
            raise forms.ValidationError("All prices must be decimal numbers.") from exc
        return grid

    @transaction.atomic
    def save(self):
        ShippingRate.objects.all().delete()  # simple replace-all strategy
        ShippingRate.objects.bulk_create(grid_to_rates(self.cleaned_data["grid"]))

Two things to notice:

  1. __init__ seeds initial["grid"] from existing rows so the widget shows current data on GET. rates_to_grid returns None for an empty table, letting the widget render a blank initial_rows × initial_cols grid.
  2. save() is your own method (this is a plain forms.Form, not a ModelForm) - call it from your view after form.is_valid(). The replace-all strategy keeps the example short; in production you'd diff against existing rows or upsert.

Hook it into a view like any other form:

def edit_shipping_rates(request):
    form = ShippingRateMatrixForm(request.POST or None)
    if request.method == "POST" and form.is_valid():
        form.save()
        return redirect("shipping-rates")
    return render(request, "rates.html", {"form": form})

Using a plain JSONField instead of MatrixField

If you don't want the field-level validation, attach the widget yourself:

class Sheet(models.Model):
    grid = models.JSONField()

class SheetForm(forms.ModelForm):
    class Meta:
        model = Sheet
        fields = ["grid"]
        widgets = {"grid": MatrixWidget(initial_rows=2, initial_cols=2)}

You can still reach for the shipped validators:

from django_matrix.validators import (
    RectangularMatrixValidator,
    MatrixBoundsValidator,
)

Compatibility

  • Python 3.10, 3.11, 3.12, 3.13
  • Django 4.2 LTS, 5.2 LTS

Development

git clone https://github.com/kmsky/django-matrix
cd django-matrix
uv sync --extra dev
uv run pytest
uv run ruff check .
uv run mypy src/

See CONTRIBUTING.md for the full workflow, including the manual JS smoke checklist.

License

MIT - see LICENSE.

About

A reusable Django admin widget for editing 2D matrices of values

Resources

License

Contributing

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors