├── .coveragerc ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE.md └── workflows │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── AUTHORS.md ├── CONTRIBUTING.md ├── HISTORY.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── django_better_admin_arrayfield ├── __init__.py ├── admin │ └── mixins.py ├── apps.py ├── forms │ ├── __init__.py │ ├── fields.py │ └── widgets.py ├── locale │ ├── es │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── fr │ │ └── LC_MESSAGES │ │ │ └── django.po │ └── pt-br │ │ └── LC_MESSAGES │ │ └── django.po ├── models │ ├── __init__.py │ └── fields.py ├── static │ ├── css │ │ ├── django_better_admin_arrayfield.css │ │ └── django_better_admin_arrayfield.min.css │ ├── img │ │ └── .gitignore │ └── js │ │ ├── django_better_admin_arrayfield.js │ │ └── django_better_admin_arrayfield.min.js └── templates │ └── django_better_admin_arrayfield │ └── forms │ └── widgets │ └── dynamic_array.html ├── docker-compose.yml ├── manage.py ├── pyproject.toml ├── readme_images ├── after.png └── before.jpg ├── requirements.txt ├── requirements_dev.txt ├── requirements_test.txt ├── sample_project ├── manage.py ├── requirements.txt ├── sample_app │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_auto_20190929_1805.py │ │ └── __init__.py │ ├── models.py │ └── views.py └── sample_project │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── settings.py ├── test_fields.py ├── test_forms.py ├── test_widgets.py ├── testapp │ ├── apps.py │ └── models.py └── urls.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | 4 | [report] 5 | omit = 6 | *site-packages* 7 | *tests* 8 | *.tox* 9 | show_missing = True 10 | exclude_lines = 11 | raise NotImplementedError 12 | -------------------------------------------------------------------------------- /.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 | 11 | [*.{py,rst,ini}] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.{html,css,scss,json,yml}] 16 | indent_style = space 17 | indent_size = 2 18 | 19 | [*.md] 20 | trim_trailing_whitespace = false 21 | 22 | [Makefile] 23 | indent_style = tab 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * Django better admin ArrayField version: 2 | * Django version: 3 | * Python version: 4 | * Operating System: 5 | 6 | ### Description 7 | 8 | Describe what you were trying to get done. 9 | Tell us what happened, what went wrong, and what you expected to happen. 10 | 11 | ### What I Did 12 | 13 | ``` 14 | Paste the command(s) you ran and the output. 15 | If there was a crash, please include the traceback here. 16 | ``` 17 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | max-parallel: 4 16 | matrix: 17 | python-version: [3.5, 3.6, 3.7, 3.8] 18 | 19 | steps: 20 | - uses: actions/checkout@v1 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v1 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install tox tox-gh-actions 29 | - name: Test with tox 30 | run: tox 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | __pycache__ 3 | .venv/ 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Packages 9 | *.egg 10 | *.egg-info 11 | dist 12 | build 13 | eggs 14 | parts 15 | bin 16 | var 17 | sdist 18 | develop-eggs 19 | .installed.cfg 20 | lib 21 | lib64 22 | 23 | # Installer logs 24 | pip-log.txt 25 | 26 | # Unit test / coverage reports 27 | .coverage 28 | .tox 29 | nosetests.xml 30 | htmlcov 31 | 32 | # Translations 33 | *.mo 34 | 35 | # Mr Developer 36 | .mr.developer.cfg 37 | .project 38 | .pydevproject 39 | 40 | # Pycharm/Intellij 41 | .idea 42 | 43 | # Complexity 44 | output/*.html 45 | output/*/index.html 46 | 47 | # Sphinx 48 | docs/_build 49 | 50 | # Pipenv 51 | Pipfile 52 | Pipfile.lock 53 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/ambv/black 3 | rev: 19.10b0 4 | hooks: 5 | - id: black 6 | language_version: python3.7 7 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Credits 2 | 3 | ## Development Lead 4 | 5 | - Jakub Semik \<\> 6 | 7 | ## Contributors 8 | 9 | | Full Name | GitHub | 10 | | ------ | ------ | 11 | | Davit Tovmasyan | [@davitovmasyan]( https://github.com/davitovmasyan ) | 12 | | Sergei Maertens | [@sergei-maertens]( https://github.com/sergei-maertens ) | 13 | | 李琼羽 | [@w392807287]( https://github.com/w392807287 ) | 14 | | Usman Hussain | [@usmandap]( https://github.com/usmandap ) | 15 | | Wojciech Semik | [@paterit]( https://github.com/paterit ) | 16 | | Jules Robichaud-Gagnon | [@jrobichaud]( https://github.com/jrobichaud ) | 17 | | Dustin Martin | [@dmartin]( https://github.com/dmartin ) | 18 | | Davit Tovmasyan | [@davitovmasyan]( https://github.com/davitovmasyan ) | 19 | | Swojak-A | [@Swojak-A]( https://github.com/Swojak-A ) | 20 | | Álvaro Mondéjar | [@mondeja]( https://github.com/mondeja ) | 21 | | Martín | [@MartinCura]( https://github.com/MartinCura ) | 22 | | Joshua Cender | [@sixthgear]( https://github.com/sixthgear ) | 23 | | Javier Matos Odut | [@javiermatos]( https://github.com/javiermatos ) 24 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome, and they are greatly appreciated\! Every 4 | little bit helps, and credit will always be given. 5 | 6 | You can contribute in many ways: 7 | 8 | ## Types of Contributions 9 | 10 | ### Report Bugs 11 | 12 | Report bugs at 13 | . 14 | 15 | If you are reporting a bug, please include: 16 | 17 | - Your operating system name and version. 18 | - Any details about your local setup that might be helpful in 19 | troubleshooting. 20 | - Detailed steps to reproduce the bug. 21 | 22 | ### Fix Bugs 23 | 24 | Look through the GitHub issues for bugs. Anything tagged with "bug" is 25 | open to whoever wants to implement it. 26 | 27 | ### Implement Features 28 | 29 | Look through the GitHub issues for features. Anything tagged with 30 | "feature" is open to whoever wants to implement it. 31 | 32 | ### Write Documentation 33 | 34 | Django better admin ArrayField could always use more documentation, 35 | whether as part of the official Django better admin ArrayField docs, in 36 | docstrings, or even on the web in blog posts, articles, and such. 37 | 38 | ### Submit Feedback 39 | 40 | The best way to send feedback is to file an issue at 41 | . 42 | 43 | If you are proposing a feature: 44 | 45 | - Explain in detail how it would work. 46 | - Keep the scope as narrow as possible, to make it easier to 47 | implement. 48 | - Remember that this is a volunteer-driven project, and that 49 | contributions are welcome :) 50 | 51 | ## Get Started\! 52 | 53 | Ready to contribute? Here's how to set up 54 | django-better-admin-arrayfield for local 55 | development. 56 | 57 | 1. Fork the 58 | django-better-admin-arrayfield repo 59 | on GitHub. 60 | 61 | 2. Clone your fork 62 | locally: 63 | 64 | $ git clone git@github.com:your_name_here/django-better-admin-arrayfield.git 65 | 66 | 3. Install your local copy into a virtualenv. Assuming you have 67 | virtualenvwrapper installed, this is how you set up your fork for 68 | local development: 69 | 70 | $ mkvirtualenv django-better-admin-arrayfield 71 | $ cd django-better-admin-arrayfield/ 72 | $ python setup.py develop 73 | 74 | 4. Create a branch for local development: 75 | 76 | $ git checkout -b name-of-your-bugfix-or-feature 77 | 78 | Now you can make your changes locally. 79 | 80 | 5. When you're done making changes, check that your changes pass flake8 81 | and the tests, including testing other Python versions with tox: 82 | 83 | $ flake8 django_better_admin_arrayfield tests 84 | $ python setup.py test 85 | $ tox 86 | 87 | To get flake8 and tox, just pip install them into your virtualenv. 88 | 89 | 6. Commit your changes and push your branch to GitHub: 90 | 91 | $ git add . 92 | $ git commit -m "Your detailed description of your changes." 93 | $ git push origin name-of-your-bugfix-or-feature 94 | 95 | 7. Submit a pull request through the GitHub website. 96 | 97 | ## Pull Request Guidelines 98 | 99 | Before you submit a pull request, check that it meets these guidelines: 100 | 101 | 1. The pull request should include tests. 102 | 2. If the pull request adds functionality, the docs should be updated. 103 | Put your n 104 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # History 2 | 3 | ## 1.4.2 (2020-12-08) 4 | 5 | - Adjust template to better match django style 6 | 7 | ## 1.4.1 (2020-12-08) 8 | 9 | - Allow submitting empty array field 10 | 11 | ## 1.4.0 (2020-10-04) 12 | 13 | - allow choosing subwidget for DynamicArrayWidget 14 | 15 | ## 1.3.0 (2020-07-09) 16 | 17 | - Handle default values in form field 18 | 19 | ## 1.2.1 (2020-07-09) 20 | 21 | - Fix tests requirements 22 | 23 | ## 1.2.0 (2020-07-09) 24 | 25 | - handle default values in model fields 26 | 27 | ## 1.1.0 (2020-04-28) 28 | 29 | - Add spanish translations 30 | 31 | ## 1.0.7 (2020-04-27) 32 | 33 | - Add possibility to i18n strings 34 | 35 | ## 1.0.6 (2020-04-15) 36 | 37 | - Remove debugging print statements 38 | - use default_app_config for easier integration 39 | - Support dynamically-added inline forms 40 | 41 | ## 1.0.5 (2019-12-30) 42 | 43 | - Add python 3.8 and Django 3.0 to tests 44 | 45 | ## 1.0.4 (2019-09-02) 46 | 47 | - Can add item after removing everything from the list 48 | 49 | ## 1.0.3 (2019-09-02) 50 | 51 | - Can add item after removing everything from the list 52 | - Do not call static at startup time 53 | 54 | ## 1.0.2 (2019-04-03) 55 | 56 | - If field is required empty list raises ValidationError on clean. 57 | 58 | ## 1.0.1 (2019-02-23) 59 | 60 | - Empty list is no longer recognized as changed. 61 | 62 | 63 | ## 1.0.0 (2019-02-21) 64 | 65 | - First release on PyPI. 66 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) 2019, Jakub Semik 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.md 2 | include CONTRIBUTING.md 3 | include HISTORY.md 4 | include LICENSE 5 | include README.md 6 | recursive-include django_better_admin_arrayfield *.html *.png *.gif *.js *.min.js *.css *.min.css *jpg *jpeg *svg *py 7 | recursive-include django_better_admin_arrayfield/locale *.po *.mo 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean-pyc clean-build docs help 2 | .DEFAULT_GOAL := help 3 | define BROWSER_PYSCRIPT 4 | import os, webbrowser, sys 5 | try: 6 | from urllib import pathname2url 7 | except: 8 | from urllib.request import pathname2url 9 | 10 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 11 | endef 12 | export BROWSER_PYSCRIPT 13 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 14 | 15 | help: 16 | @grep '^[a-zA-Z]' $(MAKEFILE_LIST) | sort | awk -F ':.*?## ' 'NF==2 {printf "\033[36m %-25s\033[0m %s\n", $$1, $$2}' 17 | 18 | clean: clean-build clean-pyc 19 | 20 | clean-build: ## remove build artifacts 21 | rm -fr build/ 22 | rm -fr dist/ 23 | rm -fr *.egg-info 24 | 25 | clean-pyc: ## remove Python file artifacts 26 | find . -name '*.pyc' -exec rm -f {} + 27 | find . -name '*.pyo' -exec rm -f {} + 28 | find . -name '*~' -exec rm -f {} + 29 | 30 | lint: ## check style with flake8 31 | flake8 django_better_admin_arrayfield tests 32 | 33 | test: ## run tests quickly with the default Python 34 | python runtests.py tests 35 | 36 | test-all: ## run tests on every Python version with tox 37 | tox 38 | 39 | coverage: ## check code coverage quickly with the default Python 40 | coverage run --source django_better_admin_arrayfield runtests.py tests 41 | coverage report -m 42 | coverage html 43 | open htmlcov/index.html 44 | 45 | docs: ## generate Sphinx HTML documentation, including API docs 46 | rm -f docs/django-better-admin-arrayfield.rst 47 | rm -f docs/modules.rst 48 | sphinx-apidoc -o docs/ django_better_admin_arrayfield 49 | $(MAKE) -C docs clean 50 | $(MAKE) -C docs html 51 | $(BROWSER) docs/_build/html/index.html 52 | 53 | release: clean ## package and upload a release 54 | python setup.py sdist bdist_wheel 55 | twine upload dist/* 56 | 57 | sdist: clean ## package 58 | python setup.py sdist bdist_wheel 59 | ls -l dist 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django better admin ArrayField 2 | 3 | [![image](https://badge.fury.io/py/django-better-admin-arrayfield.svg)](https://badge.fury.io/py/django-better-admin-arrayfield) 4 | 5 | [![Actions Status](https://github.com/gradam/django-better-admin-arrayfield/workflows/tests/badge.svg)](https://github.com/gradam/django-better-admin-arrayfield/actions) 6 | 7 | [![image](https://codecov.io/gh/gradam/django-better-admin-arrayfield/branch/master/graph/badge.svg)](https://codecov.io/gh/gradam/django-better-admin-arrayfield) 8 | 9 | Better ArrayField widget for admin 10 | 11 | Supported Python versions: [![Python 3.5](https://img.shields.io/badge/python-3.5-blue.svg)](https://www.python.org/downloads/release/python-350/) [![Python 3.6](https://img.shields.io/badge/python-3.6-blue.svg)](https://www.python.org/downloads/release/python-360/) [![Python 3.7](https://img.shields.io/badge/python-3.7-blue.svg)](https://www.python.org/downloads/release/python-370/) [![Python 3.8](https://img.shields.io/badge/python-3.8-blue.svg)](https://www.python.org/downloads/release/python-380/) 12 | 13 | 14 | 15 | 16 | 17 | Supported Django versions: 2.0, 2.1, 2.2, 3.0, 3.1 18 | 19 | might work with different django/python versions as well but I did not test that. 20 | 21 | It changes comma separated widget to list based in admin panel. 22 | 23 | Before: 24 | ![Alt text](https://raw.githubusercontent.com/gradam/django-better-admin-arrayfield/master/readme_images/before.jpg "Before") 25 | 26 | After: 27 | ![Alt text](https://raw.githubusercontent.com/gradam/django-better-admin-arrayfield/master/readme_images/after.png "After") 28 | 29 | ## Quickstart 30 | 31 | Install Django better admin ArrayField: 32 | 33 | pip install django-better-admin-arrayfield 34 | 35 | Add it to your \`INSTALLED\_APPS\`: 36 | 37 | ```python 38 | INSTALLED_APPS = ( 39 | ... 40 | 'django_better_admin_arrayfield', 41 | ... 42 | ) 43 | ``` 44 | 45 | 46 | ## Usage 47 | 48 | `django_better_admin_arrayfield.models.fields.ArrayField` is a drop-in replacement for standard Django `ArrayField`. 49 | 50 | Import it like below and use it in your model class definition. 51 | ```python 52 | from django_better_admin_arrayfield.models.fields import ArrayField 53 | ``` 54 | 55 | Import DynamicArrayMixin like below 56 | ```python 57 | from django_better_admin_arrayfield.admin.mixins import DynamicArrayMixin 58 | ``` 59 | 60 | In your admin class add `DynamicArrayMixin`: 61 | ... 62 | ```python 63 | class MyModelAdmin(admin.ModelAdmin, DynamicArrayMixin): 64 | ``` 65 | 66 | That's it. 67 | 68 | 69 | ### Custom subwidget 70 | 71 | By default the subwidget (the one used for each item in the array) will be TextInput. If you want something else, you can use your own specifying it in the `formfield_overrides` of your Admin model: 72 | ```python 73 | class MyWidget(DynamicArrayWidget): 74 | def __init__(self, *args, **kwargs): 75 | kwargs['subwidget_form'] = MyForm 76 | super().__init__(*args, **kwargs) 77 | 78 | class MyModelAdmin(models.ModelAdmin, DynamicArrayMixin): 79 | ... 80 | formfield_overrides = { 81 | DynamicArrayField: {'widget': MyWidget}, 82 | } 83 | ``` 84 | 85 | If you wanted to have Textarea as the subwidget, you can simply use the included drop-in widget replacement: 86 | ```python 87 | from django_better_admin_arrayfield.forms.widgets import DynamicArrayTextareaWidget 88 | 89 | class MyModelAdmin(models.ModelAdmin, DynamicArrayMixin): 90 | ... 91 | formfield_overrides = { 92 | DynamicArrayField: {'widget': DynamicArrayTextareaWidget}, 93 | } 94 | ``` 95 | 96 | ## Running Tests 97 | 98 | Does the code actually work? 99 | 100 | source /bin/activate 101 | (myenv) $ pip install tox 102 | (myenv) $ tox 103 | 104 | ## Pre-commit hooks 105 | 106 | Install pre-commit black hook 107 | 108 | source /bin/activate 109 | (myenv) $ pip install -r requirements_dev.txt 110 | (myenv) $ pre-commit install 111 | 112 | ## Credits 113 | 114 | Inspired by: https://stackoverflow.com/a/49370480/4638248 115 | 116 | Tools used in rendering this 117 | package: 118 | 119 | - [Cookiecutter](https://github.com/audreyr/cookiecutter) 120 | - [cookiecutter-djangopackage](https://github.com/pydanny/cookiecutter-djangopackage) 121 | -------------------------------------------------------------------------------- /django_better_admin_arrayfield/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.4.2" 2 | 3 | default_app_config = "django_better_admin_arrayfield.apps.DjangoBetterAdminArrayfieldConfig" 4 | -------------------------------------------------------------------------------- /django_better_admin_arrayfield/admin/mixins.py: -------------------------------------------------------------------------------- 1 | class DynamicArrayMixin: 2 | class Media: 3 | js = ("js/django_better_admin_arrayfield.min.js",) 4 | css = {"all": ("css/django_better_admin_arrayfield.min.css",)} 5 | -------------------------------------------------------------------------------- /django_better_admin_arrayfield/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | from django.apps import AppConfig 3 | 4 | 5 | class DjangoBetterAdminArrayfieldConfig(AppConfig): 6 | name = "django_better_admin_arrayfield" 7 | -------------------------------------------------------------------------------- /django_better_admin_arrayfield/forms/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gradam/django-better-admin-arrayfield/b9349902ed4792759e32a1aa26b58cf42180bf8a/django_better_admin_arrayfield/forms/__init__.py -------------------------------------------------------------------------------- /django_better_admin_arrayfield/forms/fields.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | from django import forms 4 | from django.contrib.postgres.utils import prefix_validation_error 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | from django_better_admin_arrayfield.forms.widgets import DynamicArrayWidget 8 | 9 | 10 | class DynamicArrayField(forms.Field): 11 | 12 | default_error_messages = { 13 | "item_invalid": _("Item %(nth)s in the array did not validate: "), 14 | } 15 | 16 | def __init__(self, base_field, **kwargs): 17 | self.base_field = base_field 18 | self.max_length = kwargs.pop("max_length", None) 19 | self.default = kwargs.pop("default", None) 20 | kwargs.setdefault("widget", DynamicArrayWidget) 21 | super().__init__(**kwargs) 22 | 23 | def clean(self, value): 24 | cleaned_data = [] 25 | errors = [] 26 | if value is not None: 27 | value = [x for x in value if x] 28 | 29 | for index, item in enumerate(value): 30 | try: 31 | cleaned_data.append(self.base_field.clean(item)) 32 | except forms.ValidationError as error: 33 | errors.append( 34 | prefix_validation_error( 35 | error, self.error_messages["item_invalid"], code="item_invalid", params={"nth": index} 36 | ) 37 | ) 38 | 39 | if not value: 40 | cleaned_data = self.default() if callable(self.default) else self.default 41 | if cleaned_data is None and self.initial is not None: 42 | cleaned_data = self.initial() if callable(self.initial) else self.initial 43 | if errors: 44 | raise forms.ValidationError(list(chain.from_iterable(errors))) 45 | if not cleaned_data and self.required: 46 | raise forms.ValidationError(self.error_messages["required"]) 47 | 48 | return cleaned_data 49 | 50 | def has_changed(self, initial, data): 51 | if not data and not initial: 52 | return False 53 | return super().has_changed(initial, data) 54 | -------------------------------------------------------------------------------- /django_better_admin_arrayfield/forms/widgets.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | 4 | class DynamicArrayWidget(forms.TextInput): 5 | 6 | template_name = "django_better_admin_arrayfield/forms/widgets/dynamic_array.html" 7 | 8 | def __init__(self, *args, **kwargs): 9 | self.subwidget_form = kwargs.pop("subwidget_form", forms.TextInput) 10 | super().__init__(*args, **kwargs) 11 | 12 | def get_context(self, name, value, attrs): 13 | context_value = value or [""] 14 | context = super().get_context(name, context_value, attrs) 15 | final_attrs = context["widget"]["attrs"] 16 | id_ = context["widget"]["attrs"].get("id") 17 | context["widget"]["is_none"] = value is None 18 | 19 | subwidgets = [] 20 | for index, item in enumerate(context["widget"]["value"]): 21 | widget_attrs = final_attrs.copy() 22 | if id_: 23 | widget_attrs["id"] = "{id_}_{index}".format(id_=id_, index=index) 24 | widget = self.subwidget_form() 25 | widget.is_required = self.is_required 26 | subwidgets.append(widget.get_context(name, item, widget_attrs)["widget"]) 27 | 28 | context["widget"]["subwidgets"] = subwidgets 29 | return context 30 | 31 | def value_from_datadict(self, data, files, name): 32 | try: 33 | getter = data.getlist 34 | return [value for value in getter(name) if value] 35 | except AttributeError: 36 | return data.get(name) 37 | 38 | def value_omitted_from_data(self, data, files, name): 39 | return False 40 | 41 | def format_value(self, value): 42 | return value or [] 43 | 44 | 45 | class DynamicArrayTextareaWidget(DynamicArrayWidget): 46 | def __init__(self, *args, **kwargs): 47 | kwargs.setdefault("subwidget_form", forms.Textarea) 48 | super().__init__(*args, **kwargs) 49 | -------------------------------------------------------------------------------- /django_better_admin_arrayfield/locale/es/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # Spanish translation of django_better_admin_arrayfield. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the django_better_admin_arrayfield package. 4 | # Álvaro Mondéjar Rubio , 2020. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: 1.0.0\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2020-04-27 10:01+0200\n" 12 | "PO-Revision-Date: 2020-04-23 18:52-0015\n" 13 | "Last-Translator: b' '\n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: es\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | "X-Translated-Using: django-rosetta 0.9.4\n" 21 | 22 | #: django_better_admin_arrayfield/forms/fields.py:13 23 | #, python-format 24 | msgid "Item %(nth)s in the array did not validate: " 25 | msgstr "Element %(nth)sº en el array no válido: " 26 | 27 | #: django_better_admin_arrayfield/templates/django_better_admin_arrayfield/forms/widgets/dynamic_array.html:16 28 | msgid "Add another" 29 | msgstr "Añadir otro" 30 | -------------------------------------------------------------------------------- /django_better_admin_arrayfield/locale/fr/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # French translation of django_better_admin_arrayfield. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the django_better_admin_arrayfield package. 4 | # Marcelo Cardoso , 2020. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: 1.0.0\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2020-04-27 10:01+0200\n" 12 | "PO-Revision-Date: 2020-04-23 18:52-0015\n" 13 | "Last-Translator: b' '\n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: fr\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | "X-Translated-Using: django-rosetta 0.9.4\n" 21 | 22 | #: django_better_admin_arrayfield/forms/fields.py:13 23 | #, python-format 24 | msgid "Item %(nth)s in the array did not validate: " 25 | msgstr "L'élément %(nth)sº de l'array n'est pas valide: " 26 | 27 | #: django_better_admin_arrayfield/templates/django_better_admin_arrayfield/forms/widgets/dynamic_array.html:16 28 | msgid "Add another" 29 | msgstr "Ajouter un autre" 30 | -------------------------------------------------------------------------------- /django_better_admin_arrayfield/locale/pt-br/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # Brazilian portuguese translation of django_better_admin_arrayfield. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the django_better_admin_arrayfield package. 4 | # Marcelo Cardoso , 2020. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: 1.0.0\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2020-04-27 10:01+0200\n" 12 | "PO-Revision-Date: 2020-04-23 18:52-0015\n" 13 | "Last-Translator: b' '\n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: pt-br\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | "X-Translated-Using: django-rosetta 0.9.4\n" 21 | 22 | #: django_better_admin_arrayfield/forms/fields.py:13 23 | #, python-format 24 | msgid "Item %(nth)s in the array did not validate: " 25 | msgstr "Elemento %(nth)sº no array não é válido: " 26 | 27 | #: django_better_admin_arrayfield/templates/django_better_admin_arrayfield/forms/widgets/dynamic_array.html:16 28 | msgid "Add another" 29 | msgstr "Adicionar outro" 30 | -------------------------------------------------------------------------------- /django_better_admin_arrayfield/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gradam/django-better-admin-arrayfield/b9349902ed4792759e32a1aa26b58cf42180bf8a/django_better_admin_arrayfield/models/__init__.py -------------------------------------------------------------------------------- /django_better_admin_arrayfield/models/fields.py: -------------------------------------------------------------------------------- 1 | from django.contrib.postgres.fields import ArrayField as DjangoArrayField 2 | 3 | from django_better_admin_arrayfield.forms.fields import DynamicArrayField 4 | 5 | 6 | class ArrayField(DjangoArrayField): 7 | def formfield(self, **kwargs): 8 | return super().formfield(**{"form_class": DynamicArrayField, **kwargs}) 9 | -------------------------------------------------------------------------------- /django_better_admin_arrayfield/static/css/django_better_admin_arrayfield.css: -------------------------------------------------------------------------------- 1 | .dynamic-array-widget .array-item { 2 | display: flex; 3 | align-items: center; 4 | } 5 | 6 | .dynamic-array-widget .remove { 7 | height: 15px; 8 | display: flex; 9 | align-items: center; 10 | margin-left: 5px; 11 | } 12 | 13 | .dynamic-array-widget .remove:hover { 14 | cursor: pointer; 15 | } 16 | 17 | .dynamic-array-widget button { 18 | height: 20px; 19 | background: #79aec8; 20 | border-radius: 4px; 21 | color: #fff; 22 | cursor: pointer; 23 | border: none; 24 | } 25 | 26 | .dynamic-array-widget button:hover { 27 | background: #609ab6; 28 | } 29 | -------------------------------------------------------------------------------- /django_better_admin_arrayfield/static/css/django_better_admin_arrayfield.min.css: -------------------------------------------------------------------------------- 1 | .dynamic-array-widget .array-item{display:flex;align-items:center}.dynamic-array-widget .remove{height:15px;display:flex;align-items:center;margin-left:5px}.dynamic-array-widget .remove:hover{cursor:pointer}.dynamic-array-widget button{height:20px;background:#79aec8;border-radius:4px;color:#fff;cursor:pointer;border:none}.dynamic-array-widget button:hover{background:#609ab6} 2 | -------------------------------------------------------------------------------- /django_better_admin_arrayfield/static/img/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gradam/django-better-admin-arrayfield/b9349902ed4792759e32a1aa26b58cf42180bf8a/django_better_admin_arrayfield/static/img/.gitignore -------------------------------------------------------------------------------- /django_better_admin_arrayfield/static/js/django_better_admin_arrayfield.js: -------------------------------------------------------------------------------- 1 | // I am to lazy to add js transpiler and compressor to this. just use https://jscompress.com 2 | 3 | window.addEventListener('load', function () { 4 | let item_count = 1; 5 | 6 | function addRemoveEventListener(widgetElement) { 7 | widgetElement.querySelectorAll('.remove').forEach(element => { 8 | element.addEventListener('click', () => { 9 | element.parentNode.remove(); 10 | }); 11 | }); 12 | } 13 | 14 | function initializeWidget(widgetElement) { 15 | const initialElement = widgetElement.querySelector('.array-item'); 16 | const elementTemplate = initialElement.cloneNode(true); 17 | const parentElement = initialElement.parentElement; 18 | 19 | if (initialElement.getAttribute('data-isNone')) { 20 | initialElement.remove(); 21 | elementTemplate.removeAttribute('data-isNone'); 22 | elementTemplate.removeAttribute('style'); 23 | } 24 | addRemoveEventListener(widgetElement); 25 | 26 | widgetElement.querySelector('.add-array-item').addEventListener('click', () => { 27 | item_count++; 28 | const newElement = elementTemplate.cloneNode(true); 29 | const id_parts = newElement.querySelector('input').getAttribute('id').split('_'); 30 | const id = id_parts.slice(0, -1).join('_') + '_' + String(item_count - 1); 31 | newElement.querySelector('input').setAttribute('id', id); 32 | newElement.querySelector('input').value = ''; 33 | 34 | addRemoveEventListener(newElement); 35 | parentElement.appendChild(newElement); 36 | }); 37 | } 38 | 39 | django.jQuery(".dynamic-array-widget").not(".empty-form .dynamic-array-widget").each( 40 | (index, widgetElement) => initializeWidget(widgetElement) 41 | ); 42 | 43 | django.jQuery(document).on('formset:added', function(event, $row, formsetName) { 44 | $row[0].querySelectorAll(".dynamic-array-widget").forEach( 45 | widgetElement => initializeWidget(widgetElement) 46 | ); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /django_better_admin_arrayfield/static/js/django_better_admin_arrayfield.min.js: -------------------------------------------------------------------------------- 1 | window.addEventListener("load",function(){function a(a){a.querySelectorAll(".remove").forEach(a=>{a.addEventListener("click",()=>{a.parentNode.remove()})})}function b(b){const d=b.querySelector(".array-item"),e=d.cloneNode(!0),f=d.parentElement;d.getAttribute("data-isNone")&&(d.remove(),e.removeAttribute("data-isNone"),e.removeAttribute("style")),a(b),b.querySelector(".add-array-item").addEventListener("click",()=>{c++;const b=e.cloneNode(!0),d=b.querySelector("input").getAttribute("id").split("_"),g=d.slice(0,-1).join("_")+"_"+(c-1+"");b.querySelector("input").setAttribute("id",g),b.querySelector("input").value="",a(b),f.appendChild(b)})}let c=1;django.jQuery(".dynamic-array-widget").not(".empty-form .dynamic-array-widget").each((a,c)=>b(c)),django.jQuery(document).on("formset:added",function(a,c){c[0].querySelectorAll(".dynamic-array-widget").forEach(a=>b(a))})}); 2 | -------------------------------------------------------------------------------- /django_better_admin_arrayfield/templates/django_better_admin_arrayfield/forms/widgets/dynamic_array.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {% load i18n %} 3 | 4 | {% spaceless %} 5 | 22 | {% endspaceless %} 23 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | db: 5 | image: postgres:latest 6 | environment: 7 | POSTGRES_USER: sample_user 8 | POSTGRES_PASSWORD: 12345 9 | POSTGRES_DB: django_db 10 | ports: 11 | - "5436:5432" 12 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import unicode_literals, absolute_import 4 | 5 | import os 6 | import sys 7 | 8 | if __name__ == "__main__": 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 10 | from django.core.management import execute_from_command_line 11 | 12 | execute_from_command_line(sys.argv) 13 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 119 3 | py36 = true 4 | include = '\.pyi?$' 5 | exclude = ''' 6 | /( 7 | \.git 8 | | \.hg 9 | | \.mypy_cache 10 | | \.tox 11 | | \.venv 12 | | \.pytest_cache 13 | | migrations 14 | | _build 15 | | buck-out 16 | | build 17 | | dist 18 | | docs/conf.py 19 | | build 20 | )/ 21 | ''' 22 | 23 | [tool.pytest.ini_options] 24 | DJANGO_SETTINGS_MODULE = "tests.settings" 25 | django_find_project = false 26 | 27 | [tool.isort] 28 | line_length = 120 29 | multi_line_output = 3 30 | include_trailing_comma = true 31 | known_django = ["django"] 32 | known_first_party = ["sample_app"] 33 | sections = ["FUTURE","STDLIB","DJANGO","THIRDPARTY","FIRSTPARTY","LOCALFOLDER"] 34 | known_third_party = ["pytest"] 35 | -------------------------------------------------------------------------------- /readme_images/after.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gradam/django-better-admin-arrayfield/b9349902ed4792759e32a1aa26b58cf42180bf8a/readme_images/after.png -------------------------------------------------------------------------------- /readme_images/before.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gradam/django-better-admin-arrayfield/b9349902ed4792759e32a1aa26b58cf42180bf8a/readme_images/before.jpg -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django>=2.0,<3.2 2 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | bumpversion==0.6.0 2 | wheel==0.36.2 3 | pre-commit==2.11.1 4 | black==20.8b1 5 | psycopg2-binary==2.8.6 6 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | coverage==5.5 2 | flake8==3.9.0 3 | tox==3.23.0 4 | codecov==2.1.11 5 | pytest==6.1.2 6 | pytest-django==4.1.0 7 | pytest-cov==2.11.1 8 | pytest-mock==3.5.1 9 | psycopg2-binary==2.8.6 10 | -------------------------------------------------------------------------------- /sample_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sample_project.settings") 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /sample_project/requirements.txt: -------------------------------------------------------------------------------- 1 | Django==3.1.7 2 | -e ../ 3 | psycopg2-binary==2.8.6 4 | -------------------------------------------------------------------------------- /sample_project/sample_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gradam/django-better-admin-arrayfield/b9349902ed4792759e32a1aa26b58cf42180bf8a/sample_project/sample_app/__init__.py -------------------------------------------------------------------------------- /sample_project/sample_app/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from django_better_admin_arrayfield.admin.mixins import DynamicArrayMixin 4 | from sample_app.models import ArrayModel 5 | 6 | 7 | class ArrayModelAdmin(admin.ModelAdmin, DynamicArrayMixin): 8 | pass 9 | 10 | 11 | admin.site.register(ArrayModel, ArrayModelAdmin) 12 | -------------------------------------------------------------------------------- /sample_project/sample_app/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SampleAppConfig(AppConfig): 5 | name = "sample_app" 6 | -------------------------------------------------------------------------------- /sample_project/sample_app/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-29 18:03 2 | 3 | from django.db import migrations, models 4 | 5 | import django_better_admin_arrayfield.models.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="ArrayModel", 17 | fields=[ 18 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 19 | ( 20 | "sample_array", 21 | django_better_admin_arrayfield.models.fields.ArrayField( 22 | base_field=models.CharField(max_length=20), size=None 23 | ), 24 | ), 25 | ], 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /sample_project/sample_app/migrations/0002_auto_20190929_1805.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-29 18:05 2 | 3 | from django.db import migrations, models 4 | 5 | import django_better_admin_arrayfield.models.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("sample_app", "0001_initial"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="arraymodel", 17 | name="sample_array", 18 | field=django_better_admin_arrayfield.models.fields.ArrayField( 19 | base_field=models.CharField(max_length=20), blank=True, null=True, size=None 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /sample_project/sample_app/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gradam/django-better-admin-arrayfield/b9349902ed4792759e32a1aa26b58cf42180bf8a/sample_project/sample_app/migrations/__init__.py -------------------------------------------------------------------------------- /sample_project/sample_app/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from django_better_admin_arrayfield.models.fields import ArrayField 4 | 5 | 6 | class ArrayModel(models.Model): 7 | sample_array = ArrayField(models.CharField(max_length=20), blank=True, null=True) 8 | -------------------------------------------------------------------------------- /sample_project/sample_app/views.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gradam/django-better-admin-arrayfield/b9349902ed4792759e32a1aa26b58cf42180bf8a/sample_project/sample_app/views.py -------------------------------------------------------------------------------- /sample_project/sample_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gradam/django-better-admin-arrayfield/b9349902ed4792759e32a1aa26b58cf42180bf8a/sample_project/sample_project/__init__.py -------------------------------------------------------------------------------- /sample_project/sample_project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for sample_project project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.2.5. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "af!o7n3efeip8p^%j^%4kde+_&^l51^q%1l3a)yz(223#@j065" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "django.contrib.admin", 35 | "django.contrib.auth", 36 | "django.contrib.contenttypes", 37 | "django.contrib.sessions", 38 | "django.contrib.messages", 39 | "django.contrib.staticfiles", 40 | "django_better_admin_arrayfield", 41 | "sample_app.apps.SampleAppConfig", 42 | ] 43 | 44 | MIDDLEWARE = [ 45 | "django.middleware.security.SecurityMiddleware", 46 | "django.contrib.sessions.middleware.SessionMiddleware", 47 | "django.middleware.common.CommonMiddleware", 48 | "django.middleware.csrf.CsrfViewMiddleware", 49 | "django.contrib.auth.middleware.AuthenticationMiddleware", 50 | "django.contrib.messages.middleware.MessageMiddleware", 51 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 52 | ] 53 | 54 | ROOT_URLCONF = "sample_project.urls" 55 | 56 | TEMPLATES = [ 57 | { 58 | "BACKEND": "django.template.backends.django.DjangoTemplates", 59 | "DIRS": [], 60 | "APP_DIRS": True, 61 | "OPTIONS": { 62 | "context_processors": [ 63 | "django.template.context_processors.debug", 64 | "django.template.context_processors.request", 65 | "django.contrib.auth.context_processors.auth", 66 | "django.contrib.messages.context_processors.messages", 67 | ] 68 | }, 69 | } 70 | ] 71 | 72 | WSGI_APPLICATION = "sample_project.wsgi.application" 73 | 74 | 75 | # Database 76 | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases 77 | 78 | DATABASES = { 79 | "default": { 80 | "ENGINE": "django.db.backends.postgresql_psycopg2", 81 | "NAME": "django_db", 82 | "USER": "sample_user", 83 | "PASSWORD": "12345", 84 | "HOST": "127.0.0.1", 85 | "PORT": "5436", 86 | } 87 | } 88 | 89 | # Password validation 90 | # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators 91 | 92 | AUTH_PASSWORD_VALIDATORS = [ 93 | {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, 94 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, 95 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, 96 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, 97 | ] 98 | 99 | 100 | # Internationalization 101 | # https://docs.djangoproject.com/en/2.2/topics/i18n/ 102 | 103 | LANGUAGE_CODE = "en-us" 104 | 105 | TIME_ZONE = "UTC" 106 | 107 | USE_I18N = True 108 | 109 | USE_L10N = True 110 | 111 | USE_TZ = True 112 | 113 | 114 | # Static files (CSS, JavaScript, Images) 115 | # https://docs.djangoproject.com/en/2.2/howto/static-files/ 116 | 117 | STATIC_URL = "/static/" 118 | -------------------------------------------------------------------------------- /sample_project/sample_project/urls.py: -------------------------------------------------------------------------------- 1 | """sample_project URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path 18 | 19 | urlpatterns = [path("admin/", admin.site.urls)] 20 | -------------------------------------------------------------------------------- /sample_project/sample_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for sample_project project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sample_project.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.4.2 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | 8 | [bumpversion:file:django_better_admin_arrayfield/__init__.py] 9 | 10 | [wheel] 11 | universal = 1 12 | 13 | [flake8] 14 | max-complexity = 18 15 | select = B,C,E,F,W,T4,B9 16 | ignore = E203, E266, E501, W503 17 | exclude = 18 | django_better_admin_arrayfield/migrations, 19 | .git, 20 | .tox, 21 | .venv, 22 | docs/conf.py, 23 | build, 24 | dist 25 | max-line-length = 119 26 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import re 5 | import sys 6 | 7 | try: 8 | from setuptools import setup 9 | except ImportError: 10 | from distutils.core import setup 11 | 12 | 13 | def get_version(*file_paths): 14 | """Retrieves the version from django_better_admin_arrayfield/__init__.py""" 15 | filename = os.path.join(os.path.dirname(__file__), *file_paths) 16 | version_file = open(filename).read() 17 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) 18 | if version_match: 19 | return version_match.group(1) 20 | raise RuntimeError("Unable to find version string.") 21 | 22 | 23 | version = get_version("django_better_admin_arrayfield", "__init__.py") 24 | 25 | 26 | if sys.argv[-1] == "publish": 27 | try: 28 | import wheel 29 | 30 | print("Wheel version: ", wheel.__version__) 31 | except ImportError: 32 | print('Wheel library missing. Please run "pip install wheel"') 33 | sys.exit() 34 | os.system("python setup.py sdist upload") 35 | os.system("python setup.py bdist_wheel upload") 36 | sys.exit() 37 | 38 | if sys.argv[-1] == "tag": 39 | print("Tagging the version on git:") 40 | os.system("git tag -a %s -m 'version %s'" % (version, version)) 41 | os.system("git push --tags") 42 | sys.exit() 43 | 44 | with open("README.md") as f: 45 | readme = f.read() 46 | with open("HISTORY.md") as f: 47 | history = f.read().replace(".. :changelog:", "") 48 | 49 | setup( 50 | name="django-better-admin-arrayfield", 51 | version=version, 52 | description="""Better ArrayField widget for admin""", 53 | long_description=readme + "\n\n" + history, 54 | long_description_content_type="text/markdown", 55 | author="Jakub Semik", 56 | author_email="kuba.semik@gmail.com", 57 | url="https://github.com/gradam/django-better-admin-arrayfield", 58 | packages=["django_better_admin_arrayfield"], 59 | include_package_data=True, 60 | install_requires=[], 61 | license="MIT", 62 | zip_safe=False, 63 | keywords="django-better-admin-arrayfield", 64 | classifiers=[ 65 | "Development Status :: 4 - Beta", 66 | "Framework :: Django :: 3.1", 67 | "Framework :: Django :: 3.0", 68 | "Framework :: Django :: 2.2", 69 | "Framework :: Django :: 2.1", 70 | "Framework :: Django :: 2.0", 71 | "Intended Audience :: Developers", 72 | "License :: OSI Approved :: BSD License", 73 | "Natural Language :: English", 74 | "Programming Language :: Python :: 3", 75 | "Programming Language :: Python :: 3.5", 76 | "Programming Language :: Python :: 3.6", 77 | "Programming Language :: Python :: 3.7", 78 | "Programming Language :: Python :: 3.8", 79 | ], 80 | ) 81 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gradam/django-better-admin-arrayfield/b9349902ed4792759e32a1aa26b58cf42180bf8a/tests/__init__.py -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 2 | DEBUG = True 3 | USE_TZ = True 4 | 5 | # SECURITY WARNING: keep the secret key used in production secret! 6 | SECRET_KEY = "w7r3dc3#mcs2e5cs7!vb_647eng4dw6i2io+k9k6#solz63i$_" 7 | 8 | DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}} 9 | 10 | ROOT_URLCONF = "tests.urls" 11 | 12 | INSTALLED_APPS = [ 13 | "django.contrib.auth", 14 | "django.contrib.contenttypes", 15 | "django.contrib.sites", 16 | "django_better_admin_arrayfield", 17 | "tests.testapp.apps.TestAppConfig", 18 | ] 19 | 20 | TEMPLATES = [{"BACKEND": "django.template.backends.django.DjangoTemplates", "APP_DIRS": True}] 21 | 22 | SITE_ID = 1 23 | 24 | MIDDLEWARE = () 25 | -------------------------------------------------------------------------------- /tests/test_fields.py: -------------------------------------------------------------------------------- 1 | from django.forms import ValidationError 2 | from django.forms.fields import CharField 3 | 4 | import pytest 5 | 6 | from django_better_admin_arrayfield.forms.fields import DynamicArrayField 7 | 8 | 9 | def test_field_not_required(): 10 | field = DynamicArrayField(CharField(max_length=10), required=False) 11 | data = [] 12 | field.clean(data) 13 | data = ["12", "13"] 14 | field.clean(data) 15 | 16 | 17 | def test_field_required(): 18 | field = DynamicArrayField(CharField(max_length=10), required=True) 19 | data = [] 20 | with pytest.raises(ValidationError): 21 | field.clean(data) 22 | data = ["12", "13"] 23 | field.clean(data) 24 | 25 | 26 | def test_default(): 27 | default = ["1"] 28 | field = DynamicArrayField(CharField(max_length=10), required=True, default=default) 29 | data = [] 30 | cleaned_data = field.clean(data) 31 | assert cleaned_data == default 32 | 33 | 34 | def test_callable_default(): 35 | def default(): 36 | return ["1", "2"] 37 | 38 | field = DynamicArrayField(CharField(max_length=10), required=True, default=default) 39 | data = [] 40 | cleaned_data = field.clean(data) 41 | assert cleaned_data == default() 42 | -------------------------------------------------------------------------------- /tests/test_forms.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from django.forms import IntegerField, ModelForm 4 | 5 | import pytest 6 | 7 | from django_better_admin_arrayfield.forms.fields import DynamicArrayField 8 | from tests.testapp.models import DefaultValueNullableModel, DefaultValueRequiredModel, NullableNoDefaultModel 9 | 10 | 11 | def form_factory(model, **attrs) -> Type[ModelForm]: 12 | meta = type("Meta", (), {"model": model, "fields": ["array"]}) 13 | return type("SampleForm", (ModelForm,), {"Meta": meta, **attrs}) 14 | 15 | 16 | class TestDefaultValue: 17 | @pytest.mark.parametrize( 18 | "model_class,init_value,expected_value", 19 | ( 20 | (DefaultValueNullableModel, None, []), 21 | (NullableNoDefaultModel, None, None), 22 | (DefaultValueRequiredModel, None, []), 23 | (DefaultValueNullableModel, [1], [1]), 24 | (NullableNoDefaultModel, [1], [1]), 25 | (DefaultValueRequiredModel, [1], [1]), 26 | ), 27 | ) 28 | def test_default_list(self, model_class, init_value, expected_value): 29 | data = {} 30 | if init_value is not None: 31 | data["array"] = init_value 32 | 33 | form = form_factory(model_class)(data=data) 34 | assert form.is_valid() 35 | assert form.cleaned_data["array"] == expected_value 36 | 37 | 38 | class TestFormDefaultField: 39 | @pytest.mark.parametrize( 40 | "model_class,init_value,expected_value", 41 | ( 42 | (DefaultValueNullableModel, None, [1]), 43 | (NullableNoDefaultModel, None, [1]), 44 | (DefaultValueRequiredModel, None, [1]), 45 | (DefaultValueNullableModel, [2], [2]), 46 | (NullableNoDefaultModel, [2], [2]), 47 | (DefaultValueRequiredModel, [2], [2]), 48 | ), 49 | ) 50 | def test_default_different_values(self, model_class, init_value, expected_value): 51 | default = [1] 52 | data = {} 53 | if init_value is not None: 54 | data["array"] = init_value 55 | 56 | field = DynamicArrayField(IntegerField(), required=True, default=default) 57 | 58 | form = form_factory(model_class, array=field)(data=data) 59 | assert form.is_valid() 60 | assert form.cleaned_data["array"] == expected_value 61 | -------------------------------------------------------------------------------- /tests/test_widgets.py: -------------------------------------------------------------------------------- 1 | from django.template.loader import get_template 2 | 3 | from django_better_admin_arrayfield.forms.widgets import DynamicArrayWidget 4 | 5 | 6 | def test_template_exists(): 7 | get_template(DynamicArrayWidget.template_name) 8 | 9 | 10 | def test_format_value(): 11 | widget = DynamicArrayWidget() 12 | assert widget.format_value("") == [] 13 | assert widget.format_value([1, 2, 3]) == [1, 2, 3] 14 | 15 | 16 | def test_value_from_datadict(mocker): 17 | widget = DynamicArrayWidget() 18 | test_name = "name" 19 | 20 | class MockData: 21 | @staticmethod 22 | def getlist(name): 23 | return [name] 24 | 25 | mocker.spy(MockData, "getlist") 26 | 27 | assert widget.value_from_datadict(MockData, None, test_name) == [test_name] 28 | assert MockData.getlist.call_count == 1 29 | 30 | 31 | def test_value_from_datadict_get(mocker): 32 | widget = DynamicArrayWidget() 33 | test_name = "name" 34 | 35 | class MockData: 36 | @staticmethod 37 | def get(name): 38 | return name 39 | 40 | mocker.spy(MockData, "get") 41 | 42 | assert widget.value_from_datadict(MockData, None, test_name) == test_name 43 | assert MockData.get.call_count == 1 44 | 45 | 46 | def test_get_context(): 47 | widget = DynamicArrayWidget() 48 | value = ["1", "2", "3"] 49 | 50 | context = widget.get_context("name", value, []) 51 | assert len(context["widget"]["subwidgets"]) == len(value) 52 | -------------------------------------------------------------------------------- /tests/testapp/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestAppConfig(AppConfig): 5 | name = "tests.testapp" 6 | verbose_name = "TestApp" 7 | -------------------------------------------------------------------------------- /tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from django_better_admin_arrayfield.models.fields import ArrayField 4 | 5 | 6 | class DefaultValueNullableModel(models.Model): 7 | array = ArrayField(models.IntegerField(), blank=True, null=True, default=list) 8 | 9 | 10 | class DefaultValueRequiredModel(models.Model): 11 | array = ArrayField(models.IntegerField(), blank=True, default=list) 12 | 13 | 14 | class NullableNoDefaultModel(models.Model): 15 | array = ArrayField(models.IntegerField(), blank=True, null=True) 16 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | from django.conf.urls import include, url 5 | 6 | urlpatterns = [url(r"^", include("django_better_admin_arrayfield.urls", namespace="django_better_admin_arrayfield"))] 7 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | django{2,21,22,30,31}-{py35,py36,py37,py38}, 4 | lint 5 | 6 | [gh-actions] 7 | python = 8 | 3.5: py35 9 | 3.6: py36 10 | 3.7: py37 11 | 3.8: py38, lint 12 | 13 | [testenv] 14 | deps = 15 | django2: Django<2.1 16 | django21: Django<2.2 17 | django22: Django<3.0 18 | django30: Django<3.1 19 | django31: Django<3.2 20 | -r{toxinidir}/requirements_test.txt 21 | setenv = 22 | PYTHONPATH = {toxinidir}:{toxinidir}/django_better_admin_arrayfield 23 | commands = pytest --cov=django_better_admin_arrayfield tests/ 24 | 25 | [testenv:lint] 26 | commands = 27 | isort --check django_better_admin_arrayfield tests sample_project 28 | black --check --diff django_better_admin_arrayfield tests sample_project 29 | flake8 30 | deps = 31 | isort 32 | flake8 33 | black 34 | --------------------------------------------------------------------------------