Skip to content

Commit eefcb2f

Browse files
committed
INTPYTHON-483 Add EmbeddedModelArrayField
1 parent c5bfebf commit eefcb2f

File tree

17 files changed

+1676
-10
lines changed

17 files changed

+1676
-10
lines changed

.pre-commit-config.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ repos:
6161
rev: v1.1.1
6262
hooks:
6363
- id: doc8
64-
args: ["--ignore=D001"] # ignore line length
64+
# D000 Invalid class attribute value for "class" directive when using
65+
# * (keyword-only parameters separator).
66+
# D001 line length
67+
args: ["--ignore=D000,D001"]
6568
stages: [manual]
6669

6770
- repo: https://github.com/sirosen/check-jsonschema

django_mongodb_backend/fields/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
from .auto import ObjectIdAutoField
33
from .duration import register_duration_field
44
from .embedded_model import EmbeddedModelField
5+
from .embedded_model_array import EmbeddedModelArrayField
56
from .json import register_json_field
67
from .objectid import ObjectIdField
78

89
__all__ = [
910
"register_fields",
1011
"ArrayField",
12+
"EmbeddedModelArrayField",
1113
"EmbeddedModelField",
1214
"ObjectIdAutoField",
1315
"ObjectIdField",
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from django.db.models import Field
2+
3+
from .. import forms
4+
from . import EmbeddedModelField
5+
from .array import ArrayField
6+
7+
8+
class EmbeddedModelArrayField(ArrayField):
9+
def __init__(self, embedded_model, **kwargs):
10+
if "size" in kwargs:
11+
raise ValueError("EmbeddedModelArrayField does not support size.")
12+
super().__init__(EmbeddedModelField(embedded_model), **kwargs)
13+
self.embedded_model = embedded_model
14+
15+
def deconstruct(self):
16+
name, path, args, kwargs = super().deconstruct()
17+
if path == "django_mongodb_backend.fields.embedded_model_array.EmbeddedModelArrayField":
18+
path = "django_mongodb_backend.fields.EmbeddedModelArrayField"
19+
kwargs["embedded_model"] = self.embedded_model
20+
del kwargs["base_field"]
21+
return name, path, args, kwargs
22+
23+
def get_db_prep_value(self, value, connection, prepared=False):
24+
if isinstance(value, list | tuple):
25+
# Must call get_db_prep_save() rather than get_db_prep_value()
26+
# to transform model instances to dicts.
27+
return [self.base_field.get_db_prep_save(i, connection) for i in value]
28+
if value is not None:
29+
raise TypeError(
30+
f"Expected list of {self.embedded_model!r} instances, not {type(value)!r}."
31+
)
32+
return value
33+
34+
def formfield(self, **kwargs):
35+
# Skip ArrayField.formfield() which has some differences, including
36+
# unneeded "base_field", and "max_length" instead of "max_num".
37+
return Field.formfield(
38+
self,
39+
**{
40+
"form_class": forms.EmbeddedModelArrayField,
41+
"model": self.embedded_model,
42+
"max_num": self.max_size,
43+
"prefix": self.name,
44+
**kwargs,
45+
},
46+
)

django_mongodb_backend/forms/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .fields import (
2+
EmbeddedModelArrayField,
23
EmbeddedModelField,
34
ObjectIdField,
45
SimpleArrayField,
@@ -7,6 +8,7 @@
78
)
89

910
__all__ = [
11+
"EmbeddedModelArrayField",
1012
"EmbeddedModelField",
1113
"SimpleArrayField",
1214
"SplitArrayField",

django_mongodb_backend/forms/fields/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from .array import SimpleArrayField, SplitArrayField, SplitArrayWidget
22
from .embedded_model import EmbeddedModelField
3+
from .embedded_model_array import EmbeddedModelArrayField
34
from .objectid import ObjectIdField
45

56
__all__ = [
7+
"EmbeddedModelArrayField",
68
"EmbeddedModelField",
79
"SimpleArrayField",
810
"SplitArrayField",
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
from django import forms
2+
from django.core.exceptions import ValidationError
3+
from django.forms import formset_factory, model_to_dict
4+
from django.forms.models import modelform_factory
5+
from django.utils.html import format_html, format_html_join
6+
7+
8+
class EmbeddedModelArrayField(forms.Field):
9+
def __init__(self, model, *, prefix, max_num=None, extra_forms=3, **kwargs):
10+
self.model = model
11+
self.prefix = prefix
12+
self.formset = formset_factory(
13+
form=modelform_factory(model, fields="__all__"),
14+
can_delete=True,
15+
max_num=max_num,
16+
extra=extra_forms,
17+
validate_max=True,
18+
)
19+
kwargs["widget"] = EmbeddedModelArrayWidget()
20+
super().__init__(**kwargs)
21+
22+
def clean(self, value):
23+
if not value:
24+
return []
25+
formset = self.formset(value, prefix=self.prefix_override or self.prefix)
26+
if not formset.is_valid():
27+
raise ValidationError(formset.errors + formset.non_form_errors())
28+
cleaned_data = []
29+
for data in formset.cleaned_data:
30+
# The "delete" checkbox isn't part of model data and must be
31+
# removed. The fallback to True skips empty forms.
32+
if data.pop("DELETE", True):
33+
continue
34+
cleaned_data.append(self.model(**data))
35+
return cleaned_data
36+
37+
def has_changed(self, initial, data):
38+
formset = self.formset(data, initial=models_to_dicts(initial), prefix=self.prefix)
39+
return formset.has_changed()
40+
41+
def get_bound_field(self, form, field_name):
42+
# Nested embedded model form fields need a double prefix.
43+
# HACK: Setting self.prefix_override makes it available in clean()
44+
# which doesn't have access to the form.
45+
self.prefix_override = f"{form.prefix}-{self.prefix}" if form.prefix else None
46+
return EmbeddedModelArrayBoundField(form, self, field_name, self.prefix_override)
47+
48+
49+
class EmbeddedModelArrayBoundField(forms.BoundField):
50+
def __init__(self, form, field, name, prefix_override):
51+
super().__init__(form, field, name)
52+
self.formset = field.formset(
53+
self.data if form.is_bound else None,
54+
initial=models_to_dicts(self.initial),
55+
prefix=prefix_override if prefix_override else self.html_name,
56+
)
57+
58+
def __str__(self):
59+
body = format_html_join(
60+
"\n", "<tbody>{}</tbody>", ((form.as_table(),) for form in self.formset)
61+
)
62+
return format_html("<table>\n{}\n</table>\n{}", body, self.formset.management_form)
63+
64+
65+
class EmbeddedModelArrayWidget(forms.Widget):
66+
"""
67+
Extract the data for EmbeddedModelArrayFormField's formset.
68+
This widget is never rendered.
69+
"""
70+
71+
def value_from_datadict(self, data, files, name):
72+
return {field: value for field, value in data.items() if field.startswith(f"{name}-")}
73+
74+
75+
def models_to_dicts(models):
76+
"""
77+
Convert initial data (which is a list of model instances or None) to a
78+
list of dictionary data suitable for a formset.
79+
"""
80+
return [model_to_dict(model) for model in models or []]

django_mongodb_backend/operations.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,15 @@ def get_db_converters(self, expression):
111111
converters.append(self.convert_decimalfield_value)
112112
elif internal_type == "EmbeddedModelField":
113113
converters.append(self.convert_embeddedmodelfield_value)
114+
elif internal_type == "EmbeddedModelArrayField":
115+
converters.extend(
116+
[
117+
self._get_arrayfield_converter(converter)
118+
for converter in self.get_db_converters(
119+
Expression(output_field=expression.output_field.base_field)
120+
)
121+
]
122+
)
114123
elif internal_type == "JSONField":
115124
converters.append(self.convert_jsonfield_value)
116125
elif internal_type == "TimeField":

docs/make.bat

100644100755
File mode changed.

docs/source/ref/forms.rst

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,36 @@ Some MongoDB-specific fields are available in ``django_mongodb_backend.forms``.
2323
in this field's subform will have so that the names don't collide with
2424
fields in the main form.
2525

26+
``EmbeddedModelArrayField``
27+
---------------------------
28+
29+
.. class:: EmbeddedModelArrayField(model, *, prefix, max_num=None, extra_forms=3, **kwargs)
30+
31+
.. versionadded:: 5.2.0b1
32+
33+
A field which maps to a list of model instances. The field will render as a
34+
:class:`ModelFormSet <django.forms.models.BaseModelFormSet>`.
35+
36+
.. attribute:: model
37+
38+
This is a required argument that specifies the model class.
39+
40+
.. attribute:: prefix
41+
42+
This is a required argument that specifies the prefix that all fields
43+
in this field's formset will have so that the names don't collide with
44+
fields in the main form.
45+
46+
.. attribute:: max_num
47+
48+
This is an optional argument which specifies the maximum number of
49+
model instances that can be created.
50+
51+
.. attribute:: extra_forms
52+
53+
This argument specifies the number of blank forms that will be
54+
rendered by the formset.
55+
2656
``ObjectIdField``
2757
-----------------
2858

docs/source/ref/models/fields.rst

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ Some MongoDB-specific fields are available in ``django_mongodb_backend.fields``.
3535
:class:`~django.db.models.OneToOneField` and
3636
:class:`~django.db.models.ManyToManyField`) and file fields (
3737
:class:`~django.db.models.FileField` and
38-
:class:`~django.db.models.ImageField`). :class:`EmbeddedModelField` is
39-
also not (yet) supported.
38+
:class:`~django.db.models.ImageField`). For
39+
:class:`EmbeddedModelField`, use :class:`EmbeddedModelArrayField`.
4040

4141
It is possible to nest array fields - you can specify an instance of
4242
``ArrayField`` as the ``base_field``. For example::
@@ -256,7 +256,8 @@ These indexes use 0-based indexing.
256256
class Book(models.Model):
257257
author = EmbeddedModelField(Author)
258258

259-
See :doc:`/topics/embedded-models` for more details and examples.
259+
See :ref:`the embedded model topic guide <embedded-model-field-example>`
260+
for more details and examples.
260261

261262
.. admonition:: Migrations support is limited
262263

@@ -268,6 +269,36 @@ These indexes use 0-based indexing.
268269
created these models and then added an indexed field to ``Address``,
269270
the index created in the nested ``Book`` embed is not created.
270271

272+
``EmbeddedModelArrayField``
273+
---------------------------
274+
275+
.. class:: EmbeddedModelArrayField(embedded_model, max_size=None, **kwargs)
276+
277+
.. versionadded:: 5.2.0b1
278+
279+
Similar to :class:`EmbeddedModelField`, but stores a **list** of models of
280+
type ``embedded_model`` rather than a single instance.
281+
282+
.. attribute:: embedded_model
283+
284+
This is a required argument that works just like
285+
:attr:`EmbeddedModelField.embedded_model`.
286+
287+
.. attribute:: max_size
288+
289+
This is an optional argument.
290+
291+
If passed, the list will have a maximum size as specified, validated
292+
by forms and model validation, but not enforced by the database.
293+
294+
See :ref:`the embedded model topic guide
295+
<embedded-model-array-field-example>` for more details and examples.
296+
297+
.. admonition:: Migrations support is limited
298+
299+
As described above for :class:`EmbeddedModelField`,
300+
:djadmin:`makemigrations` does not yet detect changes to embedded models.
301+
271302
``ObjectIdAutoField``
272303
---------------------
273304

0 commit comments

Comments
 (0)