Skip to content

Commit ac05a04

Browse files
committed
Be able to exclude attributes from JavaScript. Especially useful for preventing many-to-many fields from being re-created un-expectantly.
1 parent 93fe3d2 commit ac05a04

File tree

3 files changed

+140
-14
lines changed

3 files changed

+140
-14
lines changed

django_unicorn/components/unicorn_view.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ def render(self, init_js=False) -> str:
295295
"""
296296
Renders a UnicornView component with the public properties available. Delegates to a
297297
UnicornTemplateResponse to actually render a response.
298-
298+
299299
Args:
300300
param init_js: Whether or not to include the Javascript required to initialize the component.
301301
"""
@@ -333,12 +333,19 @@ def get_frontend_context_variables(self) -> str:
333333
attributes = self._attributes()
334334
frontend_context_variables.update(attributes)
335335

336+
exclude_field_attributes = ()
337+
336338
# Remove any field in `javascript_exclude` from `frontend_context_variables`
337339
if hasattr(self, "Meta") and hasattr(self.Meta, "javascript_exclude"):
338340
if isinstance(self.Meta.javascript_exclude, Sequence):
339341
for field_name in self.Meta.javascript_exclude:
340-
if field_name in frontend_context_variables:
341-
del frontend_context_variables[field_name]
342+
if "." in field_name:
343+
# Because the dictionary value could be an object, we can't just remove the attribute, so
344+
# store field attributes for later to remove them from the serialized dictionary
345+
exclude_field_attributes.append(field_name)
346+
else:
347+
if field_name in frontend_context_variables:
348+
del frontend_context_variables[field_name]
342349

343350
# Add cleaned values to `frontend_content_variables` based on the widget in form's fields
344351
form = self._get_form(attributes)
@@ -364,7 +371,8 @@ def get_frontend_context_variables(self) -> str:
364371
frontend_context_variables[key] = value
365372

366373
encoded_frontend_context_variables = serializer.dumps(
367-
frontend_context_variables
374+
frontend_context_variables,
375+
exclude_field_attributes=exclude_field_attributes,
368376
)
369377

370378
return encoded_frontend_context_variables
@@ -653,7 +661,7 @@ def create(
653661
to be differentiated. Optional.
654662
param parent: The parent component of the current component.
655663
param kwargs: Keyword arguments for the component passed in from the template. Defaults to `{}`.
656-
664+
657665
Returns:
658666
Instantiated `UnicornView` component.
659667
Raises `ComponentLoadError` if the component could not be loaded.

django_unicorn/serializer.py

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
22
from decimal import Decimal
33
from functools import lru_cache
4-
from typing import Dict, List
4+
from typing import Dict, List, Tuple, Any
55

66
from django.core.serializers import serialize
77
from django.db.models import (
@@ -141,7 +141,7 @@ def _json_serializer(obj):
141141
raise TypeError
142142

143143

144-
def _fix_floats(current: Dict, data: Dict = None, paths: List = []) -> None:
144+
def _fix_floats(current: Dict, data: Dict = None, paths: List = None) -> None:
145145
"""
146146
Recursively change any Python floats to a string so that JavaScript
147147
won't convert the float to an integer when deserializing.
@@ -153,6 +153,9 @@ def _fix_floats(current: Dict, data: Dict = None, paths: List = []) -> None:
153153
if data is None:
154154
data = current
155155

156+
if paths is None:
157+
paths = []
158+
156159
if isinstance(current, dict):
157160
for key, val in current.items():
158161
paths.append(key)
@@ -176,32 +179,76 @@ def _fix_floats(current: Dict, data: Dict = None, paths: List = []) -> None:
176179

177180

178181
@lru_cache(maxsize=128, typed=True)
179-
def _dumps(serialized_data):
182+
def _dumps(
183+
serialized_data: bytes, exclude_field_attributes: Tuple[str] = None
184+
) -> bytes:
185+
"""
186+
Dump serialized data with custom massaging to fix floats and remove specific keys as needed.
187+
"""
188+
180189
dict_data = orjson.loads(serialized_data)
181190
_fix_floats(dict_data)
191+
_exclude_field_attributes(dict_data, exclude_field_attributes)
182192

183-
dumped_data = orjson.dumps(dict_data).decode("utf-8")
193+
dumped_data = orjson.dumps(dict_data)
184194
return dumped_data
185195

186196

187-
def dumps(data: dict, fix_floats: bool = True) -> str:
197+
def _exclude_field_attributes(
198+
dict_data: Dict[Any, Any], exclude_field_attributes: Tuple[str] = None
199+
) -> None:
200+
"""
201+
Remove the field attribute from `dict_data`. Handles nested attributes with a dot.
202+
203+
Example:
204+
_exclude_field_attributes({"1": {"2": {"3": "4"}}}, ("1.2.3",)) == {"1": {"2": {}}}
205+
"""
206+
207+
if exclude_field_attributes:
208+
for field in exclude_field_attributes:
209+
field_splits = field.split(".")
210+
211+
if len(field_splits) > 2:
212+
remaining_field_attributes = field[field.index(".") + 1 :]
213+
remaining_dict_data = dict_data[field_splits[0]]
214+
215+
return _exclude_field_attributes(
216+
remaining_dict_data, (remaining_field_attributes,)
217+
)
218+
elif len(field_splits) == 2:
219+
(field_name, field_attr) = field_splits
220+
del dict_data[field_name][field_attr]
221+
222+
223+
def dumps(
224+
data: Dict, fix_floats: bool = True, exclude_field_attributes: Tuple[str] = None
225+
) -> str:
188226
"""
189227
Converts the passed-in dictionary to a string representation.
190228
191229
Handles the following objects: dataclass, datetime, enum, float, int, numpy, str, uuid,
192-
Django Model, Django QuerySet, any object with `to_json` method.
230+
Django Model, Django QuerySet, Pydantic models (`PydanticBaseModel`), any object with `to_json` method.
193231
194232
Args:
195233
param fix_floats: Whether any floats should be converted to strings. Defaults to `True`,
196234
but will be faster without it.
197-
198-
Returns a string which deviates from `orjson.dumps`, but seems more useful.
235+
param exclude_field_attributes: Tuple of strings with field attributes to remove, i.e. "1.2"
236+
to remove the key `2` from `{"1": {"2": "3"}}`
237+
238+
Returns a `str` instead of `bytes` (which deviates from `orjson.dumps`), but seems more useful.
199239
"""
200240

201241
serialized_data = orjson.dumps(data, default=_json_serializer)
202242

203243
if fix_floats:
204-
return _dumps(serialized_data)
244+
# Handle excluded field attributes in `_dumps` to reduce the amount of serialization/deserialization needed
245+
serialized_data = _dumps(
246+
serialized_data, exclude_field_attributes=exclude_field_attributes
247+
)
248+
elif exclude_field_attributes:
249+
dict_data = orjson.loads(serialized_data)
250+
_exclude_field_attributes(dict_data, exclude_field_attributes)
251+
serialized_data = orjson.dumps(dict_data)
205252

206253
return serialized_data.decode("utf-8")
207254

tests/serializer/test_dumps.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,3 +361,74 @@ class Book(BaseModel):
361361
actual = serializer.dumps(Book())
362362

363363
assert expected == actual
364+
365+
366+
def test_exclude_field_attributes():
367+
expected = '{"book":{"title":"The Grapes of Wrath"}}'
368+
369+
actual = serializer.dumps(
370+
{"book": {"title": "The Grapes of Wrath", "author": "John Steinbeck"}},
371+
exclude_field_attributes=("book.author",),
372+
)
373+
374+
assert expected == actual
375+
376+
377+
def test_exclude_field_attributes_no_fix_floats():
378+
expected = '{"book":{"title":"The Grapes of Wrath"}}'
379+
380+
actual = serializer.dumps(
381+
{"book": {"title": "The Grapes of Wrath", "author": "John Steinbeck"}},
382+
fix_floats=False,
383+
exclude_field_attributes=("book.author",),
384+
)
385+
386+
assert expected == actual
387+
388+
389+
def test_exclude_field_attributes_nested():
390+
expected = '{"classic":{"book":{"title":"The Grapes of Wrath"}}}'
391+
392+
actual = serializer.dumps(
393+
{
394+
"classic": {
395+
"book": {"title": "The Grapes of Wrath", "author": "John Steinbeck"}
396+
}
397+
},
398+
exclude_field_attributes=("classic.book.author",),
399+
)
400+
401+
assert expected == actual
402+
403+
404+
def test_exclude_field_attributes_invalid_field_attribute():
405+
expected = '{"book":{"title":"The Grapes of Wrath","author":"John Steinbeck"}}'
406+
407+
actual = serializer.dumps(
408+
{"book": {"title": "The Grapes of Wrath", "author": "John Steinbeck"}},
409+
exclude_field_attributes=("blob"),
410+
)
411+
412+
assert expected == actual
413+
414+
415+
def test_exclude_field_attributes_empty_string():
416+
expected = '{"book":{"title":"The Grapes of Wrath","author":"John Steinbeck"}}'
417+
418+
actual = serializer.dumps(
419+
{"book": {"title": "The Grapes of Wrath", "author": "John Steinbeck"}},
420+
exclude_field_attributes=(""),
421+
)
422+
423+
assert expected == actual
424+
425+
426+
def test_exclude_field_attributes_none():
427+
expected = '{"book":{"title":"The Grapes of Wrath","author":"John Steinbeck"}}'
428+
429+
actual = serializer.dumps(
430+
{"book": {"title": "The Grapes of Wrath", "author": "John Steinbeck"}},
431+
exclude_field_attributes=None,
432+
)
433+
434+
assert expected == actual

0 commit comments

Comments
 (0)