├── .coveragerc ├── .editorconfig ├── .github └── workflows │ ├── publish.yml │ └── tests.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── entangled ├── __init__.py ├── forms.py └── utils.py ├── manage.py ├── pytest.ini ├── setup.py └── tests ├── __init__.py ├── conftest.py ├── models.py ├── settings.py ├── test_entangled.py ├── test_inheritance.py ├── test_modelform_factory.py └── test_retangled.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = entangled 4 | 5 | [report] 6 | exclude_lines = 7 | if self.debug: 8 | pragma: no cover 9 | raise NotImplementedError 10 | if __name__ == .__main__.: 11 | ignore_errors = True 12 | omit = 13 | tests/* -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | indent_style = tab 11 | indent_size = 4 12 | 13 | [*.py] 14 | max_line_length = 119 15 | indent_style = space 16 | 17 | [*.rst] 18 | max_line_length = 100 19 | 20 | [*.json] 21 | indent_style = space 22 | indent_size = 2 23 | 24 | [*.md] 25 | trim_trailing_whitespace = false 26 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish django-entangled 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | publish: 10 | name: "Publish release" 11 | runs-on: "ubuntu-latest" 12 | 13 | environment: 14 | name: deploy 15 | 16 | strategy: 17 | matrix: 18 | python-version: ["3.9"] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v2 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | python -m pip install build --user 30 | - name: Build 🐍 Python 📦 Package 31 | run: python -m build --sdist --wheel --outdir dist/ 32 | - name: Publish 🐍 Python 📦 Package to PyPI 33 | if: startsWith(github.ref, 'refs/tags') 34 | uses: pypa/gh-action-pypi-publish@master 35 | with: 36 | password: ${{ secrets.PYPI_API_TOKEN_ENTANGLED }} 37 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - feat/* 8 | - fix/* 9 | pull_request: 10 | branches: 11 | - master 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 19 | django-version: ["4.2", "5.0", "5.1"] 20 | exclude: 21 | - python-version: 3.8 22 | django-version: 5.0 23 | - python-version: 3.8 24 | django-version: 5.1 25 | - python-version: 3.9 26 | django-version: 5.0 27 | - python-version: 3.9 28 | django-version: 5.1 29 | 30 | steps: 31 | - uses: actions/checkout@v2 32 | - name: Set up Python ${{ matrix.python-version }} 33 | uses: actions/setup-python@v2 34 | with: 35 | python-version: ${{ matrix.python-version }} 36 | - name: Upgrade PIP 37 | run: python -m pip install --upgrade pip 38 | - name: Install Django 39 | run: python -m pip install "Django==${{ matrix.django-version }}.*" 40 | - name: Lint with flake8 41 | run: | 42 | python -m pip install flake8 43 | # stop the build if there are Python syntax errors or undefined names 44 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 45 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 46 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 47 | - name: Test with pytest 48 | run: | 49 | python -m pip install beautifulsoup4 lxml pytest pytest-django pytest-cov coverage 50 | python -m pytest -v tests 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.pot 3 | *.pyc 4 | .project 5 | .pydevproject 6 | .settings 7 | .coverage 8 | .DS_Store 9 | .sass-cache 10 | .cache 11 | .pytest_cache 12 | .venv 13 | *.db 14 | *.egg-info 15 | .eggs 16 | .tox 17 | build 18 | env* 19 | dist 20 | htmlcov 21 | node_modules/ 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Changes 2 | 3 | - 0.6.2 4 | * Fix regression introduced in 0.6: In `EntangledModelFormMixin` use `_clean_form` to untangle fields rather than 5 | `_post_clean` to remain compatible with Django's formsets. 6 | 7 | - 0.6.1 8 | * Fix: Fully exclude fields not listed in `Meta.entangled_fields`, `Meta.untangled_field` or `Meta.fields`. 9 | 10 | - 0.6 11 | * Add support for Django's `modelform_factory` and `Meta.fields` option. 12 | * Add support for Django 5.0, 5.1 13 | * Drop support for Django 4.1, 4.0, 3.2, 2.2 14 | 15 | - 0.5.4 16 | * Add support for Django-4.1 and 4.2. 17 | * Confirm support for Python 3.11 18 | 19 | - 0.5.3 20 | * Load external JSONField if used with Django-2.2. 21 | 22 | - 0.5.2 23 | * Revert "Specify exceptions in helper functions". It introduced 24 | a regression in other packages using it. 25 | 26 | - 0.5.1 27 | * Specify exceptions in helper functions. 28 | * Allow initial data to be missing from entangled fields. 29 | 30 | - 0.5 31 | * Drop support for Django versions below 3.2. 32 | * Drop support for external jsonfield implementations. 33 | 34 | - 0.4 35 | * Allow nested structures for stored JSON data. 36 | * Functions `get_related_object` and `get_related_queryset` have been moved from module 37 | `entangled.forms` into module `entangled.utils`. 38 | 39 | - 0.3.1 40 | * No functional changes. 41 | * Add support for Django-3.1 and Python-3.8. 42 | * Drop support for Django<2.1 and Python-3.5. 43 | 44 | - 0.3 45 | * Add support for `ModelMultipleChoiceField`. 46 | * Fix: Make a deep copy of `entangled_fields` and `untangled_fields` before merging. 47 | * Add covenience class `EntangledModelForm`. 48 | * Moving data from entangled fields onto their compressed representation, now is performed after 49 | the form has performed its own `clean()`-call, so that accessing form fields is more natural. 50 | * Add functions `get_related_object` and `get_related_queryset` to get the model object from its 51 | JSON representation. 52 | 53 | - 0.2 54 | * Introduce `Meta`-option `untangled_fields`, because the approach in 0.1 didn't always work. 55 | * Use `formfield()`-method, for portability reasons with Django's Postgres JSON field. 56 | 57 | - 0.1 58 | * Initial release. 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Jacob Rief 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. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include setup.py 4 | recursive-include entangled *.py 5 | recursive-exclude * *.pyc 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-entangled 2 | 3 | Edit JSON-Model Fields using a Standard Django Form. 4 | 5 | [](https://travis-ci.org/jrief/django-entangled) 6 | [](https://codecov.io/github/jrief/django-entangled?branch=master) 7 | []() 8 | [](https://https://pypi.python.org/pypi/django-entangled) 9 | []() 10 | 11 | 12 | ## Use-Case 13 | 14 | A Django Model may contain fields which accept arbitrary data stored as JSON. Django itself, provides a 15 | [JSON field](https://docs.djangoproject.com/en/stable/ref/models/fields/#django.db.models.JSONField) to store arbitrary 16 | serializable data. 17 | 18 | When creating a form from a model, the input field associated with a JSON field, typically is a ``. 19 | This textarea widget is very inpracticable for editing, because it just contains a textual representation of that 20 | object notation. One possibility is to use a generic [JSON editor](https://github.com/josdejong/jsoneditor), 21 | which with some JavaScript, transforms the widget into an attribute-value-pair editor. This approach however requires 22 | us to manage the field keys ourselfs. It furthermore prevents us from utilizing all the nice features provided by the 23 | Django Form framework, such as field validation, normalization of data and the usage of foreign keys. 24 | 25 | By using **django-entangled**, one can use a Django `ModelForm`, and store all, 26 | or a subset of that form fields in one or more JSON fields inside of the associated model. 27 | 28 | 29 | ## Installation 30 | 31 | Simply install this Django app, for instance by invoking: 32 | 33 | ```bash 34 | pip install django-entangled 35 | ``` 36 | 37 | There is no need to add any configuration directives to the project's `settings.py`. 38 | 39 | 40 | ## Example 41 | 42 | Say, we have a Django model to describe a bunch of different products. The name and the price fields are common to all 43 | products, whereas the properties can vary depending on its product type. Since we don't want to create a different 44 | product model for each product type, we use a JSON field to store these arbitrary properties. 45 | 46 | ```python 47 | from django.db import models 48 | 49 | class Product(models.Model): 50 | name = models.CharField(max_length=50) 51 | price = models.DecimalField(max_digits=5, decimal_places=2) 52 | properties = models.JSONField() 53 | ``` 54 | 55 | In a typical form editing view, we would create a form inheriting from 56 | [ModelForm](https://docs.djangoproject.com/en/stable/topics/forms/modelforms/#modelform) and refer to this model using 57 | the `model` attribute in its `Meta`-class. Then the `properties`-field would show up as unstructured JSON, rendered 58 | inside a ``. This definitely is not what we want! Instead, we create a typical Django Form 59 | using the alternative class `EntangledModelForm`. 60 | 61 | ```python 62 | from django.contrib.auth import get_user_model 63 | from django.forms import fields, models 64 | from entangled.forms import EntangledModelForm 65 | from .models import Product 66 | 67 | class ProductForm(EntangledModelForm): 68 | color = fields.RegexField( 69 | regex=r'^#[0-9a-f]{6}$', 70 | ) 71 | size = fields.ChoiceField( 72 | choices=[('s', "small"), ('m', "medium"), ('l', "large"), ('xl', "extra large")], 73 | ) 74 | tenant = models.ModelChoiceField( 75 | queryset=get_user_model().objects.filter(is_staff=True), 76 | ) 77 | 78 | class Meta: 79 | model = Product 80 | entangled_fields = {'properties': ['color', 'size', 'tenant']} # fields provided by this form 81 | untangled_fields = ['name', 'price'] # these fields are provided by the Product model 82 | ``` 83 | 84 | In case our form inherits from another `ModelForm`, rewrite the class declarartion as: 85 | 86 | ```python 87 | class ProductForm(EntangledModelFormMixin, BaseProductForm): 88 | ... 89 | ``` 90 | 91 | In addition, we add a special dictionary named `entangled_fields` to our `Meta`-options. In this dictionary, the key 92 | (here `'properties'`) refers to the JSON-field in our model `Product`. The value (here `['color', 'size', 'tenant']`) 93 | is a list of named form fields, declared in our form- or base-class of thereof. This allows us to assign all standard 94 | Django form fields to arbitrary JSON fields declared in our Django model. Moreover, we can even use a 95 | `ModelChoiceField` or a `ModelMultipleChoiceField` to refer to another model object using a 96 | [generic relation](https://docs.djangoproject.com/en/stable/ref/contrib/contenttypes/#generic-relations). 97 | 98 | Since in this form we also want to access the non-JSON fields from our Django model, we add a list named 99 | `untangled_fields` to our `Meta`-options. In this list, (here `['name', 'price']`) we refer to the non-JSON fields 100 | in our model `Product`. From both of these iterables, `entangled_fields` and `untangled_fields`, the parent class 101 | `EntangledModelForm` then builds the `Meta`-option `fields`, otherwise required. Alternatively, you can use `fields` 102 | to manage which entangled **and** untangled fields are shown. If `fields` is not defined, **django-entangled** will 103 | create it on the fly, based on the keys in `entangled_fields` and on the listed `untangled_fields`. 104 | 105 | We can use this form in any Django form view. A typical use-case is the built-in Django `ModelAdmin`: 106 | 107 | ```python 108 | from django.contrib import admin 109 | from .models import Product 110 | from .forms import ProductForm 111 | 112 | @admin.register(Product) 113 | class ProductAdmin(admin.ModelAdmin): 114 | form = ProductForm 115 | ``` 116 | 117 | Since the form used by this `ModelAdmin`-class 118 | [can not be created dynamically](https://docs.djangoproject.com/en/stable/ref/contrib/admin/#django.contrib.admin.ModelAdmin.form), 119 | we have to declare it explicitly using the `form`-attribute. This is the only change which has to be performed, in 120 | order to store arbitrary content inside our JSON model-fields. 121 | 122 | 123 | ## Nested Data Structures 124 | 125 | Sometimes it can be desirable to store the data in a nested hierarchy of dictionaries, rather than having all 126 | attribute-value-pairs in the first level of our JSON field. This can for instance be handy when merging more than one 127 | form, all themselves ineriting from `EntangledModelFormMixin`. 128 | 129 | Say that we have different types of products, all of which share the same base product form: 130 | 131 | ```python 132 | from django.contrib.auth import get_user_model 133 | from django.forms import models 134 | from entangled.forms import EntangledModelFormMixin 135 | from .models import Product 136 | 137 | class BaseProductForm(EntangledModelFormMixin): 138 | tenant = models.ModelChoiceField( 139 | queryset=get_user_model().objects.filter(is_staff=True), 140 | ) 141 | 142 | class Meta: 143 | model = Product 144 | entangled_fields = {'properties': ['tenant']} 145 | untangled_fields = ['name', 'price'] 146 | ``` 147 | 148 | In order to specialize our base product towards, say clothing, we typically would inherit from the base form 149 | and add some additional fields, here `color` and `size`: 150 | 151 | ```python 152 | from django.forms import fields 153 | from .forms import BaseProductForm 154 | from .models import Product 155 | 156 | class ClothingProductForm(BaseProductForm): 157 | color = fields.RegexField( 158 | regex=r'^#[0-9a-f]{6}$', 159 | ) 160 | size = fields.ChoiceField( 161 | choices=[('s', "small"), ('m', "medium"), ('l', "large"), ('xl', "extra large")], 162 | ) 163 | 164 | class Meta: 165 | model = Product 166 | entangled_fields = {'properties': ['color', 'size']} 167 | retangled_fields = {'color': 'variants.color', 'size': 'variants.size'} 168 | ``` 169 | 170 | By adding a name mapping from our existing field names, we can group the fields `color` and `size` 171 | into a sub-dictionary named `variants` inside our `properties` fields. Such a field mapping is 172 | declared through the optional Meta-option `retangled_fields`. In this dictionary, all entries are 173 | optional; if a field name is missing, it just maps to itself. 174 | 175 | This mapping table can also be used to map field names to other keys inside the resulting JSON 176 | datastructure. This for instance is handy to map fields containg an underscore into field-names 177 | containing instead a dash. 178 | 179 | 180 | ## Caveats 181 | 182 | Due to the nature of JSON, indexing and thus building filters or sorting rules based on the fields content is not as 183 | simple, as with standard model fields. Therefore, this approach is best suited, if the main focus is to store data, 184 | rather than digging through data. 185 | 186 | Foreign keys are stored as `"fieldname": {"model": "appname.modelname", "pk": 1234}` in our JSON field, meaning that 187 | we have no database constraints. If a target object is deleted, that foreign key points to nowhere. Therefore always 188 | keep in mind, that we don't have any referential integrity and hence must write our code in a defensive manner. 189 | 190 | 191 | ## Contributing to the Project 192 | 193 | * Please ask question on the [discussion board](https://github.com/jrief/django-entangled/discussions). 194 | * Ideas for new features shall as well be discussed on that board. 195 | * The [issue tracker](https://github.com/jrief/django-entangled/issues) shall *exclusively* be used to report bugs. 196 | * Except for very small fixes (typos etc.), do not open a pull request without an issue. 197 | * Before writing code, adopt your IDE to respect the project's [.editorconfig](https://github.com/jrief/django-entangled/blob/master/.editorconfig). 198 | 199 | 200 | [](https://twitter.com/jacobrief) 201 | -------------------------------------------------------------------------------- /entangled/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.6.2' 2 | -------------------------------------------------------------------------------- /entangled/forms.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import traceback 3 | from copy import deepcopy, copy 4 | from warnings import warn 5 | 6 | from django.apps import apps 7 | from django.core.exceptions import ObjectDoesNotExist 8 | from django import forms 9 | from django.forms.models import ( 10 | ModelChoiceField, 11 | ModelMultipleChoiceField, 12 | ModelFormMetaclass, 13 | ModelForm, 14 | ) 15 | from django.forms.fields import Field 16 | from django.forms.widgets import Widget 17 | from django.db.models import JSONField, Model, QuerySet 18 | 19 | 20 | class InvisibleWidget(Widget): 21 | @property 22 | def is_hidden(self): 23 | return True 24 | 25 | def value_omitted_from_data(self, data, files, name): 26 | return False 27 | 28 | def render(self, name, value, attrs=None, renderer=None): 29 | return "" 30 | 31 | 32 | class EntangledField(Field): 33 | """ 34 | A pseudo field, which can be used to mimic a field value, which actually is not rendered inside the form. 35 | """ 36 | widget = InvisibleWidget 37 | 38 | def __init__(self, required=False, *args, **kwargs): 39 | # EntangledField is not required by default 40 | super().__init__(required=required, *args, **kwargs) 41 | 42 | 43 | class EntangledFormMetaclass(ModelFormMetaclass): 44 | def __new__(cls, class_name, bases, attrs): 45 | attrs.setdefault("Meta", type("Meta", (), {})) 46 | untangled_fields = list(getattr(attrs["Meta"], "untangled_fields", [])) 47 | entangled_fields = deepcopy(getattr(attrs["Meta"], "entangled_fields", {})) 48 | retangled_fields = deepcopy(getattr(attrs["Meta"], "retangled_fields", {})) 49 | 50 | # Merge untangled and entangled fields from base classes 51 | for base in bases: 52 | if hasattr(base, "_meta"): 53 | untangled_fields = getattr(base._meta, "untangled_fields", []) + untangled_fields 54 | for key, fields in getattr(base._meta, "entangled_fields", {}).items(): 55 | existing_fields = entangled_fields.setdefault(key, []) 56 | entangled_fields[key] = [ 57 | field for field in fields if field not in existing_fields 58 | ] + existing_fields 59 | for entangled_list in entangled_fields.values(): 60 | for ef in entangled_list: 61 | if ef not in retangled_fields: 62 | retangled_fields[ef] = ef 63 | 64 | # Modify entangled fields to respect Meta.fields and Meta.exclude 65 | fields = getattr(attrs["Meta"], "fields", None) 66 | # Adjust fields 67 | if entangled_fields: 68 | if fields == forms.ALL_FIELDS or fields is None: 69 | # This includes the case where no Meta.fields is specified 70 | # Explicitly list the entangled and untangled fields - remove excluded fields 71 | 72 | fields_to_delete = set(getattr(attrs["Meta"], "exclude", [])) 73 | for field_name in entangled_fields.keys(): 74 | entangled_fields[field_name] = [ 75 | field for field in entangled_fields[field_name] if field not in fields_to_delete 76 | ] 77 | attrs[field_name] = EntangledField() 78 | attrs["Meta"].fields = ( 79 | fields or 80 | cls._create_fields_option(untangled_fields, entangled_fields, fields_to_delete) 81 | ) 82 | else: 83 | fieldset = list(fields) # Create a copy 84 | # Alter remove fields not listed in Meta.fields 85 | fields_to_delete = set(itertools.chain(*entangled_fields.values())) - set(fieldset) 86 | fields_to_delete.update(set(untangled_fields) - set(fieldset)) 87 | # Remove fields not listed in Meta.fields from entangled_fields 88 | for field_name in entangled_fields.keys(): 89 | entangled_fields[field_name] = [ 90 | field for field in entangled_fields[field_name] if field in fieldset 91 | ] 92 | # Ensure the JSON field is declared as EntangledField 93 | attrs[field_name] = EntangledField() if entangled_fields[field_name] else None 94 | # Add the json fields needed to the fieldset 95 | for field, field_list in entangled_fields.items(): 96 | if field_list: 97 | fieldset.append(field) 98 | attrs["Meta"].fields = fieldset 99 | 100 | # Shadow fields not listed in Meta.fields 101 | for field_name in fields_to_delete: 102 | attrs[field_name] = None 103 | 104 | new_class = super().__new__(cls, class_name, bases, attrs) 105 | 106 | # perform some model checks 107 | for modelfield_name in entangled_fields.keys(): 108 | for field_name in entangled_fields[modelfield_name]: 109 | assert ( 110 | field_name in new_class.base_fields 111 | ), "Field {} listed in `{}.Meta.entangled_fields['{}']` is missing in Form declaration".format( 112 | field_name, class_name, modelfield_name 113 | ) 114 | 115 | new_class._meta.entangled_fields = entangled_fields 116 | new_class._meta.untangled_fields = untangled_fields 117 | new_class._meta.retangled_fields = retangled_fields 118 | return new_class 119 | 120 | @classmethod 121 | def _create_fields_option(cls, untangled_fields, entangled_fields, fields_to_delete): 122 | fields = list(untangled_fields) # creates a copy for modification 123 | for entangled in entangled_fields.values(): 124 | fields += entangled 125 | fields += list(entangled_fields.keys()) 126 | for field in fields_to_delete: 127 | if field in fields: 128 | fields.remove(field) 129 | return fields 130 | 131 | 132 | class EntangledModelFormMixin(metaclass=EntangledFormMetaclass): 133 | def __init__(self, *args, **kwargs): 134 | opts = self._meta 135 | if "instance" in kwargs and kwargs["instance"]: 136 | initial = kwargs.get("initial", {}) 137 | for field_name, assigned_fields in opts.entangled_fields.items(): 138 | for af in assigned_fields: 139 | reference = getattr(kwargs["instance"], field_name) 140 | try: 141 | for part in opts.retangled_fields[af].split("."): 142 | reference = reference[part] 143 | except (KeyError, TypeError): 144 | continue 145 | if isinstance(self.base_fields[af], ModelMultipleChoiceField): 146 | try: 147 | Model = apps.get_model(reference["model"]) 148 | initial[af] = Model.objects.filter( 149 | pk__in=reference["p_keys"] 150 | ) 151 | except (KeyError, TypeError): 152 | pass 153 | elif isinstance(self.base_fields[af], ModelChoiceField): 154 | try: 155 | Model = apps.get_model(reference["model"]) 156 | initial[af] = Model.objects.get(pk=reference["pk"]) 157 | except (KeyError, ObjectDoesNotExist, TypeError): 158 | pass 159 | else: 160 | initial[af] = reference 161 | kwargs.setdefault("initial", initial) 162 | super().__init__(*args, **kwargs) 163 | 164 | def _clean_form(self): 165 | opts = self._meta 166 | super()._clean_form() 167 | cleaned_data = { 168 | f: self.cleaned_data[f] 169 | for f in opts.untangled_fields 170 | if f in self.cleaned_data 171 | } 172 | for field_name, assigned_fields in opts.entangled_fields.items(): 173 | # Keep other fields in JSON 174 | if self.instance and hasattr(self.instance, field_name): 175 | cleaned_data[field_name] = getattr(self.instance, field_name) or {} 176 | else: 177 | cleaned_data[field_name] = {} 178 | for af in assigned_fields: 179 | if af not in self.cleaned_data: 180 | continue 181 | bucket = cleaned_data[field_name] 182 | af_parts = opts.retangled_fields[af].split(".") 183 | for part in af_parts[:-1]: 184 | bucket = bucket.setdefault(part, {}) 185 | part = af_parts[-1] 186 | if isinstance( 187 | self.base_fields[af], ModelMultipleChoiceField 188 | ) and isinstance(self.cleaned_data[af], QuerySet): 189 | meta = self.cleaned_data[af].model._meta 190 | value = { 191 | "model": "{}.{}".format(meta.app_label, meta.model_name), 192 | "p_keys": list( 193 | self.cleaned_data[af].values_list("pk", flat=True) 194 | ), 195 | } 196 | elif isinstance(self.base_fields[af], ModelChoiceField) and isinstance( 197 | self.cleaned_data[af], Model 198 | ): 199 | meta = self.cleaned_data[af]._meta 200 | value = { 201 | "model": "{}.{}".format(meta.app_label, meta.model_name), 202 | "pk": self.cleaned_data[af].pk, 203 | } 204 | else: 205 | value = self.cleaned_data[af] 206 | bucket[part] = value 207 | self.cleaned_data = cleaned_data 208 | 209 | 210 | class EntangledModelForm(EntangledModelFormMixin, ModelForm): 211 | """ 212 | A convenience class to create entangled model forms. 213 | """ 214 | 215 | 216 | def get_related_object(scope, field_name): 217 | from . import utils 218 | 219 | warn("Please import 'get_related_object' from entangled.utils", DeprecationWarning) 220 | return utils.get_related_object(scope, field_name) 221 | 222 | 223 | def get_related_queryset(scope, field_name): 224 | from . import utils 225 | 226 | warn( 227 | "Please import 'get_related_queryset' from entangled.utils", DeprecationWarning 228 | ) 229 | return utils.get_related_queryset(scope, field_name) 230 | -------------------------------------------------------------------------------- /entangled/utils.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | 3 | 4 | def get_related_object(scope, field_name): 5 | """ 6 | Returns the related field, referenced by the content of a ModelChoiceField. 7 | """ 8 | try: 9 | Model = apps.get_model(scope[field_name]['model']) 10 | relobj = Model.objects.get(pk=scope[field_name]['pk']) 11 | except: 12 | relobj = None 13 | return relobj 14 | 15 | 16 | def get_related_queryset(scope, field_name): 17 | """ 18 | Returns the related queryset, referenced by the content of a ModelChoiceField. 19 | """ 20 | try: 21 | Model = apps.get_model(scope[field_name]['model']) 22 | queryset = Model.objects.filter(pk__in=scope[field_name]['p_keys']) 23 | except: 24 | queryset = None 25 | return queryset 26 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | # This file only exists to make pytest-django work. 2 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = tests.settings 3 | addopts = 4 | -v 5 | --cov=entangled 6 | --tb=native 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import setup, find_packages 3 | from entangled import __version__ 4 | 5 | 6 | with open('README.md') as fh: 7 | long_description = fh.read() 8 | 9 | 10 | CLASSIFIERS = [ 11 | 'Development Status :: 5 - Production/Stable', 12 | 'Environment :: Web Environment', 13 | 'Framework :: Django', 14 | 'Intended Audience :: Developers', 15 | 'License :: OSI Approved :: MIT License', 16 | 'Operating System :: OS Independent', 17 | 'Programming Language :: Python', 18 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 19 | 'Programming Language :: Python :: 3.9', 20 | 'Programming Language :: Python :: 3.10', 21 | 'Programming Language :: Python :: 3.11', 22 | 'Programming Language :: Python :: 3.12', 23 | 'Framework :: Django :: 4.2', 24 | 'Framework :: Django :: 5.0', 25 | 'Framework :: Django :: 5.1', 26 | ] 27 | 28 | setup( 29 | name='django-entangled', 30 | version=__version__, 31 | description='Edit JSON field using Django Model Form', 32 | author='Jacob Rief', 33 | author_email='jacob.rief@gmail.com', 34 | url='https://github.com/jrief/django-entangled', 35 | packages=find_packages(), 36 | install_requires=[ 37 | 'django>=2.1', 38 | ], 39 | license='MIT', 40 | platforms=['OS Independent'], 41 | keywords=['Django Forms', 'JSON'], 42 | classifiers=CLASSIFIERS, 43 | long_description=long_description, 44 | long_description_content_type='text/markdown', 45 | include_package_data=True, 46 | zip_safe=False 47 | ) 48 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrief/django-entangled/8136af5a79ae4f11424a7369dec68a1a60f2a8ff/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from django.contrib.auth import get_user_model 4 | 5 | from .models import Category 6 | 7 | 8 | @pytest.fixture(autouse=True) 9 | def tenants(): 10 | User = get_user_model() 11 | User.objects.create(username='John') 12 | User.objects.create(username='Mary') 13 | return User.objects.all() 14 | 15 | 16 | @pytest.fixture(autouse=True) 17 | def categories(): 18 | Category.objects.create(identifier='Paraphernalia') 19 | Category.objects.create(identifier='Detergents') 20 | return Category.objects.all() 21 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db.models import CharField, JSONField, Model 2 | 3 | 4 | class Category(Model): 5 | identifier = CharField( 6 | max_length=10, 7 | ) 8 | 9 | def __str__(self): 10 | return self.identifier 11 | 12 | 13 | class Product(Model): 14 | name = CharField( 15 | max_length=20, 16 | blank=True, 17 | null=True, 18 | ) 19 | dummy_field = CharField(max_length=42, blank=True, null=True) 20 | properties = JSONField() 21 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = 'test' 2 | 3 | SITE_ID = 1 4 | 5 | DATABASES = { 6 | 'default': { 7 | 'ENGINE': 'django.db.backends.sqlite3', 8 | 'NAME': ':memory:', 9 | } 10 | } 11 | 12 | TEMPLATES = [{ 13 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 14 | 'APP_DIRS': True, 15 | 'OPTIONS': { 16 | 'context_processors': [ 17 | 'django.contrib.auth.context_processors.auth', 18 | 'django.template.context_processors.debug', 19 | 'django.template.context_processors.i18n', 20 | 'django.template.context_processors.media', 21 | 'django.template.context_processors.static', 22 | 'django.template.context_processors.tz', 23 | 'django.template.context_processors.csrf', 24 | 'django.template.context_processors.request', 25 | 'django.contrib.messages.context_processors.messages', 26 | ] 27 | } 28 | }] 29 | 30 | MIDDLEWARE = [ 31 | 'django.contrib.sessions.middleware.SessionMiddleware', 32 | 'django.middleware.csrf.CsrfViewMiddleware', 33 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 34 | 'django.contrib.messages.middleware.MessageMiddleware', 35 | 'django.middleware.locale.LocaleMiddleware', 36 | 'django.middleware.common.CommonMiddleware', 37 | ] 38 | 39 | INSTALLED_APPS = [ 40 | 'django.contrib.auth', 41 | 'django.contrib.contenttypes', 42 | 'django.contrib.sessions', 43 | 'django.contrib.sites', 44 | 'django.contrib.messages', 45 | 'django.contrib.admin', 46 | 'django.contrib.staticfiles', 47 | 'entangled', 48 | 'tests', 49 | ] 50 | 51 | USE_TZ = False -------------------------------------------------------------------------------- /tests/test_entangled.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from bs4 import BeautifulSoup 3 | from django.contrib import admin 4 | from django.contrib.admin import ModelAdmin 5 | 6 | from django.contrib.auth import get_user_model 7 | from django.forms import fields, widgets 8 | from django.forms.models import ModelChoiceField, ModelMultipleChoiceField 9 | from django.utils.html import strip_spaces_between_tags 10 | 11 | from entangled.forms import EntangledModelForm, EntangledField 12 | from entangled.utils import get_related_object, get_related_queryset 13 | from .models import Product, Category 14 | 15 | 16 | class ProductForm(EntangledModelForm): 17 | name = fields.CharField() 18 | active = fields.BooleanField() 19 | tenant = ModelChoiceField(queryset=get_user_model().objects.all(), empty_label=None) 20 | description = fields.CharField(required=False, widget=widgets.Textarea) 21 | categories = ModelMultipleChoiceField(queryset=Category.objects.all(), required=False) 22 | field_order = ['active', 'name', 'tenant', 'description', 'categories'] 23 | 24 | class Meta: 25 | model = Product 26 | untangled_fields = ['name'] 27 | entangled_fields = {'properties': ['active', 'tenant', 'description', 'categories']} 28 | 29 | 30 | @pytest.mark.django_db 31 | def test_unbound_form(): 32 | product_form = ProductForm() 33 | assert product_form.is_bound is False 34 | expected = BeautifulSoup(strip_spaces_between_tags(""" 35 |