django-form-matrix 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ from django_form_matrix.fields import Column, MatrixField, Row
2
+ from django_form_matrix.widgets import MatrixWidget
3
+
4
+ __version__ = "0.1.0"
5
+
6
+ __all__ = ["Column", "MatrixField", "MatrixWidget", "Row", "__version__"]
@@ -0,0 +1,7 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DjangoFormMatrixConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "django_form_matrix"
7
+ label = "django_form_matrix"
File without changes
@@ -0,0 +1,177 @@
1
+ """
2
+ Permissions in a matrix: one collapsible section per app, one row per model and
3
+ a column per basic action (add, view, change, delete), with the other
4
+ permissions of the model at the end of its row. Django stores the name of each
5
+ permission ("Can add invoice") in English when creating it; here they are shown
6
+ translated.
7
+
8
+ It needs ``django.contrib.auth``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from django import forms
17
+ from django.utils.text import capfirst
18
+ from django.utils.translation import gettext, pgettext_lazy
19
+ from django.utils.translation import gettext_lazy as _
20
+
21
+ if TYPE_CHECKING:
22
+ from django.contrib.auth.models import Permission
23
+ from django.db.models import QuerySet
24
+
25
+ ACTIONS = {
26
+ "add": _("Can add %(model)s"),
27
+ "change": _("Can change %(model)s"),
28
+ "delete": _("Can delete %(model)s"),
29
+ "view": _("Can view %(model)s"),
30
+ }
31
+ # Columns of the matrix, in the order they are shown.
32
+ ACTION_COLUMNS = (
33
+ ("add", pgettext_lazy("permission", "Add")),
34
+ ("view", pgettext_lazy("permission", "View")),
35
+ ("change", pgettext_lazy("permission", "Change")),
36
+ ("delete", pgettext_lazy("permission", "Delete")),
37
+ )
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class PermissionParts:
42
+ """Where a permission belongs and what it allows, translated."""
43
+
44
+ app: str
45
+ model: str
46
+ action: str | None # "add", "change"... for the basic ones
47
+ name: str
48
+
49
+
50
+ def permission_parts(permission: Permission) -> PermissionParts:
51
+ content_type = permission.content_type
52
+ model = content_type.model_class()
53
+ if model is None: # permission of a removed model
54
+ return PermissionParts(
55
+ content_type.app_label, content_type.model, None, permission.name
56
+ )
57
+ opts = model._meta
58
+ action, _separator, model_name = permission.codename.partition("_")
59
+ if action in ACTIONS and model_name == opts.model_name:
60
+ return PermissionParts(
61
+ str(capfirst(opts.app_config.verbose_name)),
62
+ str(capfirst(opts.verbose_name)),
63
+ action,
64
+ ACTIONS[action] % {"model": opts.verbose_name},
65
+ )
66
+ return PermissionParts(
67
+ str(capfirst(opts.app_config.verbose_name)),
68
+ str(capfirst(opts.verbose_name)),
69
+ None,
70
+ gettext(permission.name),
71
+ )
72
+
73
+
74
+ def permission_label(permission: Permission) -> str:
75
+ """ "App | Model | Can add model", translated like the rest of the admin."""
76
+ if permission.content_type.model_class() is None:
77
+ return str(permission)
78
+ parts = permission_parts(permission)
79
+ return f"{parts.app} | {parts.model} | {parts.name}"
80
+
81
+
82
+ @dataclass
83
+ class PermissionRow:
84
+ model: str
85
+ cells: dict[str, dict[str, Any]] = field(default_factory=dict)
86
+ others: list[dict[str, Any]] = field(default_factory=list)
87
+
88
+ @property
89
+ def columns(self) -> list[tuple[str, dict[str, Any] | None]]:
90
+ return [(action, self.cells.get(action)) for action, _label in ACTION_COLUMNS]
91
+
92
+
93
+ @dataclass
94
+ class PermissionApp:
95
+ name: str
96
+ rows: dict[str, PermissionRow] = field(default_factory=dict)
97
+
98
+ @property
99
+ def options(self) -> list[dict[str, Any]]:
100
+ return [
101
+ option
102
+ for row in self.rows.values()
103
+ for option in [*row.cells.values(), *row.others]
104
+ ]
105
+
106
+ @property
107
+ def has_others(self) -> bool:
108
+ return any(row.others for row in self.rows.values())
109
+
110
+ @property
111
+ def checked(self) -> int:
112
+ return sum(option["checked"] for option in self.options)
113
+
114
+ @property
115
+ def total(self) -> int:
116
+ return len(self.options)
117
+
118
+
119
+ class PermissionMatrixWidget(forms.CheckboxSelectMultiple):
120
+ """
121
+ Permissions as a matrix (see the module docstring). It submits like a
122
+ CheckboxSelectMultiple, so validation and saving do not change; only the
123
+ permissions of the queryset of the field are shown.
124
+ """
125
+
126
+ template_name = "django_form_matrix/permissions.html"
127
+
128
+ class Media:
129
+ css = {"all": ("django_form_matrix/css/matrix.css",)}
130
+ js = ("django_form_matrix/js/matrix.js",)
131
+
132
+ def get_context(self, name: str, value: Any, attrs: Any) -> dict[str, Any]:
133
+ context = super().get_context(name, value, attrs)
134
+ selected = {str(item) for item in value or []}
135
+ apps: dict[str, PermissionApp] = {}
136
+ # Choices of a ModelMultipleChoiceField: no empty one, and each value
137
+ # carries its permission.
138
+ for choice, _label in self.choices:
139
+ permission = choice.instance # type: ignore[union-attr]
140
+ parts = permission_parts(permission)
141
+ row = apps.setdefault(parts.app, PermissionApp(parts.app)).rows.setdefault(
142
+ parts.model, PermissionRow(parts.model)
143
+ )
144
+ option = {
145
+ "value": permission.pk,
146
+ "id": f"{context['widget']['attrs'].get('id', name)}_{permission.pk}",
147
+ "label": parts.name,
148
+ "checked": str(permission.pk) in selected,
149
+ }
150
+ if parts.action:
151
+ row.cells[parts.action] = option
152
+ else:
153
+ row.others.append(option)
154
+ context["widget"].update(
155
+ {
156
+ "apps": [
157
+ PermissionApp(app.name, dict(sorted(app.rows.items())))
158
+ for app in sorted(apps.values(), key=lambda app: app.name)
159
+ ],
160
+ "columns": ACTION_COLUMNS,
161
+ "disabled": bool(context["widget"]["attrs"].get("disabled")),
162
+ }
163
+ )
164
+ return context
165
+
166
+
167
+ class PermissionMultipleChoiceField(forms.ModelMultipleChoiceField):
168
+ """Permissions labelled with their translated names, chosen in a matrix."""
169
+
170
+ widget = PermissionMatrixWidget
171
+
172
+ def __init__(self, queryset: QuerySet[Permission], **kwargs: Any) -> None:
173
+ kwargs["widget"] = PermissionMatrixWidget
174
+ super().__init__(queryset.select_related("content_type"), **kwargs)
175
+
176
+ def label_from_instance(self, obj: Any) -> str:
177
+ return permission_label(obj)
@@ -0,0 +1,23 @@
1
+ """django-unfold integration."""
2
+
3
+ from django.forms import Media
4
+
5
+
6
+ class UnfoldMatrixMixin:
7
+ """
8
+ Puts the matrix on Unfold's palette (light and dark)::
9
+
10
+ from unfold.admin import ModelAdmin
11
+
12
+ class PlanAdmin(UnfoldMatrixMixin, ModelAdmin):
13
+ form = PlanForm
14
+ """
15
+
16
+ @property
17
+ def media(self) -> Media:
18
+ return super().media + Media( # type: ignore[misc]
19
+ css={"all": ("django_form_matrix/css/unfold.css",)},
20
+ )
21
+
22
+
23
+ __all__ = ["UnfoldMatrixMixin"]
@@ -0,0 +1,159 @@
1
+ """
2
+ A form field to fill a table: one row per item, one column per property and,
3
+ in every cell, a form field of its own (a checkbox, a number, a choice...).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from django import forms
12
+ from django.core.exceptions import ValidationError
13
+ from django.utils.translation import gettext_lazy as _
14
+
15
+ from django_form_matrix.widgets import MatrixWidget
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Collection, Iterable, Mapping
19
+
20
+
21
+ def _boolean() -> forms.Field:
22
+ return forms.BooleanField(required=False)
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Column:
27
+ """
28
+ A column and the form field that is in each of its cells (a checkbox
29
+ unless said otherwise). Checkbox columns get a shortcut to check them all.
30
+ """
31
+
32
+ key: str
33
+ label: Any
34
+ field: forms.Field = field(default_factory=_boolean)
35
+
36
+ @property
37
+ def is_checkbox(self) -> bool:
38
+ return isinstance(self.field.widget, forms.CheckboxInput)
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Row:
43
+ """
44
+ A row, optionally inside a collapsible ``section``. ``columns`` are the keys
45
+ of the columns that have a cell in this row (all of them if it is None).
46
+ """
47
+
48
+ key: str
49
+ label: Any
50
+ section: Any = None
51
+ columns: Collection[str] | None = None
52
+
53
+ def __post_init__(self) -> None:
54
+ if self.columns is not None:
55
+ object.__setattr__(self, "columns", frozenset(self.columns))
56
+
57
+ def has(self, column: str) -> bool:
58
+ return self.columns is None or column in self.columns
59
+
60
+
61
+ class MatrixField(forms.Field):
62
+ """
63
+ Its value is ``{row key: {column key: cleaned value of the cell}}``, with
64
+ only the cells that exist. Each cell is cleaned by the field of its column,
65
+ so its validation and its widget are the ones of Django; a cell that fails
66
+ reports its row and column. It is not required by default; when it is, at
67
+ least one cell must have a value.
68
+ """
69
+
70
+ widget = MatrixWidget
71
+ default_error_messages = {
72
+ "cell": _("%(row)s, %(column)s: %(error)s"),
73
+ }
74
+
75
+ def __init__(
76
+ self,
77
+ *,
78
+ rows: Iterable[Row],
79
+ columns: Iterable[Column],
80
+ required: bool = False,
81
+ **kwargs: Any,
82
+ ) -> None:
83
+ super().__init__(required=required, **kwargs)
84
+ self.rows = rows
85
+ self.columns = columns
86
+
87
+ @property
88
+ def rows(self) -> tuple[Row, ...]:
89
+ return self._rows
90
+
91
+ @rows.setter
92
+ def rows(self, rows: Iterable[Row]) -> None:
93
+ rows = tuple(rows)
94
+ self._check_unique("row", [row.key for row in rows])
95
+ self._rows = rows
96
+ self.widget.rows = rows
97
+
98
+ @property
99
+ def columns(self) -> tuple[Column, ...]:
100
+ return self._columns
101
+
102
+ @columns.setter
103
+ def columns(self, columns: Iterable[Column]) -> None:
104
+ columns = tuple(columns)
105
+ self._check_unique("column", [column.key for column in columns])
106
+ self._columns = columns
107
+ self.widget.columns = columns
108
+
109
+ @staticmethod
110
+ def _check_unique(kind: str, keys: list[str]) -> None:
111
+ if len(set(keys)) != len(keys):
112
+ raise ValueError(f"The keys of the {kind}s of a matrix must be unique.")
113
+
114
+ def clean(self, value: Mapping[str, Mapping[str, Any]] | None) -> dict[str, Any]:
115
+ value = value or {}
116
+ cleaned: dict[str, dict[str, Any]] = {}
117
+ errors: list[ValidationError] = []
118
+ for row in self.rows:
119
+ cells = cleaned[row.key] = {}
120
+ for column in self.columns:
121
+ if not row.has(column.key):
122
+ continue
123
+ raw = value.get(row.key, {}).get(column.key)
124
+ try:
125
+ cells[column.key] = column.field.clean(raw)
126
+ except ValidationError as error:
127
+ errors.extend(
128
+ ValidationError(
129
+ self.error_messages["cell"],
130
+ code="cell",
131
+ params={
132
+ "row": row.label,
133
+ "column": column.label,
134
+ "error": message,
135
+ },
136
+ )
137
+ for message in error.messages
138
+ )
139
+ if errors:
140
+ raise ValidationError(errors)
141
+ if self.required and not any(
142
+ cell for cells in cleaned.values() for cell in cells.values()
143
+ ):
144
+ raise ValidationError(self.error_messages["required"], code="required")
145
+ self.run_validators(cleaned)
146
+ return cleaned
147
+
148
+ def has_changed(self, initial: Any, data: Any) -> bool:
149
+ initial = initial or {}
150
+ data = data or {}
151
+ return any(
152
+ column.field.has_changed(
153
+ initial.get(row.key, {}).get(column.key),
154
+ data.get(row.key, {}).get(column.key),
155
+ )
156
+ for row in self.rows
157
+ for column in self.columns
158
+ if row.has(column.key)
159
+ )
@@ -0,0 +1,73 @@
1
+ # Spanish translation of django-form-matrix.
2
+ #
3
+ msgid ""
4
+ msgstr ""
5
+ "Project-Id-Version: django-form-matrix 0.1.0\n"
6
+ "Report-Msgid-Bugs-To: \n"
7
+ "POT-Creation-Date: 2026-10-16 18:04-0500\n"
8
+ "PO-Revision-Date: 2026-10-17 01:00+0200\n"
9
+ "Last-Translator: Rodolfo Becerra\n"
10
+ "Language-Team: Spanish\n"
11
+ "Language: es\n"
12
+ "MIME-Version: 1.0\n"
13
+ "Content-Type: text/plain; charset=UTF-8\n"
14
+ "Content-Transfer-Encoding: 8bit\n"
15
+ "Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
16
+
17
+ #: contrib/permissions.py
18
+ #, python-format
19
+ msgid "Can add %(model)s"
20
+ msgstr "Puede añadir %(model)s"
21
+
22
+ #: contrib/permissions.py
23
+ #, python-format
24
+ msgid "Can change %(model)s"
25
+ msgstr "Puede modificar %(model)s"
26
+
27
+ #: contrib/permissions.py
28
+ #, python-format
29
+ msgid "Can delete %(model)s"
30
+ msgstr "Puede eliminar %(model)s"
31
+
32
+ #: contrib/permissions.py
33
+ #, python-format
34
+ msgid "Can view %(model)s"
35
+ msgstr "Puede ver %(model)s"
36
+
37
+ #: contrib/permissions.py
38
+ msgctxt "permission"
39
+ msgid "Add"
40
+ msgstr "Añadir"
41
+
42
+ #: contrib/permissions.py
43
+ msgctxt "permission"
44
+ msgid "View"
45
+ msgstr "Ver"
46
+
47
+ #: contrib/permissions.py
48
+ msgctxt "permission"
49
+ msgid "Change"
50
+ msgstr "Modificar"
51
+
52
+ #: contrib/permissions.py
53
+ msgctxt "permission"
54
+ msgid "Delete"
55
+ msgstr "Eliminar"
56
+
57
+ #: fields.py
58
+ #, python-format
59
+ msgid "%(row)s, %(column)s: %(error)s"
60
+ msgstr "%(row)s, %(column)s: %(error)s"
61
+
62
+ #: templates/django_form_matrix/matrix.html
63
+ #: templates/django_form_matrix/permissions.html
64
+ msgid "All"
65
+ msgstr "Todo"
66
+
67
+ #: templates/django_form_matrix/permissions.html
68
+ msgid "Others"
69
+ msgstr "Otros"
70
+
71
+ #: templates/django_form_matrix/permissions.html
72
+ msgid "No permissions available."
73
+ msgstr "No hay permisos disponibles."
File without changes
@@ -0,0 +1,145 @@
1
+ /*
2
+ * Matrix (templates/django_form_matrix). The colours are the ones of the
3
+ * Django admin when it is there; set the --form-matrix-* variables to use
4
+ * the matrix anywhere else.
5
+ */
6
+ .form-matrix {
7
+ --form-matrix-border: var(--hairline-color, #ccc);
8
+ --form-matrix-heading-bg: var(--darkened-bg, #f8f8f8);
9
+ --form-matrix-quiet: var(--body-quiet-color, #666);
10
+
11
+ display: flex;
12
+ flex-direction: column;
13
+ gap: 6px;
14
+ width: 100%;
15
+ max-width: 900px;
16
+ }
17
+
18
+ .form-matrix-section {
19
+ border: 1px solid var(--form-matrix-border);
20
+ border-radius: 4px;
21
+ }
22
+
23
+ /* Unfold draws its chevron at the end of every summary, absolutely placed. */
24
+ .form-matrix-section > summary {
25
+ position: relative;
26
+ display: flex;
27
+ justify-content: space-between;
28
+ padding: 6px 36px 6px 10px;
29
+ cursor: pointer;
30
+ background: var(--form-matrix-heading-bg);
31
+ font-weight: bold;
32
+ }
33
+
34
+ /* Centred (it is 18px high); Unfold turns it when the section is open. */
35
+ .form-matrix-section > summary::after {
36
+ top: calc(50% - 9px);
37
+ }
38
+
39
+ .form-matrix-section[open] > summary {
40
+ margin-bottom: 0;
41
+ }
42
+
43
+ .form-matrix-count {
44
+ font-weight: normal;
45
+ color: var(--form-matrix-quiet);
46
+ }
47
+
48
+ /* Fixed layout with the same widths in every section: the columns line up. */
49
+ .form-matrix-section table {
50
+ width: 100%;
51
+ border-collapse: collapse;
52
+ table-layout: fixed;
53
+ }
54
+
55
+ .form-matrix-row-column {
56
+ width: 26%;
57
+ }
58
+
59
+ .form-matrix-column {
60
+ width: 11%;
61
+ }
62
+
63
+ .form-matrix-section th,
64
+ .form-matrix-section td {
65
+ padding: 4px 8px;
66
+ text-align: center;
67
+ vertical-align: middle;
68
+ }
69
+
70
+ .form-matrix-section thead th {
71
+ vertical-align: bottom;
72
+ }
73
+
74
+ .form-matrix-section input[type="checkbox"] {
75
+ margin: 0;
76
+ }
77
+
78
+ .form-matrix-section label {
79
+ display: inline-flex;
80
+ align-items: center;
81
+ gap: 6px;
82
+ padding: 0;
83
+ font-size: inherit;
84
+ white-space: nowrap;
85
+ }
86
+
87
+ .form-matrix-section th[scope="row"] label,
88
+ .form-matrix-section thead th:first-child label {
89
+ white-space: normal;
90
+ }
91
+
92
+ /* The cells that are not checkboxes take their column. */
93
+ .form-matrix-section td input:not([type="checkbox"]),
94
+ .form-matrix-section td select {
95
+ box-sizing: border-box;
96
+ width: 100%;
97
+ min-width: 0;
98
+ }
99
+
100
+ /*
101
+ * The checkbox of a column above its name, over the checkboxes of the column
102
+ * (on the left for "Others", whose checkboxes are listed from the left).
103
+ */
104
+ .form-matrix-section thead th:not(:first-child) label {
105
+ flex-direction: column-reverse;
106
+ gap: 4px;
107
+ }
108
+
109
+ .form-matrix-section th[scope="row"],
110
+ .form-matrix-section thead th:first-child,
111
+ .form-matrix-section .form-matrix-others {
112
+ text-align: left;
113
+ }
114
+
115
+ .form-matrix-section thead th.form-matrix-others label {
116
+ align-items: flex-start;
117
+ }
118
+
119
+ /*
120
+ * The extra checkboxes of a row, one under the other and wrapping inside
121
+ * their column, not past the matrix; their checkbox next to their first line.
122
+ */
123
+ td.form-matrix-others label {
124
+ display: flex;
125
+ align-items: flex-start;
126
+ white-space: normal;
127
+ text-align: left;
128
+ }
129
+
130
+ td.form-matrix-others label + label {
131
+ margin-top: 4px;
132
+ }
133
+
134
+ td.form-matrix-others input[type="checkbox"] {
135
+ flex: none;
136
+ margin-top: 2px;
137
+ }
138
+
139
+ .form-matrix-missing {
140
+ color: var(--form-matrix-quiet);
141
+ }
142
+
143
+ .form-matrix-placeholder {
144
+ visibility: hidden;
145
+ }
@@ -0,0 +1,35 @@
1
+ /*
2
+ * The colours of the matrix on Unfold's palette (Unfold does not define the
3
+ * variables of Django's admin). Unfold switches to dark with a "dark" class
4
+ * on <html>. The selectors are more specific than matrix.css's, so it wins
5
+ * whatever the order the stylesheets are loaded in.
6
+ */
7
+ html .form-matrix {
8
+ --form-matrix-border: var(--color-base-200);
9
+ --form-matrix-heading-bg: var(--color-base-50);
10
+ --form-matrix-quiet: var(--color-base-500);
11
+ }
12
+
13
+ html.dark .form-matrix {
14
+ --form-matrix-border: var(--color-base-800);
15
+ --form-matrix-heading-bg: rgb(255 255 255 / 0.02);
16
+ --form-matrix-quiet: var(--color-base-400);
17
+ }
18
+
19
+ /* Unfold's inputs are styled by its own widgets; these cells are Django's. */
20
+ html .form-matrix td input:not([type="checkbox"]),
21
+ html .form-matrix td select {
22
+ padding: 0.375rem 0.625rem;
23
+ border: 1px solid var(--color-base-200);
24
+ border-radius: 0.375rem;
25
+ background: #fff;
26
+ color: var(--color-base-700);
27
+ font-size: 0.875rem;
28
+ }
29
+
30
+ html.dark .form-matrix td input:not([type="checkbox"]),
31
+ html.dark .form-matrix td select {
32
+ border-color: var(--color-base-700);
33
+ background: var(--color-base-900);
34
+ color: var(--color-base-200);
35
+ }
@@ -0,0 +1,67 @@
1
+ {
2
+ // Shortcuts of the matrix (templates/django_form_matrix): the checkbox of a
3
+ // row, a column or a whole section sets every checkbox it covers, and shows
4
+ // whether they are all, some or none checked.
5
+ const CELL = "input[type=checkbox][name]:not([disabled])";
6
+
7
+ function covered(toggle) {
8
+ const table = toggle.closest("table");
9
+ switch (toggle.dataset.formMatrixToggle) {
10
+ case "row":
11
+ return toggle.closest("tr").querySelectorAll(CELL);
12
+ case "column":
13
+ return [...table.querySelectorAll("td[data-column]")]
14
+ .filter(
15
+ (cell) => cell.dataset.column === toggle.dataset.column,
16
+ )
17
+ .flatMap((cell) => [...cell.querySelectorAll(CELL)]);
18
+ default:
19
+ return table.querySelectorAll(CELL);
20
+ }
21
+ }
22
+
23
+ function refresh(section) {
24
+ for (const toggle of section.querySelectorAll(
25
+ "[data-form-matrix-toggle]",
26
+ )) {
27
+ const inputs = [...covered(toggle)];
28
+ const checked = inputs.filter((input) => input.checked).length;
29
+ toggle.disabled = inputs.length === 0;
30
+ toggle.checked = inputs.length > 0 && checked === inputs.length;
31
+ toggle.indeterminate = checked > 0 && checked < inputs.length;
32
+ }
33
+ const count = section.querySelector("[data-form-matrix-count]");
34
+ if (count) {
35
+ const all = section.querySelectorAll("input[type=checkbox][name]");
36
+ const checked = [...all].filter((input) => input.checked).length;
37
+ count.textContent = `${checked}/${all.length}`;
38
+ }
39
+ }
40
+
41
+ document.addEventListener("change", (event) => {
42
+ const section = event.target.closest(".form-matrix-section");
43
+ if (!section) {
44
+ return;
45
+ }
46
+ if (event.target.dataset.formMatrixToggle) {
47
+ for (const input of covered(event.target)) {
48
+ input.checked = event.target.checked;
49
+ }
50
+ }
51
+ refresh(section);
52
+ });
53
+
54
+ function init() {
55
+ for (const section of document.querySelectorAll(
56
+ ".form-matrix-section",
57
+ )) {
58
+ refresh(section);
59
+ }
60
+ }
61
+
62
+ if (document.readyState === "loading") {
63
+ document.addEventListener("DOMContentLoaded", init);
64
+ } else {
65
+ init();
66
+ }
67
+ }
@@ -0,0 +1,78 @@
1
+ {% load i18n %}
2
+ <div class="form-matrix"{% if widget.attrs.id %} id="{{ widget.attrs.id }}"{% endif %}>
3
+ {% for section in widget.sections %}
4
+ {% if section.name %}
5
+ <details class="form-matrix-section"{% if section.is_open %} open{% endif %}>
6
+ <summary>
7
+ {% block section_summary %}
8
+ <span class="form-matrix-section-name">{{ section.name }}</span>
9
+ {% if section.total %}
10
+ <span class="form-matrix-count" data-form-matrix-count>{{ section.checked }}/{{ section.total }}</span>
11
+ {% endif %}
12
+ {% endblock section_summary %}
13
+ </summary>
14
+ {% else %}
15
+ <div class="form-matrix-section">
16
+ {% endif %}
17
+ <table>
18
+ {# Same column widths in every section, so the columns line up. #}
19
+ <colgroup>
20
+ <col class="form-matrix-row-column">
21
+ {% for column in widget.columns %}<col class="form-matrix-column">{% endfor %}
22
+ </colgroup>
23
+ <thead>
24
+ <tr>
25
+ <th scope="col">
26
+ {% block section_toggle %}
27
+ {% if section.has_checkboxes and not widget.disabled %}
28
+ <label><input type="checkbox" data-form-matrix-toggle="section"> {% translate "All" %}</label>
29
+ {% endif %}
30
+ {% endblock section_toggle %}
31
+ </th>
32
+ {% for column in widget.columns %}
33
+ <th scope="col">
34
+ {% block column_heading %}
35
+ {% if column.is_checkbox and not widget.disabled %}
36
+ <label><input type="checkbox" data-form-matrix-toggle="column" data-column="{{ forloop.counter0 }}"> {{ column.label }}</label>
37
+ {% else %}
38
+ {{ column.label }}
39
+ {% endif %}
40
+ {% endblock column_heading %}
41
+ </th>
42
+ {% endfor %}
43
+ </tr>
44
+ </thead>
45
+ <tbody>
46
+ {% for row in section.rows %}
47
+ <tr>
48
+ <th scope="row">
49
+ {% block row_heading %}
50
+ {% if row.has_checkboxes and not widget.disabled %}
51
+ <label><input type="checkbox" data-form-matrix-toggle="row"> {{ row.label }}</label>
52
+ {% else %}
53
+ {{ row.label }}
54
+ {% endif %}
55
+ {% endblock row_heading %}
56
+ </th>
57
+ {% for cell in row.cells %}
58
+ <td data-column="{{ forloop.counter0 }}">
59
+ {% if cell.html %}
60
+ {% block cell %}{{ cell.html }}{% endblock %}
61
+ {% else %}
62
+ {% block missing_cell %}<span class="form-matrix-missing" aria-hidden="true">—</span>{% endblock %}
63
+ {% endif %}
64
+ </td>
65
+ {% endfor %}
66
+ </tr>
67
+ {% endfor %}
68
+ </tbody>
69
+ </table>
70
+ {% if section.name %}
71
+ </details>
72
+ {% else %}
73
+ </div>
74
+ {% endif %}
75
+ {% empty %}
76
+ {% block empty %}{% endblock %}
77
+ {% endfor %}
78
+ </div>
@@ -0,0 +1,89 @@
1
+ {% load i18n %}
2
+ <div class="form-matrix"{% if widget.attrs.id %} id="{{ widget.attrs.id }}"{% endif %}>
3
+ {% for app in widget.apps %}
4
+ <details class="form-matrix-section"{% if app.checked %} open{% endif %}>
5
+ <summary>
6
+ {% block section_summary %}
7
+ <span class="form-matrix-section-name">{{ app.name }}</span>
8
+ <span class="form-matrix-count" data-form-matrix-count>{{ app.checked }}/{{ app.total }}</span>
9
+ {% endblock section_summary %}
10
+ </summary>
11
+ <table>
12
+ {# Same column widths in every app, so the columns line up. #}
13
+ <colgroup>
14
+ <col class="form-matrix-row-column">
15
+ {% for action, label in widget.columns %}<col class="form-matrix-column">{% endfor %}
16
+ <col class="form-matrix-others-column">
17
+ </colgroup>
18
+ <thead>
19
+ <tr>
20
+ <th scope="col">
21
+ {% block section_toggle %}
22
+ {% if not widget.disabled %}
23
+ <label><input type="checkbox" data-form-matrix-toggle="section"> {% translate "All" %}</label>
24
+ {% endif %}
25
+ {% endblock section_toggle %}
26
+ </th>
27
+ {% for action, label in widget.columns %}
28
+ <th scope="col">
29
+ {% block column_heading %}
30
+ {% if widget.disabled %}{{ label }}{% else %}
31
+ <label><input type="checkbox" data-form-matrix-toggle="column" data-column="{{ action }}"> {{ label }}</label>
32
+ {% endif %}
33
+ {% endblock column_heading %}
34
+ </th>
35
+ {% endfor %}
36
+ <th scope="col" class="form-matrix-others">
37
+ {% block others_heading %}
38
+ {% if widget.disabled %}{% translate "Others" %}{% else %}
39
+ <label>
40
+ {% if app.has_others %}
41
+ <input type="checkbox" data-form-matrix-toggle="column" data-column="others">
42
+ {% else %}
43
+ {# Room of the checkbox, so the heading lines up with the others. #}
44
+ <input type="checkbox" class="form-matrix-placeholder" tabindex="-1" aria-hidden="true" disabled>
45
+ {% endif %}
46
+ {% translate "Others" %}
47
+ </label>
48
+ {% endif %}
49
+ {% endblock others_heading %}
50
+ </th>
51
+ </tr>
52
+ </thead>
53
+ <tbody>
54
+ {% for row in app.rows.values %}
55
+ <tr>
56
+ <th scope="row">
57
+ {% block row_heading %}
58
+ {% if widget.disabled %}{{ row.model }}{% else %}
59
+ <label><input type="checkbox" data-form-matrix-toggle="row"> {{ row.model }}</label>
60
+ {% endif %}
61
+ {% endblock row_heading %}
62
+ </th>
63
+ {% for action, option in row.columns %}
64
+ <td data-column="{{ action }}">
65
+ {% if option %}
66
+ {% block permission %}
67
+ <input type="checkbox" name="{{ widget.name }}" value="{{ option.value }}" id="{{ option.id }}" title="{{ option.label }}" aria-label="{{ row.model }}: {{ option.label }}"{% if option.checked %} checked{% endif %}{% if widget.disabled %} disabled{% endif %}>
68
+ {% endblock permission %}
69
+ {% else %}
70
+ {% block missing_cell %}<span class="form-matrix-missing" aria-hidden="true">—</span>{% endblock %}
71
+ {% endif %}
72
+ </td>
73
+ {% endfor %}
74
+ <td class="form-matrix-others" data-column="others">
75
+ {% for option in row.others %}
76
+ {% block other_permission %}
77
+ <label><input type="checkbox" name="{{ widget.name }}" value="{{ option.value }}" id="{{ option.id }}"{% if option.checked %} checked{% endif %}{% if widget.disabled %} disabled{% endif %}> {{ option.label }}</label>
78
+ {% endblock other_permission %}
79
+ {% endfor %}
80
+ </td>
81
+ </tr>
82
+ {% endfor %}
83
+ </tbody>
84
+ </table>
85
+ </details>
86
+ {% empty %}
87
+ {% block empty %}<p>{% translate "No permissions available." %}</p>{% endblock %}
88
+ {% endfor %}
89
+ </div>
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from django import forms
7
+
8
+ if TYPE_CHECKING:
9
+ from django_form_matrix.fields import Column, Row
10
+
11
+
12
+ @dataclass
13
+ class Section:
14
+ """The rows under one heading; the ones without section go together."""
15
+
16
+ name: Any
17
+ rows: list[dict[str, Any]] = field(default_factory=list)
18
+
19
+ @property
20
+ def checkboxes(self) -> list[dict[str, Any]]:
21
+ return [
22
+ cell
23
+ for row in self.rows
24
+ for cell in row["cells"]
25
+ if cell["checkbox"] and cell["html"]
26
+ ]
27
+
28
+ @property
29
+ def has_checkboxes(self) -> bool:
30
+ return bool(self.checkboxes)
31
+
32
+ @property
33
+ def checked(self) -> int:
34
+ return sum(cell["checked"] for cell in self.checkboxes)
35
+
36
+ @property
37
+ def total(self) -> int:
38
+ return len(self.checkboxes)
39
+
40
+ @property
41
+ def is_open(self) -> bool:
42
+ # Open when something in it is filled (also when it fails), and when
43
+ # it has no checkboxes to count.
44
+ return not self.total or any(
45
+ cell["filled"] for row in self.rows for cell in row["cells"]
46
+ )
47
+
48
+
49
+ class MatrixWidget(forms.Widget):
50
+ """
51
+ A table with the widget of each column in every cell. Every cell is
52
+ submitted on its own, as ``<name>__<row key>__<column key>``, so it reads
53
+ like any other widget; the value is ``{row key: {column key: raw value}}``.
54
+ """
55
+
56
+ template_name = "django_form_matrix/matrix.html"
57
+
58
+ class Media:
59
+ css = {"all": ("django_form_matrix/css/matrix.css",)}
60
+ js = ("django_form_matrix/js/matrix.js",)
61
+
62
+ def __init__(
63
+ self,
64
+ attrs: dict[str, Any] | None = None,
65
+ rows: tuple[Row, ...] = (),
66
+ columns: tuple[Column, ...] = (),
67
+ ) -> None:
68
+ super().__init__(attrs)
69
+ self.rows = rows
70
+ self.columns = columns
71
+
72
+ @staticmethod
73
+ def cell_name(name: str, row: Row, column: Column) -> str:
74
+ return f"{name}__{row.key}__{column.key}"
75
+
76
+ def value_from_datadict(
77
+ self, data: Any, files: Any, name: str
78
+ ) -> dict[str, dict[str, Any]]:
79
+ return {
80
+ row.key: {
81
+ column.key: column.field.widget.value_from_datadict(
82
+ data, files, self.cell_name(name, row, column)
83
+ )
84
+ for column in self.columns
85
+ if row.has(column.key)
86
+ }
87
+ for row in self.rows
88
+ }
89
+
90
+ def value_omitted_from_data(self, data: Any, files: Any, name: str) -> bool:
91
+ return all(
92
+ column.field.widget.value_omitted_from_data(
93
+ data, files, self.cell_name(name, row, column)
94
+ )
95
+ for row in self.rows
96
+ for column in self.columns
97
+ if row.has(column.key)
98
+ )
99
+
100
+ def format_value(self, value: Any) -> Any:
101
+ return value
102
+
103
+ def get_context(self, name: str, value: Any, attrs: Any) -> dict[str, Any]:
104
+ context = super().get_context(name, value, attrs)
105
+ widget = context["widget"]
106
+ value = value or {}
107
+ base_id = widget["attrs"].get("id") or f"id_{name}"
108
+ disabled = bool(widget["attrs"].get("disabled"))
109
+ sections: dict[Any, Section] = {}
110
+ for i, row in enumerate(self.rows):
111
+ cells = []
112
+ for j, column in enumerate(self.columns):
113
+ cell: dict[str, Any] = {
114
+ "column": column,
115
+ "html": None,
116
+ "checkbox": column.is_checkbox,
117
+ "checked": False,
118
+ "filled": False,
119
+ }
120
+ if row.has(column.key):
121
+ cell_value = value.get(row.key, {}).get(column.key)
122
+ cell_attrs: dict[str, Any] = {
123
+ "id": f"{base_id}_{i}_{j}",
124
+ "aria-label": f"{row.label}: {column.label}",
125
+ }
126
+ if disabled:
127
+ cell_attrs["disabled"] = True
128
+ cell["html"] = column.field.widget.render(
129
+ self.cell_name(name, row, column),
130
+ column.field.prepare_value(cell_value),
131
+ cell_attrs,
132
+ )
133
+ cell["filled"] = bool(cell_value)
134
+ cell["checked"] = column.is_checkbox and cell["filled"]
135
+ cells.append(cell)
136
+ sections.setdefault(row.section, Section(row.section)).rows.append(
137
+ {
138
+ "label": row.label,
139
+ "cells": cells,
140
+ "has_checkboxes": any(c["checkbox"] and c["html"] for c in cells),
141
+ }
142
+ )
143
+ widget.update(
144
+ sections=list(sections.values()),
145
+ columns=self.columns,
146
+ disabled=disabled,
147
+ )
148
+ return context
@@ -0,0 +1,217 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-form-matrix
3
+ Version: 0.1.0
4
+ Summary: A form field to fill a table of rows and columns, with a form field in every cell (checkboxes, numbers, choices...), plus a ready-made permission matrix.
5
+ Project-URL: Homepage, https://github.com/rodolvbg/django-form-matrix
6
+ Project-URL: Issues, https://github.com/rodolvbg/django-form-matrix/issues
7
+ Project-URL: Repository, https://github.com/rodolvbg/django-form-matrix
8
+ Author-email: Rodolfo Valentín Becerra García <rodolvbg@gmail.com>
9
+ Maintainer-email: Rodolfo Valentín Becerra García <rodolvbg@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: admin,checkbox,django,form,grid,matrix,permissions,widget
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: Web Environment
15
+ Classifier: Framework :: Django
16
+ Classifier: Framework :: Django :: 4.2
17
+ Classifier: Framework :: Django :: 5.0
18
+ Classifier: Framework :: Django :: 5.1
19
+ Classifier: Framework :: Django :: 5.2
20
+ Classifier: Intended Audience :: Developers
21
+ Classifier: License :: OSI Approved :: MIT License
22
+ Classifier: Operating System :: OS Independent
23
+ Classifier: Programming Language :: Python :: 3 :: Only
24
+ Classifier: Programming Language :: Python :: 3.10
25
+ Classifier: Programming Language :: Python :: 3.11
26
+ Classifier: Programming Language :: Python :: 3.12
27
+ Classifier: Programming Language :: Python :: 3.13
28
+ Classifier: Programming Language :: Python :: 3.14
29
+ Classifier: Typing :: Typed
30
+ Requires-Python: >=3.10
31
+ Requires-Dist: django>=4.2
32
+ Provides-Extra: dev
33
+ Requires-Dist: coverage[toml]>=7.4; extra == 'dev'
34
+ Requires-Dist: django-stubs[compatible-mypy]>=5; extra == 'dev'
35
+ Requires-Dist: django-unfold>=0.60; extra == 'dev'
36
+ Requires-Dist: mypy>=1.10; extra == 'dev'
37
+ Requires-Dist: pre-commit>=4; extra == 'dev'
38
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
39
+ Requires-Dist: pytest-django>=4.8; extra == 'dev'
40
+ Requires-Dist: pytest-playwright>=0.5; extra == 'dev'
41
+ Requires-Dist: pytest>=8; extra == 'dev'
42
+ Requires-Dist: ruff>=0.6; extra == 'dev'
43
+ Requires-Dist: tox-uv>=1; extra == 'dev'
44
+ Requires-Dist: tox>=4; extra == 'dev'
45
+ Description-Content-Type: text/markdown
46
+
47
+ # django-form-matrix
48
+
49
+ [![Build status](https://github.com/rodolvbg/django-form-matrix/actions/workflows/pytest.yml/badge.svg)](https://github.com/rodolvbg/django-form-matrix/actions/workflows/pytest.yml)
50
+ [![PyPI version](https://img.shields.io/pypi/v/django-form-matrix.svg)](https://pypi.org/project/django-form-matrix/)
51
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-form-matrix)](https://pypi.org/project/django-form-matrix/)
52
+ [![PyPI - Django Version](https://img.shields.io/pypi/djversions/django-form-matrix)](https://pypi.org/project/django-form-matrix/)
53
+ [![Downloads](https://static.pepy.tech/personalized-badge/django-form-matrix?period=month&units=international_system&left_color=black&right_color=blue&left_text=Downloads/month)](https://pepy.tech/project/django-form-matrix)
54
+
55
+ A Django form field to fill a **table**: one row per item, one column per property and, in every cell, a form field of its own — a checkbox, a number, a choice, text. Rows can be grouped in collapsible sections, and checkbox columns get shortcuts to check a row, a column or a whole section.
56
+
57
+ It works in any Django form and in the admin. A ready-made **permission matrix** (apps → models → add/view/change/delete) is included as a use case.
58
+
59
+ ![A matrix with checkboxes, numbers and choices in its cells, in two sections](https://raw.githubusercontent.com/rodolvbg/django-form-matrix/HEAD/docs/screenshots/hero.png)
60
+
61
+ ## Installation
62
+
63
+ ```bash
64
+ pip install django-form-matrix
65
+ ```
66
+
67
+ Add `django_form_matrix` to `INSTALLED_APPS` (it ships a template, a script and a stylesheet):
68
+
69
+ ```python
70
+ INSTALLED_APPS = [
71
+ # ...
72
+ "django_form_matrix",
73
+ ]
74
+ ```
75
+
76
+ ## Quick start
77
+
78
+ ```python
79
+ from django import forms
80
+ from django_form_matrix import Column, MatrixField, Row
81
+
82
+
83
+ class PlanForm(forms.Form):
84
+ features = MatrixField(
85
+ rows=[
86
+ Row("invoices", "Invoices", section="Billing"),
87
+ Row("quotes", "Quotes", section="Billing"),
88
+ Row("exports", "Exports", section="Analytics", columns=["enabled"]),
89
+ ],
90
+ columns=[
91
+ Column("enabled", "Enabled"), # a checkbox unless said otherwise
92
+ Column(
93
+ "limit",
94
+ "Monthly limit",
95
+ forms.IntegerField(min_value=0, required=False),
96
+ ),
97
+ ],
98
+ )
99
+ ```
100
+
101
+ Render the form as usual and add `{{ form.media }}` (the admin does it for you). The cleaned value has one entry per row with the cells that exist:
102
+
103
+ ```python
104
+ form.cleaned_data["features"]
105
+ # {"invoices": {"enabled": True, "limit": 50},
106
+ # "quotes": {"enabled": False, "limit": None},
107
+ # "exports": {"enabled": False}}
108
+ ```
109
+
110
+ That is what a `JSONField` stores, so a `ModelForm` can save it directly.
111
+
112
+ ### Rows and columns
113
+
114
+ - `Column(key, label, field=BooleanField(required=False))`: the form field of the column. Its validation and its widget are Django's own, and a failing cell is reported with its row and column ("Invoices, Monthly limit: …").
115
+ - `Row(key, label, section=None, columns=None)`: `section` groups rows under a collapsible heading (rows without one go in a plain table); `columns` are the keys of the columns that have a cell in this row, the rest show a dash.
116
+ - `MatrixField(rows=…, columns=…)` is not required by default; with `required=True` at least one cell needs a value. `rows` and `columns` can be reassigned in a form's `__init__` to build the matrix dynamically.
117
+
118
+ Each cell is submitted as `<name>__<row key>__<column key>`, so it reads like any other widget.
119
+
120
+ ### Shortcuts
121
+
122
+ Checkbox columns get a checkbox in their heading, every row with checkboxes gets one at its start, and each section gets an "All". They show whether their cells are all, some or none checked, and the heading of a section counts the checked ones. A section opens by itself when something in it is filled.
123
+
124
+ ## Permissions
125
+
126
+ ```python
127
+ from django_form_matrix.contrib.permissions import PermissionMultipleChoiceField
128
+
129
+
130
+ class RoleAdmin(admin.ModelAdmin):
131
+ def formfield_for_manytomany(self, db_field, request, **kwargs):
132
+ if db_field.name == "permissions":
133
+ kwargs["form_class"] = PermissionMultipleChoiceField
134
+ return super().formfield_for_manytomany(db_field, request, **kwargs)
135
+ ```
136
+
137
+ ![The permission matrix in the admin](https://raw.githubusercontent.com/rodolvbg/django-form-matrix/HEAD/docs/screenshots/permissions.png)
138
+
139
+ One section per app, one row per model and a column per action; the other permissions of a model go in an "Others" column. It submits like a `CheckboxSelectMultiple`, so validation and saving do not change, and the names of the permissions are shown translated. Needs `django.contrib.auth`. [docs/examples/permissions.md](https://github.com/rodolvbg/django-form-matrix/blob/HEAD/docs/examples/permissions.md) shows how to build the same matrix by hand with `MatrixField`, with rows that come from the database, for when you need more control.
140
+
141
+ ## Customizing the HTML
142
+
143
+ Both templates are split in blocks, so a template of yours can extend one and change only a piece, without copying the rest:
144
+
145
+ ```django
146
+ {% extends "django_form_matrix/matrix.html" %}
147
+ {% block section_summary %}<strong>{{ section.name }}</strong>{% endblock %}
148
+ {% block cell %}<div class="my-cell">{{ cell.html }}</div>{% endblock %}
149
+ ```
150
+
151
+ Point a widget to it with `template_name`:
152
+
153
+ ```python
154
+ class MyMatrixWidget(MatrixWidget):
155
+ template_name = "myapp/my_matrix.html"
156
+ ```
157
+
158
+ | Block | `matrix.html` | `permissions.html` | What it is |
159
+ | --- | --- | --- | --- |
160
+ | `section_summary` | ✓ | ✓ | The heading of a section (its name and count) |
161
+ | `section_toggle` | ✓ | ✓ | The "All" checkbox of a section |
162
+ | `column_heading` | ✓ | ✓ | The heading of a column |
163
+ | `others_heading` | | ✓ | The heading of the "Others" column |
164
+ | `row_heading` | ✓ | ✓ | The heading of a row |
165
+ | `cell` | ✓ | | The widget of a cell (`{{ cell.html }}`) |
166
+ | `permission` | | ✓ | The checkbox of a permission |
167
+ | `other_permission` | | ✓ | A permission of the "Others" column |
168
+ | `missing_cell` | ✓ | ✓ | A cell a row does not have |
169
+ | `empty` | ✓ | ✓ | What shows when there is nothing to fill |
170
+
171
+ `{{ block.super }}` keeps the original of a block. The classes (`.form-matrix-*`) and the `data-form-matrix-*` attributes are what the stylesheet and the script look for: keep them in your blocks and the shortcuts keep working.
172
+
173
+ Django looks for the widget templates of a form in the `templates/` of the installed apps, **not** in the `DIRS` of your project. To override `django_form_matrix/matrix.html` itself by name from the project's templates, set `FORM_RENDERER = "django.forms.renderers.TemplatesSetting"`, or put the template in an app listed before `django_form_matrix` in `INSTALLED_APPS`. Extending it with your own `template_name`, as above, needs neither.
174
+
175
+ ## Styling
176
+
177
+ The stylesheet uses the colours of the Django admin when it is there. Anywhere else, set `--form-matrix-border`, `--form-matrix-heading-bg` and `--form-matrix-quiet` on `.form-matrix`.
178
+
179
+ ### Unfold
180
+
181
+ [django-unfold](https://github.com/unfoldadmin/django-unfold) does not define the variables of Django's admin, so add `UnfoldMatrixMixin` to the admin: it puts the matrix on Unfold's palette, light and dark, and styles the number, text and select cells like Unfold's own inputs.
182
+
183
+ ```python
184
+ from unfold.admin import ModelAdmin
185
+ from django_form_matrix.contrib.unfold import UnfoldMatrixMixin
186
+
187
+
188
+ @admin.register(Plan)
189
+ class PlanAdmin(UnfoldMatrixMixin, ModelAdmin):
190
+ form = PlanForm
191
+ ```
192
+
193
+ ![The matrix in Unfold](https://raw.githubusercontent.com/rodolvbg/django-form-matrix/HEAD/docs/screenshots/unfold.png)
194
+
195
+ The permission matrix, the use case this library started from, in Unfold:
196
+
197
+ ![The permission matrix in Unfold](https://raw.githubusercontent.com/rodolvbg/django-form-matrix/HEAD/docs/screenshots/unfold-permissions.png)
198
+
199
+ And in Unfold's dark mode:
200
+
201
+ ![The matrix in Unfold, dark](https://raw.githubusercontent.com/rodolvbg/django-form-matrix/HEAD/docs/screenshots/unfold-dark.png)
202
+
203
+ ## Translations
204
+
205
+ Ships a Spanish (`es`) translation: the matrix's headings ("Add", "View",
206
+ "Change", "Delete", "Others"), the permissions' names ("Can view plan") and
207
+ its messages follow the active language, like the rest of the admin.
208
+
209
+ ## Development
210
+
211
+ ```bash
212
+ uv sync && npm install
213
+ uv run pytest # Python + end-to-end (needs `uv run playwright install chromium`)
214
+ npm test # JS
215
+ cd example && uv run python manage.py runserver # demo: Plans and Roles in the admin
216
+ DJANGO_SETTINGS_MODULE=example.settings_unfold uv run python manage.py runserver # the same, in Unfold
217
+ ```
@@ -0,0 +1,19 @@
1
+ django_form_matrix/__init__.py,sha256=sqTm713ZUXTiw-iL3DvySAmREpr9EbCszk3pM4WFmYs,213
2
+ django_form_matrix/apps.py,sha256=36hiEwMuMP_WzHQxw-m_FLT-Nyybfk7PmwQWsbZVfZk,199
3
+ django_form_matrix/fields.py,sha256=w1t6YHAHeu1QK6jUg88WaauGtADrhMWaZMcnKFca4CQ,5073
4
+ django_form_matrix/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ django_form_matrix/widgets.py,sha256=S19O90fRHBnIfaxVWdxQYPoqMtEkOI01NE-VdWD9aWI,4913
6
+ django_form_matrix/contrib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ django_form_matrix/contrib/permissions.py,sha256=FUlbA49zvrpWZ7BER4ZCNd_6tRGv7XYafRQsPdvWp2Y,6063
8
+ django_form_matrix/contrib/unfold.py,sha256=5lD6314EIzQ0vPhjQKde6VeUP6hBSlN6nxQWE67FCfw,511
9
+ django_form_matrix/locale/es/LC_MESSAGES/django.mo,sha256=hHE3PsJ4OIOeQV_d9vyx6PyZ50GMsbmWg3nAohXGzhA,1066
10
+ django_form_matrix/locale/es/LC_MESSAGES/django.po,sha256=82TbUeBBK8AZy0dNxmcRyjWoMEePFESIf_3vb7Zj5jI,1664
11
+ django_form_matrix/static/django_form_matrix/css/matrix.css,sha256=ysX0PndeZeWtHXe0kcxpmrl9AUNdTLsQwI-qFgW3tnY,3264
12
+ django_form_matrix/static/django_form_matrix/css/unfold.css,sha256=WytH1lpM6W-O3vtQs5XbpeU02Hrb2wuDgBrdnJYZkhY,1199
13
+ django_form_matrix/static/django_form_matrix/js/matrix.js,sha256=nXFSuYcw5fwcU6e3Bzxj5-WzQIwC_Gvmrr__8LAhAgM,2420
14
+ django_form_matrix/templates/django_form_matrix/matrix.html,sha256=w3atBHQNHZo0N96FZzxOn_tC42g8KjUvkK6uONhzsIQ,2931
15
+ django_form_matrix/templates/django_form_matrix/permissions.html,sha256=qJGRK_fFHUAcPOAHK-AK8jFbHDYajYt8FjCh0NICCU0,4275
16
+ django_form_matrix-0.1.0.dist-info/METADATA,sha256=w-gS4RRUyithJcG2JLobWdV43o5js5LeHQ0XmIKpo10,10752
17
+ django_form_matrix-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
18
+ django_form_matrix-0.1.0.dist-info/licenses/LICENSE,sha256=0EJi4J2ISkSQsH8fR7SQvn4J66zEouFMPErgIRKn0cg,1072
19
+ django_form_matrix-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rodolfo Becerra
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.