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.
- django_form_matrix/__init__.py +6 -0
- django_form_matrix/apps.py +7 -0
- django_form_matrix/contrib/__init__.py +0 -0
- django_form_matrix/contrib/permissions.py +177 -0
- django_form_matrix/contrib/unfold.py +23 -0
- django_form_matrix/fields.py +159 -0
- django_form_matrix/locale/es/LC_MESSAGES/django.mo +0 -0
- django_form_matrix/locale/es/LC_MESSAGES/django.po +73 -0
- django_form_matrix/py.typed +0 -0
- django_form_matrix/static/django_form_matrix/css/matrix.css +145 -0
- django_form_matrix/static/django_form_matrix/css/unfold.css +35 -0
- django_form_matrix/static/django_form_matrix/js/matrix.js +67 -0
- django_form_matrix/templates/django_form_matrix/matrix.html +78 -0
- django_form_matrix/templates/django_form_matrix/permissions.html +89 -0
- django_form_matrix/widgets.py +148 -0
- django_form_matrix-0.1.0.dist-info/METADATA +217 -0
- django_form_matrix-0.1.0.dist-info/RECORD +19 -0
- django_form_matrix-0.1.0.dist-info/WHEEL +4 -0
- django_form_matrix-0.1.0.dist-info/licenses/LICENSE +21 -0
|
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
|
+
)
|
|
Binary file
|
|
@@ -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
|
+
[](https://github.com/rodolvbg/django-form-matrix/actions/workflows/pytest.yml)
|
|
50
|
+
[](https://pypi.org/project/django-form-matrix/)
|
|
51
|
+
[](https://pypi.org/project/django-form-matrix/)
|
|
52
|
+
[](https://pypi.org/project/django-form-matrix/)
|
|
53
|
+
[](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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
194
|
+
|
|
195
|
+
The permission matrix, the use case this library started from, in Unfold:
|
|
196
|
+
|
|
197
|
+

|
|
198
|
+
|
|
199
|
+
And in Unfold's dark mode:
|
|
200
|
+
|
|
201
|
+

|
|
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,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.
|