From f878a4b0e0342d95c19311f31506669a9423028e Mon Sep 17 00:00:00 2001 From: Pete Natale Date: Thu, 5 Sep 2024 16:10:21 -0400 Subject: [PATCH 1/3] * Updates for Django 4.2+ and Python 3.8+ * Also includes updates for documentation and packaging --- CHANGES.rst | 5 ++++ LICENSE | 1 + README.rst | 48 +++++++++++++++++++++++++--------- django_superform/boundfield.py | 2 +- django_superform/fields.py | 11 +++++--- django_superform/forms.py | 13 ++++----- django_superform/widgets.py | 20 ++++---------- docs/quickstart.rst | 16 +++++++++++- pyproject.toml | 45 +++++++++++++++++++++++++++++++ pytest.ini | 4 +-- requirements.txt | 4 +-- setup.py => setup.py.bak | 0 tests/models.py | 4 +-- tests/requirements.txt | 13 +++++---- tests/settings.py | 11 +++++++- tests/test_boundfield.py | 7 ++--- tests/test_formsetfield.py | 2 +- tests/test_modelformfield.py | 1 + tests/test_widgets.py | 9 ++++--- tox.ini | 28 +++++++++++--------- 20 files changed, 167 insertions(+), 77 deletions(-) create mode 100644 pyproject.toml rename setup.py => setup.py.bak (100%) diff --git a/CHANGES.rst b/CHANGES.rst index 51c4d3e..7b35a84 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,6 +1,11 @@ Changelog ========= +0.5.0 +----- +* Upgrades functionality for Django 4.2+ +* Forked and taken over by Peter Natale + 0.4.0 ----- * Fix formset rendering in Django 1.9. `#17`_ diff --git a/LICENSE b/LICENSE index f9f0965..2c90a90 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,4 @@ +Copyright (c) 2024, Peter Natale Copyright (c) 2014, Gregor Müllegger All rights reserved. diff --git a/README.rst b/README.rst index 0484db1..25bf390 100644 --- a/README.rst +++ b/README.rst @@ -3,7 +3,7 @@ django-superform **Less sucking formsets.** -|build| |package| |gitter| +|build| |package| Documentation_ | Changelog_ | Requirements_ | Installation_ @@ -18,8 +18,8 @@ view is usually quite troublesome. You need to validate both the form and the formset manually and you cannot use django's generic FormView_. So here comes **django-superform** into play. -.. _formsets: https://docs.djangoproject.com/en/1.10/topics/forms/formsets/ -.. _FormView: https://docs.djangoproject.com/en/1.10/ref/class-based-views/generic-editing/#formview +.. _formsets: https://docs.djangoproject.com/en/5.1/topics/forms/formsets/ +.. _FormView: https://docs.djangoproject.com/en/5.1/ref/class-based-views/generic-editing/#formview Here we have an example for the usecase. Let's have a look at the ``forms.py``: @@ -37,7 +37,7 @@ Here we have an example for the usecase. Let's have a look at the fields = ('account', 'email',) - EmailFormSet = modelformset_factory(EmailForm) + EmailFormSet = forms.models.modelformset_factory(EmailForm) class SignupForm(SuperModelForm): @@ -50,6 +50,32 @@ Here we have an example for the usecase. Let's have a look at the fields = ('username',) +Alternatively, if you did not wish to use a formset_factory, model or inline, for InlineFormSetField, +you may pass it explicitly stated parent_model and model + +.. code-block:: python + + from django import forms + from django_superform import SuperModelForm, InlineFormSetField + from myapp.models import Account, Email + + + class EmailForm(forms.ModelForm): + class Meta: + model = Email + fields = ('account', 'email') + + + class SignupForm(SuperModelForm): + username = foms.charField() + # The model `Email` has a ForeignKey called `user` to `Account`. + emails=InlineFormSetField(parent_model=Account, model=Email) + + class Meta: + model = Account + fields = ('username',) + + So we assign the ``EmailFormSet`` as a field directly to the ``SignupForm``. That's where it belongs! Ok and how do I handle this composite form in the view? Have a look: @@ -97,8 +123,8 @@ And it just works. Requirements ------------ -- Python 2.7 or Python 3.3+ or PyPy -- Django 1.4+ +- Python 3.8+ or PyPy +- Django 4.2+ .. _Installation: @@ -107,7 +133,7 @@ Installation Install the desired version with pip_:: - pip install django-superform + pip install django-superform4 .. _pip: https://pip.pypa.io/en/stable/ @@ -124,9 +150,9 @@ Then add ``django-superform`` to ``INSTALLED_APPS`` in your settings file: Development ----------- -- Clone django-superform:: +- Clone django-superform4:: - git clone git@github.com:gregmuellegger/django-superform.git + git clone git@github.com:panatale1/django-superform.git - ``cd`` into the repository:: @@ -163,7 +189,3 @@ Full documentation is available on Read the Docs: https://django-superform.readt :alt: Package Version :scale: 100% :target: http://badge.fury.io/py/django-superform -.. |gitter| image:: https://badges.gitter.im/JoinChat.svg - :alt: Gitter Chat, discuss django-superform with others - :scale: 100% - :target: https://gitter.im/gregmuellegger/django-superform diff --git a/django_superform/boundfield.py b/django_superform/boundfield.py index a9774b6..deb190e 100644 --- a/django_superform/boundfield.py +++ b/django_superform/boundfield.py @@ -1,4 +1,4 @@ -from django.forms.forms import BoundField +from django.forms import BoundField class CompositeBoundField(BoundField): diff --git a/django_superform/fields.py b/django_superform/fields.py index 8f5297e..94b5c68 100644 --- a/django_superform/fields.py +++ b/django_superform/fields.py @@ -151,9 +151,10 @@ class RegistrationForm(SuperForm): prefix_name = 'form' widget = FormWidget - def __init__(self, form_class, kwargs=None, **field_kwargs): + def __init__(self, form_class, kwargs=None, initial=None, **field_kwargs): super(FormField, self).__init__(**field_kwargs) + self.initial = initial self.form_class = form_class if kwargs is None: kwargs = {} @@ -172,6 +173,7 @@ def get_form(self, form, name): Get an instance of the form. """ kwargs = self.get_kwargs(form, name) + kwargs.update({'use_required_attribute': False if kwargs.get('empty_permitted', False) is True else True}) form_class = self.get_form_class(form, name) composite_form = form_class( data=form.data if form.is_bound else None, @@ -279,7 +281,7 @@ def save(self, form, name, composite_form, commit): class ForeignKeyFormField(ModelFormField): - def __init__(self, form_class, kwargs=None, field_name=None, blank=None, + def __init__(self, form_class, initial=None, kwargs=None, field_name=None, blank=None, **field_kwargs): super(ForeignKeyFormField, self).__init__(form_class, kwargs, **field_kwargs) @@ -348,9 +350,10 @@ class FormSetField(CompositeField): prefix_name = 'formset' widget = FormSetWidget - def __init__(self, formset_class, kwargs=None, **field_kwargs): + def __init__(self, formset_class, initial=None, kwargs=None, **field_kwargs): super(FormSetField, self).__init__(**field_kwargs) + self.initial = initial self.formset_class = formset_class if kwargs is None: kwargs = {} @@ -434,7 +437,7 @@ class Meta: extra=1) """ - def __init__(self, parent_model=None, model=None, formset_class=None, + def __init__(self, initial=None, parent_model=None, model=None, formset_class=None, kwargs=None, **factory_kwargs): """ You need to either provide the ``formset_class`` or the ``model`` diff --git a/django_superform/forms.py b/django_superform/forms.py index 13fe99e..d0bfd72 100644 --- a/django_superform/forms.py +++ b/django_superform/forms.py @@ -79,7 +79,6 @@ def post_form(request): from django import forms from django.forms.forms import DeclarativeFieldsMetaclass, ErrorDict, ErrorList from django.forms.models import ModelFormMetaclass -from django.utils import six import copy from .fields import CompositeField @@ -156,10 +155,10 @@ class SuperFormMixin(object): from django_superform import SuperFormMetaclass import six - class MySuperForm(six.with_metaclass( - SuperFormMetaclass, + class MySuperForm( SuperFormMixin, - MyCustomForm)): + MyCustomForm, + metaclass=SuperFormMetaclass): pass The goal of a superform is to behave just like a normal django form but is @@ -372,8 +371,7 @@ def save_formsets(self, commit=True): self._extend_save_m2m('save_formsets_m2m', saved_composites) -class SuperModelForm(six.with_metaclass(SuperModelFormMetaclass, - SuperModelFormMixin, forms.ModelForm)): +class SuperModelForm(SuperModelFormMixin, forms.ModelForm, metaclass=SuperModelFormMetaclass): """ The ``SuperModelForm`` works like a Django ``ModelForm`` but has the capabilities of nesting like :class:`~django_superform.forms.SuperForm`. @@ -382,8 +380,7 @@ class SuperModelForm(six.with_metaclass(SuperModelFormMetaclass, """ -class SuperForm(six.with_metaclass(SuperFormMetaclass, - SuperFormMixin, forms.Form)): +class SuperForm(SuperFormMixin, forms.Form, metaclass=SuperFormMetaclass): """ The base class for all super forms. The goal of a superform is to behave just like a normal django form but is able to take composite fields, like diff --git a/django_superform/widgets.py b/django_superform/widgets.py index f5173ce..aacd9b0 100644 --- a/django_superform/widgets.py +++ b/django_superform/widgets.py @@ -24,33 +24,23 @@ def get_context_data(self): return {} def get_context(self, name, value, attrs=None): - context = { - 'name': name, - 'hidden': self.is_hidden, - 'required': self.is_required, - # In our case ``value`` is the form or formset instance. - 'value': value, - } + context = super().get_context(name, value, attrs) if self.value_context_name: - context[self.value_context_name] = value - - if self.is_hidden: - context['hidden'] = True + context['widget'][self.value_context_name] = value context.update(self.get_context_data()) - context['attrs'] = self.build_attrs(attrs) return context - def render(self, name, value, attrs=None, **kwargs): + def render(self, name, value, attrs=None, renderer=None, **kwargs): template_name = kwargs.pop('template_name', None) if template_name is None: template_name = self.template_name context = self.get_context(name, value, attrs=attrs or {}, **kwargs) return loader.render_to_string( template_name, - dictionary=context, - context_instance=self.context_instance) + context=context['widget'], + ) class FormWidget(TemplateWidget): diff --git a/docs/quickstart.rst b/docs/quickstart.rst index b213c95..0c0513d 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -3,4 +3,18 @@ Quickstart ========== -TODO. +Installation +------------ + +* Install django-superform4:: + + pip install django-superform4 + +* Add ``'django_superform'`` to your ``INSTALLED_APPS`` settings:: + + INSTALLED_APPS = [ + # other apps + "django_superform", + ] + +* Use like you would any other Form or Field. Subclass ``SuperModelForm`` to make ModelForms that can have InlineFormSetFields as fields diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..abc2503 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[project] +name = "django-superform4" +version = "0.5.0" +authors = [ + { name="Gregor Müllegger", email="gregor@mullegger.de" }, +] +maintainers = [ + { name="Peter Natale", email="panatale1@gmail.com" }, +] +description = "So much easier handling of formsets for Django 4.2+. Drop-in replacement for django-superform" +readme = "README.rst" +requires-python = ">=3.8" +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Framework :: Django", + "Framework :: Django :: 4.2", + "Framework :: Django :: 5.0", + "Framework :: Django :: 5.1", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Intended Audience :: Developers", + "Natural Language :: English", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Software Development :: User Interfaces", + "Development Status :: 3 - Alpha", +] +dependencies = [ + "django>=4.2", +] +license = { file = "LICENSE" } +keywords = ["formsets", "form fields", "inline formsets"] + +[project.urls] +Homepage = "https://github.com/panatale1/django-superform" +Documentation = "https://django-superform4.readthedocs.io" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/pytest.ini b/pytest.ini index f2c251e..4fd7723 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,4 @@ [pytest] -addopts = --cov=django_superform --cov-report=term-missing -python_paths = . +addopts = --cov=django_superform --cov-report=term-missing --import-mode=importlib DJANGO_SETTINGS_MODULE=tests.settings +python_files = test_*.py diff --git a/requirements.txt b/requirements.txt index 4817391..1ef7e06 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -Django>=1.8,<1.9 -Sphinx==1.3.1 +Django>=4.2 +Sphinx==8.0.2 -r tests/requirements.txt diff --git a/setup.py b/setup.py.bak similarity index 100% rename from setup.py rename to setup.py.bak diff --git a/tests/models.py b/tests/models.py index 45f61db..049ae52 100644 --- a/tests/models.py +++ b/tests/models.py @@ -15,11 +15,11 @@ class Post(models.Model): """ title = models.CharField(max_length=50) - series = models.ForeignKey('Series', null=True, blank=True) + series = models.ForeignKey(Series, null=True, blank=True, on_delete=models.CASCADE) class Image(models.Model): - post = models.ForeignKey('Post', related_name='images') + post = models.ForeignKey(Post, related_name='images', on_delete=models.CASCADE) name = models.CharField(max_length=50) position = models.PositiveIntegerField(default=0) diff --git a/tests/requirements.txt b/tests/requirements.txt index 498da52..17942d8 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,7 +1,6 @@ -coverage==3.7.1 -tox==2.0.1 -flake8==2.5.4 -pytest==2.8.7 -pytest-cov==2.2.1 -pytest-django==2.9.1 -pytest-pythonpath==0.7 +coverage>=7.6.1 +tox>=4.18.0 +flake8>=7.1.1 +pytest>=8.3.2 +pytest-cov>=5.0.0 +pytest-django>=4.9.0 diff --git a/tests/settings.py b/tests/settings.py index f61e178..b30cc54 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -9,7 +9,6 @@ } USE_I18N = True -USE_L10N = True INSTALLED_APPS = [ 'django_superform', @@ -21,3 +20,13 @@ STATIC_URL = '/static/' SECRET_KEY = '0' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': ['tests/templates', 'django-superform/templates'], + 'APP_DIRS': True + } +] + +USE_TZ = False diff --git a/tests/test_boundfield.py b/tests/test_boundfield.py index 7450ad8..232f41e 100644 --- a/tests/test_boundfield.py +++ b/tests/test_boundfield.py @@ -1,5 +1,5 @@ from django import forms -from django.forms.forms import BoundField +from django.forms import BoundField from django.forms.formsets import formset_factory from django.test import TestCase from django_superform import FormField @@ -34,7 +34,8 @@ def test_it_is_nonzero_for_empty_formsets(self): form = AccountForm() bf = form['emails'] self.assertTrue(isinstance(bf, CompositeBoundField)) - self.assertEqual(len(bf), 0) + self.assertIsNone(bf.initial) + #self.assertEqual(len(bf), 0) self.assertEqual(bool(bf), True) def test_it_is_nonzero_for_filled_formsets(self): @@ -43,7 +44,7 @@ def test_it_is_nonzero_for_filled_formsets(self): }) bf = form['emails'] self.assertTrue(isinstance(bf, CompositeBoundField)) - self.assertEqual(len(bf), 1) + self.assertEqual(len(bf.initial), 1) self.assertEqual(bool(bf), True) def test_it_is_nonzero_for_forms(self): diff --git a/tests/test_formsetfield.py b/tests/test_formsetfield.py index 89734ea..218ce4a 100644 --- a/tests/test_formsetfield.py +++ b/tests/test_formsetfield.py @@ -14,7 +14,7 @@ class Meta: model = Post fields = ['title'] - images_inlineformset = InlineFormSetField(Post, Image, fields=['name']) + images_inlineformset = InlineFormSetField(parent_model=Post, model=Image, fields=['name']) images_modelformset = ModelFormSetField(ImageFormSet) def __init__(self, *args, **kwargs): diff --git a/tests/test_modelformfield.py b/tests/test_modelformfield.py index 4ee592a..6e06629 100644 --- a/tests/test_modelformfield.py +++ b/tests/test_modelformfield.py @@ -1,4 +1,5 @@ from django import forms +from django.forms.utils import ErrorList from django.template import Context, Template from django.test import TestCase from django_superform import SuperModelForm, ModelFormField diff --git a/tests/test_widgets.py b/tests/test_widgets.py index fd43d0c..661ad8f 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -11,7 +11,7 @@ def test_it_puts_hidden_variable_in_context(self): widget = TemplateWidget() widget_context = widget.get_context('foo', None) - self.assertEqual(widget_context['hidden'], False) + self.assertEqual(widget_context['widget']['is_hidden'], False) class HiddenWidget(TemplateWidget): is_hidden = True @@ -19,7 +19,7 @@ class HiddenWidget(TemplateWidget): hidden_widget = HiddenWidget() hidden_widget_context = hidden_widget.get_context('foo', None) - self.assertEqual(hidden_widget_context['hidden'], True) + self.assertEqual(hidden_widget_context['widget']['is_hidden'], True) def test_it_recognizes_value_context_name(self): class DifferentValueNameWidget(TemplateWidget): @@ -29,11 +29,12 @@ class DifferentValueNameWidget(TemplateWidget): widget = DifferentValueNameWidget() context = widget.get_context('foo', value) - self.assertTrue(context['strange_name'] is value) + self.assertTrue(context['widget']['strange_name'] is value) # The name 'value' is always available, regardless of the # value_context_name. - self.assertTrue(context['value'] is value) + self.assertEqual(context['widget']['value'], str(value)) +# self.assertTrue(context['widget']['value'] is str(value)) def test_it_renders_template_from_attribute(self): class TemplateAttributeWidget(TemplateWidget): diff --git a/tox.ini b/tox.ini index 00f7a58..09cd75a 100644 --- a/tox.ini +++ b/tox.ini @@ -3,22 +3,19 @@ minversion = 1.8 envlist = docs, flake8, - py26-{14,16}, - py27-{14,16,17,18,19}, - py33-{16,17,18}, - py34-{16,17,18,19}, - py35-{18,19}, - pypy-{14,16,17,18,19} + py38-{42}, + py39-{42}, + py310-{42, 50, 51}, + py311-{42, 50, 51}, + py312-{42, 50, 51}, [testenv] deps = - 14: Django >= 1.4, < 1.5 - 16: Django >= 1.6, < 1.7 - 17: Django >= 1.7, < 1.8 - 18: Django >= 1.8, < 1.9 - 19: Django >= 1.9, < 1.10 + 42: Django >= 4.2.0, < 5.0.0 + 50: Django >= 5.0.0, < 5.1.0 + 51: Django >= 5.1.0, < 5.2.0 -r{toxinidir}/tests/requirements.txt -commands = py.test --cov django_superform {posargs:tests} +commands = pytest --cov django_superform {posargs} tests [testenv:docs] changedir = docs @@ -26,8 +23,13 @@ deps = -r{toxinidir}/requirements.txt commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html +allowlist_externals = python, sphinx-build [testenv:flake8] deps = - flake8==2.5.4 + flake8 >=2.5.4 commands = flake8 django_superform + +[flake8] +max-line-length=120 +ignore = E501, W504, W605 From f55fd47d8c9bd2f70b504f31a55a657c17c887a1 Mon Sep 17 00:00:00 2001 From: Pete Natale Date: Fri, 6 Sep 2024 13:20:04 -0400 Subject: [PATCH 2/3] further updates --- README.rst | 10 ---------- pyproject.toml | 3 +++ setup.py.bak | 1 + 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/README.rst b/README.rst index 25bf390..c2e4701 100644 --- a/README.rst +++ b/README.rst @@ -3,8 +3,6 @@ django-superform **Less sucking formsets.** -|build| |package| - Documentation_ | Changelog_ | Requirements_ | Installation_ A ``SuperForm`` is absolutely super if you want to nest a lot of forms in each @@ -181,11 +179,3 @@ Full documentation is available on Read the Docs: https://django-superform.readt .. _Changelog: https://django-superform.readthedocs.org/en/latest/changelog.html .. _Documentation: https://django-superform.readthedocs.org/ -.. |build| image:: https://travis-ci.org/gregmuellegger/django-superform.svg?branch=master - :alt: Build Status - :scale: 100% - :target: https://travis-ci.org/gregmuellegger/django-superform -.. |package| image:: https://badge.fury.io/py/django-superform.svg - :alt: Package Version - :scale: 100% - :target: http://badge.fury.io/py/django-superform diff --git a/pyproject.toml b/pyproject.toml index abc2503..5b8e210 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,3 +43,6 @@ Documentation = "https://django-superform4.readthedocs.io" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["django_superform"] diff --git a/setup.py.bak b/setup.py.bak index 4e683b0..2e8131e 100644 --- a/setup.py.bak +++ b/setup.py.bak @@ -33,6 +33,7 @@ setup( long_description=u'\n\n'.join(( read('README.rst'), read('CHANGES.rst'))), + long_description_content_type='text/rst', classifiers=[ 'Development Status :: 4 - Beta', 'Environment :: Web Environment', From 29dfe7b8d39581602e419d39dd69fbf06bcf1acc Mon Sep 17 00:00:00 2001 From: Peter Natale Date: Thu, 26 Sep 2024 09:10:11 -0400 Subject: [PATCH 3/3] fixes docs/conf.py --- docs/conf.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index e122762..7cf2557 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -211,11 +211,11 @@ def find_version(*file_paths): latex_elements = { # The paper size ('letterpaper' or 'a4paper'). - #'papersize': 'letterpaper', + # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). - #'pointsize': '10pt', + # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. - #'preamble': '', + # 'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples