├── .coveragerc ├── .github └── workflows │ └── python-package.yml ├── .gitignore ├── AUTHORS ├── CHANGELOG ├── CONTRIBUTING ├── INSTALL ├── LICENSE ├── MANIFEST.in ├── README.rst ├── demo ├── Dockerfile ├── README.rst ├── db.sqlite3 ├── demo │ ├── __init__.py │ ├── apps.py │ ├── middleware.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_auto_20200518_1024.py │ │ ├── 0003_auto_20200519_1228.py │ │ └── __init__.py │ ├── models.py │ ├── templates │ │ ├── _base.html │ │ ├── _head_bootstrap4.html │ │ ├── _head_bootstrap5.html │ │ ├── _head_none.html │ │ ├── demo_nav.html │ │ ├── index.html │ │ ├── index_bootstrap4.html │ │ ├── index_bootstrap5.html │ │ └── index_none.html │ ├── urls.py │ ├── utils.py │ └── views.py ├── docker-compose.yml ├── manage.py ├── requirements.txt └── settings │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── docs ├── Makefile ├── build │ └── empty ├── requirements.txt └── source │ ├── _static │ └── empty │ ├── _templates │ └── empty │ ├── advanced.rst │ ├── bs4.rst │ ├── bs5.rst │ ├── conf.py │ ├── filtering.rst │ ├── index.rst │ ├── quickstart.rst │ └── rst_guide.rst ├── pytest.ini ├── setup.cfg ├── setup.py ├── siteforms ├── __init__.py ├── apps.py ├── base.py ├── composers │ ├── __init__.py │ ├── base.py │ ├── bootstrap4.py │ └── bootstrap5.py ├── fields.py ├── formsets.py ├── locale │ ├── en │ │ └── LC_MESSAGES │ │ │ └── django.po │ └── ru │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── metas.py ├── tests │ ├── __init__.py │ ├── conftest.py │ ├── datafixtures │ │ ├── bs4_basic_1.html │ │ ├── bs5_basic_1.html │ │ └── nocss_basic_1.html │ ├── test_bootstrap4.py │ ├── test_bootstrap5.py │ ├── test_common.py │ ├── test_filtering.py │ ├── test_nocss.py │ ├── test_widgets.py │ └── testapp │ │ ├── __init__.py │ │ ├── models.py │ │ └── templates │ │ └── mywidget.html ├── toolbox.py ├── utils.py └── widgets.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | include = siteforms/* 3 | omit = siteforms/migrations/*, siteforms/tests/* 4 | 5 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | python-version: [3.7, 3.8, 3.9, "3.10"] 18 | django-version: [2.2, 3.0, 3.1, 3.2, 4.0, 4.1] 19 | 20 | exclude: 21 | 22 | - python-version: 3.7 23 | django-version: 4.0 24 | 25 | - python-version: 3.7 26 | django-version: 4.1 27 | 28 | steps: 29 | - uses: actions/checkout@v2 30 | - name: Set up Python ${{ matrix.python-version }} & Django ${{ matrix.django-version }} 31 | uses: actions/setup-python@v2 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | - name: Install deps 35 | run: | 36 | python -m pip install pytest coverage coveralls "Django~=${{ matrix.django-version }}.0" 37 | - name: Run tests 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.github_token }} 40 | run: | 41 | coverage run --source=siteforms setup.py test 42 | coveralls --service=github 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .project 2 | .pydevproject 3 | .idea 4 | .tox 5 | __pycache__ 6 | *.pyc 7 | *.pyo 8 | *.egg-info 9 | docs/_build/ 10 | 11 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | django-siteforms authors 2 | ======================== 3 | 4 | Created by Igor `idle sign` Starikov. 5 | 6 | 7 | Contributors 8 | ------------ 9 | 10 | Andrey Tremasov 11 | 12 | 13 | 14 | Translators 15 | ----------- 16 | 17 | Here could be your name. 18 | 19 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | django-siteforms changelog 2 | ========================== 3 | 4 | 5 | v1.2.0 [2023-09-08] 6 | ------------------- 7 | + Support filtering of one field with two multiple filters. 8 | + Support multiple field values for filtering. 9 | 10 | 11 | v1.1.0 [2023-02-02] 12 | ------------------- 13 | + Add support for fields for model properties (closes #10). 14 | * Allow getting 'widgets_readonly' for forms without Meta (closes #8). 15 | * Json subforms serialization improved (support date-related, decimal, uuid fields) (closes #9). 16 | * Now forms handle both data/files given in args/kwargs and fetched from 'request'+'src' (closes #11). 17 | 18 | 19 | v1.0.0 [2023-01-21] 20 | ------------------- 21 | ! Dropped QA for Python 3.6. 22 | + Add 'opt_feedback_valid' for Bootstrap 4, 5 (closes #6). 23 | + Add 'target_url' form parameter (closes #7). 24 | + Add basic support for filtering forms (closes #5). 25 | + Add support for Bootstrap 5 (closes #4). 26 | * Do not leak CSRF token for GET (closes #3). 27 | * Rename SubformBoundField -> EnhancedBoundField. 28 | 29 | 30 | v0.9.1 [2021-12-18] 31 | ------------------- 32 | * Django 4.0 compatibility improved. 33 | 34 | 35 | v0.9.0 [2021-10-13] 36 | ------------------- 37 | + Added support for append operation for attrs. 38 | + Added support for Form.Meta.widgets_readonly. 39 | 40 | 41 | v0.8.0 [2021-09-24] 42 | ------------------- 43 | + ReadOnlyWidget now can represent ModelMultipleChoiceField. 44 | * Add hidden fields for custom layout without ALL_FIELDS. 45 | * Fix 'render_form_tag' handling for Bootstrap 4. 46 | * Improved disable, readonly and hidden fields handling. 47 | * Improved multipart form detection and files passing to subforms. 48 | 49 | 50 | v0.7.0 [2021-09-19] 51 | ------------------- 52 | ! 'Composer.opt_render_form' was renamed into '.opt_render_form_tag'. 53 | + 'ReadOnlyWidget' now can deal with 'BooleanField'. 54 | + Added 'render_form_tag' form argument. 55 | * Fixed subforms rendering when 'readonly_fields' form argument is used. 56 | 57 | 58 | v0.6.0 [2021-09-06] 59 | ------------------- 60 | + Now nested form items (FK, M2M) can be initialized automatically. 61 | + ReadOnlyWidget. Customizability improved. 62 | + ReadOnlyWidget. Now can efficiently handle Foreign Keys. 63 | + ReadOnlyWidget. Now can handle missing choices. 64 | 65 | 66 | v0.5.0 [2021-08-29] 67 | ------------------- 68 | + Added basic formset support (many-to-many). 69 | + Added support for '__all__' in 'disabled_fields'. 70 | + Added support for 'opt_title_label' and 'opt_title_help' Composer options. 71 | + Added support for 'readonly_fields' in forms. 72 | + Added support for deeply nested forms. 73 | + Added support for FK models as subforms. 74 | + Form widgets now have 'bound_field' attribute to allow complex widgets rendering. 75 | + Layout. Added support for field stacking. 76 | 77 | v0.4.0 [2020-12-26] 78 | ------------------- 79 | + Add Russian locale. 80 | + Add shortcut for setting form ID and fields ID prefix. 81 | 82 | 83 | v0.3.0 [2020-05-19] 84 | ------------------- 85 | + Added support for multiple forms handled by the same view. 86 | + Added support for non-field errors. 87 | + Now showing hidden fields errors in non-field area. 88 | + Subform fields errors is now rendered in non-field area. 89 | 90 | 91 | v0.2.0 [2020-05-18] 92 | ------------------- 93 | + Added automatic files handling. 94 | + Added basic support for subforms. 95 | + Added support for 'ALL_FIELDS' as group row. 96 | + Allow groups without titles. 97 | + Bootstrap 4. Auto layout in rows. 98 | + Form data populated only if request method matches. 99 | * Exposed 'toolbox.Form'. 100 | * Fixed 'disabled_fields' and 'hidden_fields' setting declaratively. 101 | 102 | 103 | v0.1.0 [2020-05-13] 104 | ------------------- 105 | + Basic functionality. -------------------------------------------------------------------------------- /CONTRIBUTING: -------------------------------------------------------------------------------- 1 | django-siteforms contributing 2 | ============================= 3 | 4 | 5 | Submit issues 6 | ------------- 7 | 8 | If you spotted something weird in application behavior or want to propose a feature you are welcome. 9 | 10 | 11 | Write code 12 | ---------- 13 | If you are eager to participate in application development and to work on an existing issue (whether it should 14 | be a bugfix or a feature implementation), fork, write code, and make a pull request right from the forked project page. 15 | 16 | 17 | Spread the word 18 | --------------- 19 | 20 | If you have some tips and tricks or any other words that you think might be of interest for the others — publish it 21 | wherever you find convenient. 22 | 23 | 24 | See also: https://github.com/idlesign/django-siteforms 25 | 26 | -------------------------------------------------------------------------------- /INSTALL: -------------------------------------------------------------------------------- 1 | django-siteforms installation 2 | ============================= 3 | 4 | 5 | Python ``pip`` package is required to install ``django-siteforms``. 6 | 7 | 8 | From sources 9 | ------------ 10 | 11 | Use the following command line to install ``django-siteforms`` from sources directory (containing setup.py): 12 | 13 | pip install . 14 | 15 | or 16 | 17 | python setup.py install 18 | 19 | 20 | From PyPI 21 | --------- 22 | 23 | Alternatively you can install ``django-siteforms`` from PyPI: 24 | 25 | pip install django-siteforms 26 | 27 | 28 | Use `-U` flag for upgrade: 29 | 30 | pip install -U django-siteforms 31 | 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020-2023, Igor `idle sign` Starikov 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the django-siteforms nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | 29 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include CHANGELOG 3 | include INSTALL 4 | include LICENSE 5 | include README.rst 6 | 7 | include docs/Makefile 8 | recursive-include docs *.rst 9 | recursive-include docs *.py 10 | recursive-include tests * 11 | 12 | recursive-include siteforms/locale * 13 | recursive-include siteforms/tests * 14 | recursive-include siteforms/migrations *.py 15 | recursive-include siteforms/templates *.html 16 | recursive-include siteforms/templatetags *.py 17 | recursive-include siteforms/management *.py 18 | 19 | recursive-exclude * __pycache__ 20 | recursive-exclude * *.py[co] 21 | recursive-exclude * empty 22 | 23 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-siteforms 2 | ================ 3 | https://github.com/idlesign/django-siteforms 4 | 5 | |release| |lic| |coverage| 6 | 7 | .. |release| image:: https://img.shields.io/pypi/v/django-siteforms.svg 8 | :target: https://pypi.python.org/pypi/django-siteforms 9 | 10 | .. |lic| image:: https://img.shields.io/pypi/l/django-siteforms.svg 11 | :target: https://pypi.python.org/pypi/django-siteforms 12 | 13 | .. |coverage| image:: https://img.shields.io/coveralls/idlesign/django-siteforms/master.svg 14 | :target: https://coveralls.io/r/idlesign/django-siteforms 15 | 16 | 17 | Description 18 | ----------- 19 | 20 | *Django reusable app to simplify form construction* 21 | 22 | For those who consider maintaining templates-based forms solutions for Django a burden. 23 | 24 | Features: 25 | 26 | * Full form rendering support, including prolog and submit button 27 | * Subforms support (represent entire other form as a form field): JSON, Foreign Key, Many-to-Many 28 | * Field groups 29 | * Declarative attributes for elements 30 | * Simplified declarative forms layout, allowing fields ordering 31 | * Simple ways to make fields hidden, disabled, readonly 32 | * Support for fields from model's properties 33 | * Aria-friendly (Accessible Rich Internet Applications) 34 | * Complex widgets (e.g. using values from multiple fields) support 35 | * Filter-forms (use form for queryset filtering) 36 | 37 | Supported styling: 38 | 39 | * No CSS 40 | * Bootstrap 4 41 | * Bootstrap 5 42 | 43 | 44 | Usage 45 | ----- 46 | 47 | To render a form in templates just address a variable, e.g. ``
{{ form }}
``. 48 | 49 | .. note:: By default there's no need to add a submit button and wrap it all into ``
``. 50 | 51 | Basic 52 | ~~~~~ 53 | 54 | Let's show how to build a simple form. 55 | 56 | .. code-block:: python 57 | 58 | from django.shortcuts import render 59 | from siteforms.composers.bootstrap5 import Bootstrap5 60 | from siteforms.toolbox import ModelForm 61 | 62 | 63 | class MyForm(ModelForm): 64 | """This form will show us how siteforms works.""" 65 | 66 | disabled_fields = {'somefield'} # Declarative way of disabling fields. 67 | hidden_fields = {'otherfield'} # Declarative way of hiding fields. 68 | readonly_fields = {'anotherfield'} # Declarative way of making fields readonly. 69 | 70 | class Composer(Bootstrap5): 71 | """This will instruct siteforms to compose this 72 | form using Bootstrap 5 styling. 73 | 74 | """ 75 | class Meta: 76 | model = MyModel # Suppose you have a model class already. 77 | fields = '__all__' 78 | 79 | def my_view(request): 80 | # Initialize form using data from POST. 81 | my_form = MyForm(request=request, src='POST') 82 | is_valid = form.is_valid() 83 | return render(request, 'mytemplate.html', {'form': my_form}) 84 | 85 | 86 | Composer options 87 | ~~~~~~~~~~~~~~~~ 88 | 89 | Now let's see how to tune our form. 90 | 91 | .. code-block:: python 92 | 93 | from siteforms.composers.bootstrap5 import Bootstrap5, FORM, ALL_FIELDS 94 | 95 | class Composer(Bootstrap5): 96 | 97 | opt_size='sm' # Bootstrap 5 has sizes, so let's make our form small. 98 | 99 | # Element (fields, groups, form, etc.) attributes are ruled by `attrs`. 100 | # Let's add rows=2 to our `contents` model field. 101 | attrs={'contents': {'rows': 2}} 102 | 103 | # To group fields into named groups describe them in `groups`. 104 | groups={ 105 | 'basic': 'Basic attributes', 106 | 'other': 'Other fields', 107 | } 108 | 109 | # We apply custom layout to our form. 110 | layout = { 111 | FORM: { 112 | 'basic': [ # First we place `basic` group. 113 | # The following three fields are in the same row - 114 | # two fields in the right column are stacked. 115 | ['title', ['date_created', 116 | 'date_updated']], 117 | 'contents', # This one field goes into a separate row. 118 | ], 119 | # We place all the rest fields into `other` group. 120 | 'other': ALL_FIELDS, 121 | } 122 | } 123 | 124 | 125 | Documentation 126 | ------------- 127 | 128 | https://django-siteforms.readthedocs.org/ 129 | -------------------------------------------------------------------------------- /demo/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7-alpine 2 | 3 | ADD . /code 4 | WORKDIR /code 5 | 6 | ADD requirements.txt /requirements.txt 7 | RUN pip install -r /requirements.txt -------------------------------------------------------------------------------- /demo/README.rst: -------------------------------------------------------------------------------- 1 | django-siteforms demo 2 | ===================== 3 | http://github.com/idlesign/django-siteforms 4 | 5 | 6 | This Django project demonstrates siteforms basic features. 7 | 8 | Expects Django 2+ 9 | 10 | 11 | How to run 12 | ---------- 13 | 14 | Docker 15 | ~~~~~~ 16 | 17 | 1. Run `docker-compose up` 18 | 2. Go to http://localhost:8000 19 | 20 | Manually 21 | ~~~~~~~~ 22 | 23 | 1. Install the requirements with `pip install -r requirements.txt` 24 | 2. Run the server `python manage.py runserver` 25 | 3. Go to http://localhost:8000 26 | 27 | Admin 28 | ~~~~~ 29 | 30 | Admin (http://localhost:8000) credentials: 31 | 32 | Login: `demo` 33 | Password: `demodemo` 34 | -------------------------------------------------------------------------------- /demo/db.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-siteforms/d9df98da64447ab01841495e8c495363cefdc399/demo/db.sqlite3 -------------------------------------------------------------------------------- /demo/demo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-siteforms/d9df98da64447ab01841495e8c495363cefdc399/demo/demo/__init__.py -------------------------------------------------------------------------------- /demo/demo/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DemoConfig(AppConfig): 5 | 6 | name = 'demo' 7 | -------------------------------------------------------------------------------- /demo/demo/middleware.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import activate 2 | 3 | 4 | def language_activator(get_response): 5 | 6 | def middleware(request): 7 | 8 | lang = request.GET.get('lang', 'en') 9 | activate(lang) 10 | 11 | request.lang = lang 12 | 13 | return get_response(request) 14 | 15 | return middleware 16 | -------------------------------------------------------------------------------- /demo/demo/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-14 09:36 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Author', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('name', models.CharField(max_length=100, verbose_name='Name')), 20 | ], 21 | ), 22 | migrations.CreateModel( 23 | name='Article', 24 | fields=[ 25 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 26 | ('date_created', models.DateTimeField(auto_created=True, blank=True, verbose_name='Created')), 27 | ('title', models.CharField(help_text='Short descriptive text', max_length=200, verbose_name='Title')), 28 | ('to_hide', models.CharField(blank=True, default='yes', help_text='Secret field', max_length=10, verbose_name='To hide')), 29 | ('dummy', models.CharField(blank=True, default='dummy', help_text='This is just a dummy', max_length=100, verbose_name='Dummy')), 30 | ('email', models.EmailField(help_text='Where to send a message', max_length=254, verbose_name='Email')), 31 | ('contents', models.TextField(verbose_name='Contents')), 32 | ('approved', models.BooleanField(default=False, help_text='Whether it was approved', verbose_name='Approved')), 33 | ('status', models.IntegerField(choices=[(0, 'Draft'), (1, 'Published')], default=0, help_text='Article status', verbose_name='Status')), 34 | ('attach', models.FileField(help_text='Custom user attachment', upload_to='', verbose_name='Attachment')), 35 | ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='demo.Author', verbose_name='Author')), 36 | ], 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /demo/demo/migrations/0002_auto_20200518_1024.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-18 10:24 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('demo', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='article', 15 | name='formsub1', 16 | field=models.CharField(blank=True, default='{"first": "f", "second": "s"}', help_text='This part is from subform', max_length=250, verbose_name='Subform'), 17 | ), 18 | migrations.AlterField( 19 | model_name='article', 20 | name='attach', 21 | field=models.FileField(blank=True, help_text='Custom user attachment', null=True, upload_to='', verbose_name='Attachment'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /demo/demo/migrations/0003_auto_20200519_1228.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-19 12:28 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('demo', '0002_auto_20200518_1024'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='article', 15 | name='date_created', 16 | field=models.DateTimeField(auto_now_add=True, verbose_name='Created'), 17 | ), 18 | migrations.AlterField( 19 | model_name='article', 20 | name='formsub1', 21 | field=models.CharField(blank=True, default='{"first": "f", "second": "s"}', help_text='This part is from subform', max_length=35, verbose_name='Subform'), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /demo/demo/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-siteforms/d9df98da64447ab01841495e8c495363cefdc399/demo/demo/migrations/__init__.py -------------------------------------------------------------------------------- /demo/demo/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Author(models.Model): 5 | 6 | name = models.CharField('Name', max_length=100) 7 | 8 | def __str__(self): 9 | return f'{self.name} ({self.id})' 10 | 11 | 12 | class Article(models.Model): 13 | 14 | date_created = models.DateTimeField('Created', auto_now_add=True, blank=True) 15 | 16 | author = models.ForeignKey(Author, verbose_name='Author', on_delete=models.CASCADE) 17 | 18 | title = models.CharField('Title', max_length=200, help_text='Short descriptive text') 19 | 20 | to_hide = models.CharField('To hide', max_length=10, help_text='Secret field', default='yes', blank=True) 21 | 22 | formsub1 = models.CharField( 23 | 'Subform', max_length=35, help_text='This part is from subform', 24 | default='{"first": "f", "second": "s"}', blank=True) 25 | 26 | dummy = models.CharField('Dummy', max_length=100, help_text='This is just a dummy', default='dummy', blank=True) 27 | 28 | email = models.EmailField('Email', help_text='Where to send a message') 29 | 30 | contents = models.TextField('Contents') 31 | 32 | approved = models.BooleanField('Approved', default=False, help_text='Whether it was approved') 33 | status = models.IntegerField( 34 | 'Status', default=0, choices={0: 'Draft', 1: 'Published'}.items(), help_text='Article status') 35 | 36 | attach = models.FileField(verbose_name='Attachment', help_text='Custom user attachment', null=True, blank=True) 37 | -------------------------------------------------------------------------------- /demo/demo/templates/_base.html: -------------------------------------------------------------------------------- 1 | {% load static i18n %} 2 | 3 | 4 | 5 | 6 | django-siteforms demo > {{ title }} > ({% get_current_language as LANGUAGE_CODE %}{{LANGUAGE_CODE}}) 7 | 8 | 9 | 10 | {% include tpl_head %} 11 | 12 | 33 | 34 | 35 | 36 | 37 | {% include 'demo_nav.html' %} 38 |

{{ title }}

39 | {% block body %}{% endblock %} 40 | {% block listing %} 41 |
42 |

Filtering

43 |
44 | {{ form_filtering1 }} 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | {% for article in listing %} 54 | 55 | 56 | 57 | 58 | 59 | 60 | {% endfor %} 61 |
createdtitleapprovedstatus
{{ article.date_created }}{{ article.title }}{{ article.approved }}{{ article.status }}
62 |
63 | {% endblock %} 64 | 65 | 66 | -------------------------------------------------------------------------------- /demo/demo/templates/_head_bootstrap4.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /demo/demo/templates/_head_bootstrap5.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /demo/demo/templates/_head_none.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-siteforms/d9df98da64447ab01841495e8c495363cefdc399/demo/demo/templates/_head_none.html -------------------------------------------------------------------------------- /demo/demo/templates/demo_nav.html: -------------------------------------------------------------------------------- 1 |
2 | {% for theme_name, title in nav_items.items %} 3 | {{ title }}  |  4 | {% endfor %} 5 |
6 | Options: 7 | {% for opt, opt_val in opts.items %} 8 |
{{ opt }}: 9 | {% if not opt_val or opt_val == "1" %} 10 | off 11 | {% endif %} 12 | 13 | {% if not opt_val or opt_val == "0" %} 14 | on 15 | {% endif %} 16 |
17 | {% endfor %} 18 |
19 |
django-siteforms  
20 |
21 |
22 |
23 | -------------------------------------------------------------------------------- /demo/demo/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends '_base.html' %} 2 | 3 | {% block body %} 4 | {% include tpl_realm %} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /demo/demo/templates/index_bootstrap4.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |
5 |
6 | {{ form1 }} 7 |
8 |
9 | 10 |
11 | -------------------------------------------------------------------------------- /demo/demo/templates/index_bootstrap5.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |
5 |
6 | {{ form1 }} 7 |
8 |
9 | 10 |
11 | -------------------------------------------------------------------------------- /demo/demo/templates/index_none.html: -------------------------------------------------------------------------------- 1 | 2 | {{ form1 }} 3 | -------------------------------------------------------------------------------- /demo/demo/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import index, themed 4 | 5 | 6 | urlpatterns = [ 7 | path('', index, name='index'), 8 | path('/', themed, name='themed'), 9 | ] 10 | -------------------------------------------------------------------------------- /demo/demo/utils.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | 4 | def render_themed(request, view_type, context): 5 | theme = context['theme'] 6 | context.update({ 7 | 'tpl_head': '_head_%s.html' % theme, 8 | 'tpl_realm': '%s_%s.html' % (view_type, theme) 9 | }) 10 | return render(request, '%s.html' % view_type, context) 11 | -------------------------------------------------------------------------------- /demo/demo/views.py: -------------------------------------------------------------------------------- 1 | from siteforms.composers.base import FormComposer 2 | from siteforms.composers.bootstrap4 import Bootstrap4, FORM, ALL_FIELDS 3 | from siteforms.composers.bootstrap5 import Bootstrap5 4 | from siteforms.toolbox import ModelForm, Form, fields, FilteringModelForm 5 | from .models import Article, Author 6 | from .utils import render_themed 7 | 8 | 9 | THEMES = { 10 | 'none': ('No CSS', (FormComposer,)), 11 | 'bootstrap4': ('Bootstrap 4', (Bootstrap4,)), 12 | 'bootstrap5': ('Bootstrap 5', (Bootstrap5,)), 13 | } 14 | 15 | 16 | class SubFormBase(Form): 17 | 18 | first = fields.CharField(label='some', help_text='some help') 19 | second = fields.ChoiceField(label='variants', choices={'1': 'one', '2': 'two'}.items()) 20 | 21 | 22 | class ArticleFormMeta: 23 | 24 | model = Article 25 | fields = '__all__' 26 | 27 | 28 | class AuthorFormMeta: 29 | 30 | model = Author 31 | fields = '__all__' 32 | 33 | 34 | opts = { 35 | 'layout': ( 36 | { 37 | FORM: { 38 | 'basic': [ 39 | ['title', ['date_created', 'author', 'status']], 40 | 'contents', 41 | ], 42 | '_': ['dummy'], 43 | 'other': ALL_FIELDS, 44 | } 45 | }, 46 | None, 47 | ), 48 | 'opt_form_inline': (True, False), 49 | 'opt_render_labels': (True, False), 50 | 'opt_render_help': (True, False), 51 | 'opt_placeholder_label': (True, False), 52 | 'opt_placeholder_help': (True, False), 53 | 'opt_title_label': (True, False), 54 | 'opt_title_help': (True, False), 55 | 56 | # bs4 57 | 'opt_columns': (True, False), 58 | 'opt_custom_controls': (True, False), 59 | 'opt_checkbox_switch': (True, False), 60 | 'opt_feedback_tooltips': (True, False), 61 | 'opt_disabled_plaintext': (True, False), 62 | 63 | # bs5 64 | 'opt_labels_floating': (True, False), 65 | 'opt_feedback_valid': (True, False), 66 | } 67 | 68 | 69 | def handle_opts(request, composer_options): 70 | values = {} 71 | 72 | for opt, (on, off) in sorted(opts.items(), key=lambda item: item[0]): 73 | val = request.GET.get(f'do_{opt}', '0') 74 | 75 | casted = on if val == '1' else off 76 | if casted is not None: 77 | composer_options[opt] = casted 78 | 79 | values[f'do_{opt}'] = val 80 | 81 | return values 82 | 83 | 84 | def themed(request, theme): 85 | return index(request, theme) 86 | 87 | 88 | def index(request, theme='none'): 89 | 90 | article = Article.objects.get(pk=1) 91 | 92 | title, composer = THEMES.get(theme) 93 | 94 | composer_options = dict( 95 | opt_size='sm', 96 | 97 | attrs={ 98 | 'contents': {'rows': 2}, 99 | FORM: {'novalidate': ''}, 100 | }, 101 | groups={ 102 | 'basic': 'Basic attributes', 103 | 'other': 'Other fields', 104 | }, 105 | ) 106 | 107 | option_values = handle_opts(request, composer_options) 108 | 109 | SubForm1 = type('SubForm', (SubFormBase,), dict( 110 | Composer=type('Composer', composer, { 111 | 'opt_render_labels': False, 112 | 'opt_placeholder_label': True, 113 | 'layout': { 114 | FORM: {'_': ALL_FIELDS} 115 | } 116 | }), 117 | )) 118 | 119 | Form = type('ArticleForm', (ModelForm,), dict( 120 | Composer=type('Composer', composer, composer_options), 121 | Meta=ArticleFormMeta, 122 | subforms={'formsub1': SubForm1}, 123 | readonly_fields={'email'}, 124 | disabled_fields={'dummy'}, 125 | hidden_fields={'to_hide'}, 126 | )) 127 | 128 | form1: ModelForm = Form( 129 | request=request, 130 | src='POST', 131 | instance=article, 132 | ) 133 | 134 | class FilterForm(FilteringModelForm): 135 | 136 | Composer = type( 137 | 'Composer', composer, 138 | {**composer_options, 'opt_form_inline': True, 'opt_render_labels': True} 139 | ) 140 | 141 | class Meta: 142 | model = Article 143 | fields = ['title', 'approved', 'status'] 144 | 145 | form_filtering1 = FilterForm(request=request, src='GET', id='flt') 146 | 147 | qs = Article.objects.all() 148 | listing, _ = form_filtering1.filtering_apply(qs) 149 | 150 | if form1.is_valid(): 151 | form1.add_error(None, 'This is a non-field error 1.') 152 | form1.add_error(None, 'And this one is a non-field error 2.') 153 | 154 | context = { 155 | 'theme': theme, 156 | 'title': title, 157 | 'nav_items': {alias: descr[0] for alias, descr in THEMES.items()}, 158 | 'url': request.build_absolute_uri(), 159 | 'opts': option_values, 160 | 'form1': form1, 161 | 'form_filtering1': form_filtering1, 162 | 'listing': listing, 163 | } 164 | 165 | return render_themed(request, 'index', context) 166 | -------------------------------------------------------------------------------- /demo/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | web: 5 | restart: always 6 | build: . 7 | command: sh -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000" 8 | volumes: 9 | - .:/code 10 | ports: 11 | - "8000:8000" -------------------------------------------------------------------------------- /demo/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | 6 | PATH_DEMO = os.path.dirname(__file__) 7 | PATH_SITETREE = os.path.dirname(PATH_DEMO) 8 | 9 | sys.path = [PATH_DEMO, PATH_SITETREE] + sys.path 10 | 11 | if __name__ == "__main__": 12 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.settings") 13 | try: 14 | from django.core.management import execute_from_command_line 15 | except ImportError: 16 | # The above import may fail for some other reason. Ensure that the 17 | # issue is really that Django is missing to avoid masking other 18 | # exceptions on Python 2. 19 | try: 20 | import django 21 | except ImportError: 22 | raise ImportError( 23 | "Couldn't import Django. Are you sure it's installed and " 24 | "available on your PYTHONPATH environment variable? Did you " 25 | "forget to activate a virtual environment?" 26 | ) 27 | raise 28 | execute_from_command_line(sys.argv) 29 | -------------------------------------------------------------------------------- /demo/requirements.txt: -------------------------------------------------------------------------------- 1 | Django 2 | django-siteforms 3 | -------------------------------------------------------------------------------- /demo/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-siteforms/d9df98da64447ab01841495e8c495363cefdc399/demo/settings/__init__.py -------------------------------------------------------------------------------- /demo/settings/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | USE_DEBUG_TOOLBAR = False 5 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 6 | SECRET_KEY = 'not-a-secret' 7 | DEBUG = True 8 | ALLOWED_HOSTS = [] 9 | INTERNAL_IPS = ['127.0.0.1'] 10 | 11 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 12 | 13 | INSTALLED_APPS = [ 14 | 'django.contrib.admin', 15 | 'django.contrib.auth', 16 | 'django.contrib.contenttypes', 17 | 'django.contrib.sessions', 18 | 'django.contrib.messages', 19 | 'django.contrib.staticfiles', 20 | 21 | 'siteforms', 22 | 23 | 'demo', 24 | ] 25 | 26 | MIDDLEWARE = [ 27 | 'django.middleware.security.SecurityMiddleware', 28 | 'django.contrib.sessions.middleware.SessionMiddleware', 29 | 'django.middleware.common.CommonMiddleware', 30 | 'django.middleware.csrf.CsrfViewMiddleware', 31 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 32 | 'django.contrib.messages.middleware.MessageMiddleware', 33 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 34 | 35 | 'demo.middleware.language_activator', 36 | ] 37 | 38 | ROOT_URLCONF = 'settings.urls' 39 | 40 | TEMPLATES = [ 41 | { 42 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 43 | 'DIRS': [], 44 | 'APP_DIRS': True, 45 | 'OPTIONS': { 46 | 'context_processors': [ 47 | 'django.template.context_processors.debug', 48 | 'django.template.context_processors.request', 49 | 'django.contrib.auth.context_processors.auth', 50 | 'django.contrib.messages.context_processors.messages', 51 | ], 52 | }, 53 | }, 54 | ] 55 | 56 | WSGI_APPLICATION = 'settings.wsgi.application' 57 | 58 | DATABASES = { 59 | 'default': { 60 | 'ENGINE': 'django.db.backends.sqlite3', 61 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 62 | } 63 | } 64 | 65 | LANGUAGE_CODE = 'en-us' 66 | TIME_ZONE = 'UTC' 67 | USE_I18N = True 68 | USE_L10N = True 69 | USE_TZ = True 70 | STATIC_URL = '/static/' 71 | 72 | 73 | CACHES = { 74 | 'default': { 75 | 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', 76 | } 77 | } 78 | 79 | 80 | if USE_DEBUG_TOOLBAR: 81 | INSTALLED_APPS.append('debug_toolbar') 82 | MIDDLEWARE.append('debug_toolbar.middleware.DebugToolbarMiddleware') 83 | -------------------------------------------------------------------------------- /demo/settings/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.urls import path, include 3 | from django.contrib import admin 4 | 5 | 6 | urlpatterns = [ 7 | path('admin/', admin.site.urls), 8 | path('', include(('demo.urls', 'demo'), namespace='demo')), 9 | ] 10 | 11 | 12 | if settings.DEBUG and settings.USE_DEBUG_TOOLBAR: 13 | import debug_toolbar 14 | urlpatterns = [ 15 | path('__debug__/', include(debug_toolbar.urls)), 16 | ] + urlpatterns 17 | -------------------------------------------------------------------------------- /demo/settings/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for settings 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/1.11/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", "settings.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = django-siteforms 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | 22 | -------------------------------------------------------------------------------- /docs/build/empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-siteforms/d9df98da64447ab01841495e8c495363cefdc399/docs/build/empty -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # This can be used to describe dependencies. May be useful for Read The Docs. 2 | 3 | -------------------------------------------------------------------------------- /docs/source/_static/empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-siteforms/d9df98da64447ab01841495e8c495363cefdc399/docs/source/_static/empty -------------------------------------------------------------------------------- /docs/source/_templates/empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-siteforms/d9df98da64447ab01841495e8c495363cefdc399/docs/source/_templates/empty -------------------------------------------------------------------------------- /docs/source/advanced.rst: -------------------------------------------------------------------------------- 1 | Advanced 2 | ======== 3 | 4 | ``Composers`` allows declarative configuration with the help of class attributes which 5 | should be enough for many simple cases. 6 | 7 | Just inherit from your composer from base composer class and set attributes and options you want to override. 8 | 9 | .. code-block:: python 10 | 11 | from siteforms.composers.bootstrap5 import Bootstrap5 12 | 13 | 14 | class Composer(Bootstrap5): 15 | 16 | opt_render_labels = False 17 | opt_placeholder_label = True 18 | 19 | attrs_help = {'class': 'some', 'data-one': 'other'} 20 | 21 | 22 | Attributes 23 | ---------- 24 | 25 | * ``attrs`` - Attributes to apply to basic elements (form, fields, widget types, groups). 26 | 27 | * ``attrs_labels`` - Attributes to apply to labels. 28 | 29 | * ``attrs_help`` - Attributes to apply to hints. 30 | 31 | * ``attrs_feedback`` - Attributes to apply to feedback (validation notes). 32 | 33 | * ``wrappers`` - Wrappers for fields, groups, rows, submit button. 34 | 35 | * ``groups`` - Map alias to group titles. Groups can be addressed in ``layout``. 36 | 37 | * ``layout`` - Layout instructions for fields and form. 38 | 39 | 40 | Options 41 | ------- 42 | 43 | * ``opt_form_inline`` - Make form inline. 44 | 45 | * ``opt_render_form_tag`` - Render form tag. On by default. 46 | 47 | * ``opt_label_colon`` - Whether to render colons after label's texts. 48 | 49 | * ``opt_render_labels`` - Render label elements. 50 | 51 | * ``opt_render_help`` - Render hints (help texts). 52 | 53 | * ``opt_placeholder_label`` - Put title (verbose name) into field's placeholders. 54 | 55 | * ``opt_placeholder_help`` - Put hint (help text) into field's placeholders. 56 | 57 | * ``opt_title_label`` - Put title (verbose name) into field's title. 58 | 59 | * ``opt_title_help`` - Put hint (help text) into field's title. 60 | 61 | * ``opt_tag_help`` - Tag to be used for hints. 62 | 63 | * ``opt_tag_feedback`` - Tag to be used for feedback. 64 | 65 | * ``opt_submit`` - Submit button text. If not set, submit button won't be added into this form. 66 | 67 | * ``opt_submit_name`` - Submit button name. 68 | 69 | 70 | Macroses 71 | -------- 72 | 73 | Macroses are a quick way to address various objects. 74 | 75 | They can be used as keys in ``Attributes`` (see above). 76 | 77 | * ``ALL_FIELDS`` - Denotes every field. 78 | 79 | * ``ALL_GROUPS`` - Denotes every group. 80 | 81 | * ``ALL_ROWS`` - Denotes every row. 82 | 83 | * ``FORM`` - Denotes a form. 84 | 85 | * ``SUBMIT`` - Submit button. 86 | 87 | 88 | ALL_FIELDS in Layout 89 | -------------------- 90 | 91 | ``ALL_FIELDS`` macros can be used in many places in layout. 92 | It expands into rows for each field which has not been addressed so far. 93 | 94 | As layout value 95 | ~~~~~~~~~~~~~~~ 96 | 97 | This one is the default. 98 | 99 | .. code-block:: python 100 | 101 | layout = { 102 | FORM: ALL_FIELDS 103 | } 104 | 105 | 106 | As group value 107 | ~~~~~~~~~~~~~~ 108 | 109 | .. code-block:: python 110 | 111 | layout = { 112 | FORM: { 113 | 'basic': [ # group 114 | ['title', 'date_created'], # row1 115 | 'contents', # row2 116 | ], 117 | 'other': ALL_FIELDS, # rest rows 118 | } 119 | } 120 | 121 | 122 | 123 | As group row 124 | ~~~~~~~~~~~~ 125 | 126 | .. code-block:: python 127 | 128 | layout = { 129 | FORM: { 130 | 'basic': [ # group 131 | ['title', 'date_created'], # row1 132 | ALL_FIELDS, # rest rows 133 | ], 134 | } 135 | } 136 | 137 | 138 | Subforms 139 | -------- 140 | 141 | Sometimes you may want to represent an entire other form as a field of you main form. 142 | 143 | This can be considered as an alternative to complex widgets. 144 | 145 | .. code-block:: python 146 | 147 | from siteforms.composers.bootstrap5 import Bootstrap5 148 | from siteforms.toolbox import ModelForm, Form 149 | 150 | class SubForm(Form): 151 | """This form we'll include in our main form.""" 152 | 153 | class Composer(Bootstrap5): 154 | 155 | opt_render_labels = False 156 | opt_placeholder_label = True 157 | 158 | field1 = fields.CharField(label='field1') 159 | field2 = fields.ChoiceField(label='field1') 160 | 161 | def get_subform_value(self): 162 | """You may override this method to apply value casting. 163 | Be default it returns subform's cleaned data dictionary 164 | (convenient for JSONField in main form). 165 | 166 | The result of this method would became the value of main form field. 167 | 168 | """ 169 | value = super().get_subform_value() 170 | return f"{value['field1']} ----> {value['field2']}" 171 | 172 | class MyForm(ModelForm): 173 | """That would be our main form. 174 | 175 | Let's suppose it has `myfield` field, which value 176 | we want to represent in a subform. 177 | 178 | """ 179 | subforms = {'myfield': SubForm} # Map field name to subform class. 180 | 181 | class Composer(Bootstrap5): 182 | 183 | opt_columns = True 184 | 185 | class Meta: 186 | model = MyModel 187 | fields = '__all__' 188 | 189 | 190 | After MyForm instance is validated (``.is_valid()``), subform fields values 191 | are gathered (see ``.get_subform_value()``) and placed into main form ``cleaned_data``. 192 | 193 | 194 | Multiple forms 195 | -------------- 196 | You may put more than one form from the same view. 197 | 198 | For that to wok properly please use ``prefix`` for your forms. 199 | 200 | .. code-block:: python 201 | 202 | form1 = MyForm1() 203 | form2 = MyForm2(prefix='form2') 204 | form3 = MyForm3(prefix='form3') 205 | 206 | Prefix attribute may also be declared in form class. 207 | -------------------------------------------------------------------------------- /docs/source/bs4.rst: -------------------------------------------------------------------------------- 1 | Bootstrap 4 2 | =========== 3 | 4 | To use Bootstrap 4 styling add ``Bootstrap4`` inherited composer in your form class. 5 | 6 | .. code-block:: python 7 | 8 | from siteforms.composers.bootstrap4 import Bootstrap4 9 | 10 | 11 | class Composer(Bootstrap4): 12 | """This will instruct siteforms to compose this 13 | form using Bootstrap 4 styling. 14 | 15 | """ 16 | 17 | 18 | Options 19 | ------- 20 | 21 | * ``opt_form_inline`` - Make form inline. 22 | 23 | * ``opt_columns`` - Enabled two-columns mode. 24 | 25 | Expects a columns tuple: (label_columns_count, control_columns_count). 26 | 27 | If `True` default tuple ('col-2', 'col-10') is used. 28 | 29 | * ``opt_custom_controls`` - Use custom controls from Bootstrap 4. 30 | 31 | * ``opt_checkbox_switch`` - Use switches for checkboxes (if custom controls). 32 | 33 | * ``opt_size`` - Apply size to form elements. E.g. ``Bootstrap4.SIZE_SMALL`` 34 | 35 | * ``opt_disabled_plaintext`` - Render disabled fields as plain text. 36 | 37 | * ``opt_feedback_tooltips`` - Whether to render feedback in tooltips. 38 | 39 | * ``opt_feedback_valid`` - Whether to render feedback for valid fields. 40 | -------------------------------------------------------------------------------- /docs/source/bs5.rst: -------------------------------------------------------------------------------- 1 | Bootstrap 5 2 | =========== 3 | 4 | To use Bootstrap 5 styling add ``Bootstrap5`` inherited composer in your form class. 5 | 6 | .. code-block:: python 7 | 8 | from siteforms.composers.bootstrap5 import Bootstrap5 9 | 10 | 11 | class Composer(Bootstrap5): 12 | """This will instruct siteforms to compose this 13 | form using Bootstrap 5 styling. 14 | 15 | """ 16 | 17 | 18 | Options 19 | ------- 20 | 21 | * ``opt_form_inline`` - Make form inline. 22 | 23 | * ``opt_columns`` - Enabled two-columns mode. 24 | 25 | Expects a columns tuple: (label_columns_count, control_columns_count). 26 | 27 | If `True` default tuple ('col-2', 'col-10') is used. 28 | 29 | 30 | * ``opt_checkbox_switch`` - Use switches for checkboxes (if custom controls). 31 | 32 | * ``opt_size`` - Apply size to form elements. E.g. ``Bootstrap5.SIZE_SMALL`` 33 | 34 | * ``opt_disabled_plaintext`` - Render disabled fields as plain text. 35 | 36 | * ``opt_feedback_tooltips`` - Whether to render feedback in tooltips. 37 | 38 | * ``opt_feedback_valid`` - Whether to render feedback for valid fields. 39 | 40 | * ``opt_labels_floating`` - Whether to render labels floating inside inputs. 41 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-siteforms documentation build configuration file. 4 | # 5 | # This file is execfile()d with the current directory set to its 6 | # containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | # If extensions (or modules to document with autodoc) are in another directory, 15 | # add these directories to sys.path here. If the directory is relative to the 16 | # documentation root, use os.path.abspath to make it absolute, like shown here. 17 | # 18 | import sys, os 19 | sys.path.insert(0, os.path.abspath('../../')) 20 | 21 | from siteforms import VERSION_STR 22 | 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | # 28 | # needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = ['sphinx.ext.autodoc'] 34 | 35 | # Instruct autoclass directive to document both class and __init__ docstrings. 36 | autoclass_content = 'both' 37 | 38 | # Add any paths that contain templates here, relative to this directory. 39 | templates_path = ['_templates'] 40 | 41 | # The suffix(es) of source filenames. 42 | # You can specify multiple suffix as a list of string: 43 | # 44 | # source_suffix = ['.rst', '.md'] 45 | source_suffix = '.rst' 46 | 47 | # The master toctree document. 48 | master_doc = 'index' 49 | 50 | # General information about the project. 51 | project = 'django-siteforms' 52 | copyright = '2020-2023, Igor `idle sign` Starikov' 53 | author = 'Igor `idle sign` Starikov' 54 | 55 | # The version info for the project you're documenting, acts as replacement for 56 | # |version| and |release|, also used in various other places throughout the 57 | # built documents. 58 | # 59 | # The short X.Y version. 60 | version = VERSION_STR 61 | # The full version, including alpha/beta/rc tags. 62 | release = VERSION_STR 63 | 64 | # The language for content autogenerated by Sphinx. Refer to documentation 65 | # for a list of supported languages. 66 | # 67 | # This is also used if you do content translation via gettext catalogs. 68 | # Usually you set "language" from the command line for these cases. 69 | language = None 70 | 71 | # List of patterns, relative to source directory, that match files and 72 | # directories to ignore when looking for source files. 73 | # This patterns also effect to html_static_path and html_extra_path 74 | exclude_patterns = ['rst_guide.rst'] 75 | 76 | # The name of the Pygments (syntax highlighting) style to use. 77 | pygments_style = 'sphinx' 78 | 79 | # If true, `todo` and `todoList` produce output, else they produce nothing. 80 | todo_include_todos = False 81 | 82 | 83 | # -- Options for HTML output ---------------------------------------------- 84 | 85 | # The theme to use for HTML and HTML Help pages. See the documentation for 86 | # a list of builtin themes. 87 | # 88 | html_theme = 'default' 89 | 90 | # Theme options are theme-specific and customize the look and feel of a theme 91 | # further. For a list of options available for each theme, see the 92 | # documentation. 93 | # 94 | # html_theme_options = {} 95 | 96 | # Add any paths that contain custom static files (such as style sheets) here, 97 | # relative to this directory. They are copied after the builtin static files, 98 | # so a file named "default.css" will overwrite the builtin "default.css". 99 | html_static_path = ['_static'] 100 | 101 | 102 | # -- Options for HTMLHelp output ------------------------------------------ 103 | 104 | # Output file base name for HTML help builder. 105 | htmlhelp_basename = 'django-siteformsdoc' 106 | 107 | 108 | # -- Options for LaTeX output --------------------------------------------- 109 | 110 | latex_elements = { 111 | # The paper size ('letterpaper' or 'a4paper'). 112 | # 113 | # 'papersize': 'letterpaper', 114 | 115 | # The font size ('10pt', '11pt' or '12pt'). 116 | # 117 | # 'pointsize': '10pt', 118 | 119 | # Additional stuff for the LaTeX preamble. 120 | # 121 | # 'preamble': '', 122 | 123 | # Latex figure (float) alignment 124 | # 125 | # 'figure_align': 'htbp', 126 | } 127 | 128 | # Grouping the document tree into LaTeX files. List of tuples 129 | # (source start file, target name, title, 130 | # author, documentclass [howto, manual, or own class]). 131 | latex_documents = [ 132 | (master_doc, 'django-siteforms.tex', 'django-siteforms Documentation', 133 | 'Igor `idle sign` Starikov', 'manual'), 134 | ] 135 | 136 | 137 | # -- Options for manual page output --------------------------------------- 138 | 139 | # One entry per manual page. List of tuples 140 | # (source start file, name, description, authors, manual section). 141 | man_pages = [ 142 | (master_doc, 'django-siteforms', 'django-siteforms Documentation', 143 | [author], 1) 144 | ] 145 | 146 | 147 | # -- Options for Texinfo output ------------------------------------------- 148 | 149 | # Grouping the document tree into Texinfo files. List of tuples 150 | # (source start file, target name, title, author, 151 | # dir menu entry, description, category) 152 | texinfo_documents = [ 153 | (master_doc, 'django-siteforms', 'django-siteforms Documentation', 154 | author, 'django-siteforms', 'Django reusable app to simplify form construction', 155 | 'Miscellaneous'), 156 | ] 157 | 158 | 159 | # This allows mocking dependency imports for autodoc. 160 | autodoc_mock_imports = [] 161 | 162 | -------------------------------------------------------------------------------- /docs/source/filtering.rst: -------------------------------------------------------------------------------- 1 | Filtering Forms 2 | =============== 3 | 4 | ``siteforms`` allows you to use form a filter applied to a query set. 5 | 6 | Use ``FilteringForm`` or ``FilteringModelForm``. 7 | 8 | .. code-block:: python 9 | 10 | from siteforms.composers.bootstrap5 import Bootstrap5 11 | from siteforms.toolbox import FilteringModelForm 12 | 13 | # Let's suppose we want to filter articles we have in our database. 14 | # Among others fields Article model has `title` and `status` 15 | # we want our articles to be filtered by. 16 | 17 | # We inherit our form from FilteringModelForm. 18 | class MyFilterForm(FilteringModelForm): 19 | 20 | ... 21 | 22 | # If you have custom form fields, you may want 23 | # to map those fields to model field names: 24 | lookup_names = { 25 | 'date_from': 'date', 26 | 'date_till': 'date', 27 | } 28 | 29 | # Or you can have special lookups for certain fields: 30 | filtering = { 31 | 'field1': 'icontains', 32 | 'field2_json': 'some__gt', 33 | } 34 | 35 | class Composer(Bootstrap5): # apply styling as it fits our needs 36 | opt_form_inline = True 37 | 38 | class Meta: 39 | model = Article 40 | fields = ['title', 'status'] 41 | 42 | # In our view function: 43 | filter_form = MyFilterForm(request=request, src='GET', id='flt') 44 | 45 | query_set = Article.objects.all() 46 | 47 | # Now we apply filter to our query set 48 | listing, filter_applied = filter_form.filtering_apply(query_set) 49 | 50 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | django-siteforms documentation 2 | ============================== 3 | https://github.com/idlesign/django-siteforms 4 | 5 | 6 | 7 | Description 8 | ----------- 9 | 10 | *Django reusable app to simplify form construction* 11 | 12 | For those who consider maintaining templates-based forms solutions for Django a burden. 13 | 14 | Features: 15 | 16 | * Full form rendering support, including prolog and submit button 17 | * Subforms support (represent entire other form as a form field): JSON, Foreign Key, Many-to-Many 18 | * Field groups 19 | * Declarative attributes for elements 20 | * Simplified declarative forms layout, allowing fields ordering 21 | * Simple ways to make fields hidden, disabled, readonly 22 | * Support for fields from model's properties 23 | * Aria-friendly (Accessible Rich Internet Applications) 24 | * Complex widgets (e.g. using values from multiple fields) support 25 | * Filter-forms (use form for queryset filtering) 26 | 27 | Supported styling: 28 | 29 | * No CSS 30 | * Bootstrap 4 31 | * Bootstrap 5 32 | 33 | 34 | Requirements 35 | ------------ 36 | 37 | 1. Python 3.7+ 38 | 2. Django 2.2+ 39 | 40 | 41 | 42 | Table of Contents 43 | ----------------- 44 | 45 | .. toctree:: 46 | :maxdepth: 2 47 | 48 | quickstart 49 | advanced 50 | filtering 51 | bs4 52 | bs5 53 | -------------------------------------------------------------------------------- /docs/source/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quickstart 2 | ========== 3 | 4 | Let's show how to build a simple form. 5 | 6 | .. code-block:: python 7 | 8 | from django.shortcuts import render 9 | from siteforms.composers.bootstrap4 import Bootstrap4 10 | from siteforms.toolbox import ModelForm, ReadOnlyWidget, fields 11 | 12 | 13 | class MySmileWidget(ReadOnlyWidget): 14 | """This one we'd use to render our""" 15 | 16 | def format_value_hook(self, value): 17 | return super().format_value_hook(value) + ' %) ' 18 | 19 | 20 | class MyForm(ModelForm): 21 | """This form will show us how siteforms works.""" 22 | 23 | disabled_fields = {'somefield'} 24 | """Declarative way of disabling fields. Use __all__ to disable all fields (affects subforms). 25 | This can also be passed into __init__() as the keyword-argument with the same name. 26 | 27 | """ 28 | 29 | hidden_fields = {'otherfield'} 30 | """Declarative way of hiding fields. 31 | This can also be passed into __init__() as the keyword-argument with the same name. 32 | 33 | """ 34 | 35 | readonly_fields = {'anotherfield'} 36 | """Declarative way of making fields readonly (to not to render input fields, but show a value). 37 | 38 | Use __all__ to make all fields readonly (affects subforms). 39 | This mode can be useful to make cheap details pages, using the same layout as form. 40 | 41 | This can also be passed into __init__() as the keyword-argument with the same name. 42 | 43 | """ 44 | 45 | myprop = fields.CharField(label='from property', required=False) 46 | """This field will be populated from model property.""" 47 | 48 | class Composer(Bootstrap4): 49 | """This will instruct siteforms to compose this 50 | form using Bootstrap 4 styling. 51 | 52 | """ 53 | class Meta: 54 | model = MyModel # Suppose you have a model class already. 55 | 56 | # and your model defines 'myprop' property you want 57 | # to put into 'myprop' field 58 | property_fields = ['myprop'] 59 | 60 | fields = '__all__' 61 | 62 | widgets_readonly = { 63 | # and here we can define our own widgets for fields 64 | # that are rendered as readonly 65 | 'myfield': MySmileWidget, 66 | } 67 | 68 | def my_view(request): 69 | # Initialize form using data from POST. 70 | my_form = MyForm(request=request, src='POST') 71 | is_valid = form.is_valid() 72 | return render(request, 'mytemplate.html', {'form': my_form}) 73 | 74 | 75 | Composer options 76 | ~~~~~~~~~~~~~~~~ 77 | 78 | Now let's see how to tune our form. 79 | 80 | .. code-block:: python 81 | 82 | from siteforms.composers.bootstrap4 import Bootstrap4, FORM, ALL_FIELDS 83 | 84 | class Composer(Bootstrap4): 85 | 86 | opt_size='sm' # Bootstrap 4 has sizes, so let's make our form small. 87 | 88 | # Element (fields, groups, form, etc.) attributes are ruled by `attrs`. 89 | # Let's add rows=2 to our `contents` model field. 90 | # We also add (notice + sign) 'mycss' to an existing 'class' attribute value. 91 | attrs={'contents': {'rows': 2, 'class': '+mycss'}} 92 | 93 | # To group fields into named groups describe them in `groups`. 94 | groups={ 95 | 'basic': 'Basic attributes', 96 | 'other': 'Other fields', 97 | } 98 | 99 | # We apply custom layout to our form. 100 | layout = { 101 | FORM: { 102 | 'basic': [ # First we place `basic` group. 103 | # The following three fields are in the same row - 104 | # two fields in the right column are stacked. 105 | ['title', ['date_created', 106 | 'date_updated']], 107 | 'contents', # This one field goes into a separate row. 108 | ], 109 | # We place all the rest fields into `other` group. 110 | 'other': ALL_FIELDS, 111 | } 112 | } 113 | 114 | -------------------------------------------------------------------------------- /docs/source/rst_guide.rst: -------------------------------------------------------------------------------- 1 | RST Quick guide 2 | =============== 3 | 4 | Online reStructuredText editor - http://rst.ninjs.org/ 5 | 6 | 7 | Main heading 8 | ============ 9 | 10 | 11 | Secondary heading 12 | ----------------- 13 | 14 | Minor heading 15 | ~~~~~~~~~~~~~ 16 | 17 | 18 | Typography 19 | ---------- 20 | 21 | **Bold** 22 | 23 | `Italic` 24 | 25 | ``Accent`` 26 | 27 | 28 | 29 | Blocks 30 | ------ 31 | 32 | Double colon to consider the following paragraphs preformatted:: 33 | 34 | This text is preformated. Can be used for code samples. 35 | 36 | 37 | .. code-block:: python 38 | 39 | # code-block accepts language name to highlight code 40 | # E.g.: python, html 41 | import this 42 | 43 | 44 | .. note:: 45 | 46 | This text will be rendered as a note block (usually green). 47 | 48 | 49 | .. warning:: 50 | 51 | This text will be rendered as a warning block (usually red). 52 | 53 | 54 | 55 | Lists 56 | ----- 57 | 58 | 1. Ordered item 1. 59 | 60 | Indent paragraph to make in belong to the above list item. 61 | 62 | 2. Ordered item 2. 63 | 64 | 65 | + Unordered item 1. 66 | + Unordered item . 67 | 68 | 69 | 70 | Links 71 | ----- 72 | 73 | :ref:`Documentation inner link label ` 74 | 75 | .. _some-marker: 76 | 77 | 78 | `Outer link label `_ 79 | 80 | Inline URLs are converted to links automatically: http://github.com/idlesign/makeapp/ 81 | 82 | 83 | Images 84 | ------ 85 | 86 | .. image:: path_to_image/image.png 87 | 88 | 89 | Automation 90 | ---------- 91 | 92 | http://sphinx-doc.org/ext/autodoc.html 93 | 94 | .. automodule:: my_module 95 | :members: 96 | 97 | .. autoclass:: my_module.MyClass 98 | :members: do_this, do_that 99 | :inherited-members: 100 | :undoc-members: 101 | :private-members: 102 | :special-members: 103 | :show-inheritance: 104 | 105 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --pyargs 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | release = clean --all sdist bdist_wheel upload 3 | test = pytest 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import re 4 | 5 | from setuptools import setup, find_packages 6 | 7 | import sys 8 | 9 | PATH_BASE = os.path.dirname(__file__) 10 | 11 | 12 | def read_file(fpath): 13 | """Reads a file within package directories.""" 14 | with io.open(os.path.join(PATH_BASE, fpath)) as f: 15 | return f.read() 16 | 17 | 18 | def get_version(): 19 | """Returns version number, without module import (which can lead to ImportError 20 | if some dependencies are unavailable before install.""" 21 | contents = read_file(os.path.join('siteforms', '__init__.py')) 22 | version = re.search('VERSION = \(([^)]+)\)', contents) 23 | version = version.group(1).replace(', ', '.').strip() 24 | return version 25 | 26 | 27 | setup( 28 | name='django-siteforms', 29 | version=get_version(), 30 | url='https://github.com/idlesign/django-siteforms', 31 | 32 | description='Django reusable app to simplify form construction', 33 | long_description=read_file('README.rst'), 34 | license='BSD 3-Clause License', 35 | 36 | author='Igor `idle sign` Starikov', 37 | author_email='idlesign@yandex.ru', 38 | 39 | packages=find_packages(exclude=['tests']), 40 | include_package_data=True, 41 | zip_safe=False, 42 | 43 | install_requires=[], 44 | 45 | setup_requires=(['pytest-runner'] if 'test' in sys.argv else []), 46 | 47 | test_suite='tests', 48 | 49 | tests_require=[ 50 | 'pytest', 51 | 'pytest-djangoapp>=0.15.1', 52 | 'pytest-datafixtures', 53 | ], 54 | 55 | classifiers=[ 56 | # As in https://pypi.python.org/pypi?:action=list_classifiers 57 | 'Development Status :: 4 - Beta', 58 | 'Operating System :: OS Independent', 59 | 'Programming Language :: Python', 60 | 'Programming Language :: Python :: 3', 61 | 'Programming Language :: Python :: 3.7', 62 | 'Programming Language :: Python :: 3.8', 63 | 'Programming Language :: Python :: 3.9', 64 | 'Programming Language :: Python :: 3.10', 65 | 'License :: OSI Approved :: BSD License', 66 | ], 67 | ) 68 | 69 | 70 | -------------------------------------------------------------------------------- /siteforms/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | VERSION = (1, 2, 0) 4 | """Application version number tuple.""" 5 | 6 | VERSION_STR = '.'.join(map(str, VERSION)) 7 | """Application version number string.""" 8 | 9 | 10 | default_app_config = 'siteforms.apps.SiteformsConfig' -------------------------------------------------------------------------------- /siteforms/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class SiteformsConfig(AppConfig): 6 | """Application configuration.""" 7 | 8 | name = 'siteforms' 9 | verbose_name = _('Siteforms') 10 | -------------------------------------------------------------------------------- /siteforms/base.py: -------------------------------------------------------------------------------- 1 | import json 2 | from types import MethodType 3 | from typing import Type, Set, Dict, Union, Generator, Callable, Any, Tuple 4 | from django.utils.datastructures import MultiValueDict 5 | from django.db.models import QuerySet 6 | from django.forms import ( 7 | BaseForm, 8 | modelformset_factory, HiddenInput, 9 | ModelMultipleChoiceField, ModelChoiceField, BaseFormSet, BooleanField, Select, Field, 10 | ) 11 | from django.http import HttpRequest, QueryDict 12 | from django.utils.safestring import mark_safe 13 | from django.utils.translation import gettext_lazy as _ 14 | 15 | from .fields import SubformField, EnhancedBoundField, EnhancedField 16 | from .formsets import ModelFormSet, SiteformFormSetMixin 17 | from .utils import bind_subform, UNSET, temporary_fields_patch 18 | from .widgets import ReadOnlyWidget 19 | 20 | if False: # pragma: nocover 21 | from .composers.base import FormComposer, TypeComposer # noqa 22 | 23 | 24 | TypeSubform = Union['SiteformsMixin', SiteformFormSetMixin] 25 | TypeDefFieldsAll = Union[Set[str], str] 26 | TypeDefSubforms = Dict[str, Type['SiteformsMixin']] 27 | 28 | MACRO_ALL = '__all__' 29 | 30 | YES_NO_CHOICES = [ 31 | (True, _('Yes')), (False, _('No')) 32 | ] 33 | 34 | 35 | class SiteformsMixin(BaseForm): 36 | """Mixin to extend native Django form tools.""" 37 | 38 | disabled_fields: TypeDefFieldsAll = None 39 | """Fields to be disabled. Use __all__ to disable all fields (affects subforms). 40 | 41 | .. note:: This can also be passed into __init__() as the keyword-argument 42 | with the same name. 43 | 44 | """ 45 | 46 | hidden_fields: Set[str] = None 47 | """Fields to be hidden. 48 | 49 | .. note:: This can also be passed into __init__() as the keyword-argument 50 | with the same name. 51 | 52 | """ 53 | 54 | readonly_fields: TypeDefFieldsAll = None 55 | """Fields to make read-only. Use __all__ to disable all fields (affects subforms). 56 | Readonly fields are disabled automatically to prevent data corruption. 57 | 58 | .. note:: This can also be passed into __init__() as the keyword-argument 59 | with the same name. 60 | 61 | """ 62 | 63 | subforms: TypeDefSubforms = None 64 | """Allows sub forms registration. Expects field name to subform class mapping.""" 65 | 66 | formset_kwargs: dict = None 67 | """These kwargs are passed into formsets factory (see `formset_factory()`). 68 | 69 | Example:: 70 | { 71 | 'subformfield1': {'extra': 2}, 72 | 'subformfield1': {'validate_max': True, 'min_num': 2}, 73 | } 74 | 75 | .. note:: This can also be passed into __init__() as the keyword-argument 76 | with the same name. 77 | 78 | """ 79 | 80 | is_submitted: bool = False 81 | """Whether this form is submitted and uses th submitted data.""" 82 | 83 | _cls_subform_field = SubformField 84 | 85 | Composer: Type['FormComposer'] = None 86 | 87 | def __init__( 88 | self, 89 | *args, 90 | request: HttpRequest = None, 91 | src: str = None, 92 | id: str = '', # noqa 93 | target_url: str = '', 94 | parent: 'SiteformsMixin' = None, 95 | hidden_fields: Set[str] = UNSET, 96 | formset_kwargs: dict = UNSET, 97 | subforms: TypeDefSubforms = UNSET, 98 | submit_marker: Any = UNSET, 99 | render_form_tag: bool = UNSET, 100 | **kwargs 101 | ): 102 | """ 103 | 104 | :param args: 105 | 106 | :param request: Django request object. 107 | 108 | :param src: Form data source. E.g.: POST, GET. 109 | 110 | :param id: Form ID. If defined the form will be rendered 111 | with this ID. This ID will also be used as auto_id prefix for fields. 112 | 113 | :param target_url: Where form data should be sent. 114 | This will appear in 'action' attribute of a 'form' tag. 115 | 116 | :param parent: Parent form for a subform. 117 | 118 | :param hidden_fields: See the class attribute docstring. 119 | 120 | :param formset_kwargs: See the class attribute docstring. 121 | 122 | :param subforms: See the class attribute docstring. 123 | 124 | :param submit_marker: A value that should be used to detect 125 | whether the form was submitted. 126 | 127 | :param render_form_tag: Can be used to override `Composer.opt_render_form_tag` setting. 128 | Useful in conjunction with `readonly_fields='__all__` to make read-only details pages 129 | using form layout. 130 | 131 | :param kwargs: Form arguments to pass to the base 132 | class of this form and also to subforms. 133 | 134 | """ 135 | self.src = src 136 | self.request = request 137 | 138 | disabled = kwargs.get('disabled_fields', self.disabled_fields) 139 | self.disabled_fields = disabled if isinstance(disabled, str) else set(disabled or []) 140 | 141 | readonly = kwargs.get('readonly_fields', self.readonly_fields) 142 | self.readonly_fields = readonly if isinstance(readonly, str) else set(readonly or []) 143 | 144 | self.hidden_fields = set((self.hidden_fields if hidden_fields is UNSET else hidden_fields) or []) 145 | self.formset_kwargs = (self.formset_kwargs if formset_kwargs is UNSET else formset_kwargs) or {} 146 | self.subforms = (self.subforms if subforms is UNSET else subforms) or {} 147 | self.composer_render_form_tag = render_form_tag 148 | 149 | self.id = id 150 | self.target_url = target_url 151 | 152 | if id and 'auto_id' not in kwargs: 153 | kwargs['auto_id'] = f'{id}_%s' 154 | 155 | self._subforms: Dict[str, TypeSubform] = {} # noqa 156 | self._subforms_kwargs = {} 157 | self.parent = parent 158 | 159 | # Allow subform using the same submit value as the base form. 160 | self.submit_marker = ( 161 | (kwargs.get('prefix', self.prefix) or 'siteform') 162 | if submit_marker is UNSET 163 | else submit_marker) 164 | 165 | args = list(args) 166 | self._initialize_pre(args=args, kwargs=kwargs) 167 | 168 | kwargs.pop('disabled_fields', '') 169 | kwargs.pop('readonly_fields', '') 170 | 171 | super().__init__(*args, **kwargs) 172 | 173 | def __str__(self): 174 | return self.render() 175 | 176 | @classmethod 177 | def _meta_hook(cls): 178 | """Allows hooking on meta construction (see BaseMeta).""" 179 | 180 | subforms = cls.subforms or {} 181 | base_fields = cls.base_fields 182 | 183 | for field_name, field in base_fields.items(): 184 | field: Field 185 | # Swap bound field with our custom one 186 | # to add .bound_field attr to every widget. 187 | field.get_bound_field = MethodType(EnhancedField.get_bound_field, field) 188 | 189 | # Use custom field for subforms. 190 | if subforms.get(field_name): 191 | base_fields[field_name] = cls._cls_subform_field( 192 | original_field=field, 193 | validators=field.validators, 194 | ) 195 | 196 | @classmethod 197 | def _combine_dicts(cls, *, args: list, kwargs: dict, src: dict, arg_idx: int, kwargs_key: str) -> MultiValueDict: 198 | 199 | try: 200 | data_args = args[arg_idx] 201 | except IndexError: 202 | data_args = None 203 | 204 | if data_args is None: 205 | data_args = kwargs.pop(kwargs_key, {}) 206 | 207 | combined = MultiValueDict() 208 | combined.update(src) 209 | if data_args: 210 | combined.update(data_args) 211 | 212 | return combined 213 | 214 | def _preprocess_source_data(self, data: Union[dict, QueryDict]) -> Union[dict, QueryDict]: 215 | return data 216 | 217 | def _initialize_pre(self, *, args, kwargs): 218 | # NB: may mutate args and kwargs 219 | 220 | src = self.src 221 | request = self.request 222 | 223 | # Get initial data from instance properties (as per property_fields) 224 | instance = kwargs.get('instance') 225 | if instance: 226 | property_fields = self._get_meta_option('property_fields', []) 227 | if property_fields: 228 | initial = {} 229 | for property_field in property_fields: 230 | initial[property_field] = getattr(instance, property_field) 231 | kwargs['initial'] = {**initial, **kwargs.get('initial', {})} 232 | 233 | # Handle user supplied data. 234 | if src and request: 235 | data = getattr(request, src) 236 | is_submitted = data.get(self.Composer.opt_submit_name, '') == self.submit_marker 237 | 238 | self.is_submitted = is_submitted 239 | 240 | if is_submitted and request.method == src: 241 | 242 | data = self._combine_dicts( 243 | args=args, kwargs=kwargs, 244 | src=data, arg_idx=0, kwargs_key='data' 245 | ) 246 | data = self._preprocess_source_data(data) 247 | self.data = data 248 | 249 | files = self._combine_dicts( 250 | args=args, kwargs=kwargs, 251 | src=request.FILES, arg_idx=1, kwargs_key='files' 252 | ) 253 | self.files = files 254 | 255 | if args: 256 | # Prevent arguments clash. 257 | args[0] = data 258 | if len(args) > 1: 259 | args[1] = files 260 | 261 | else: 262 | kwargs.update({ 263 | 'data': data, 264 | 'files': files, 265 | }) 266 | 267 | if self.subforms: 268 | # Prepare form arguments. 269 | subforms_kwargs = kwargs.copy() 270 | subforms_kwargs.pop('instance', None) 271 | 272 | subforms_kwargs.update({ 273 | 'src': self.src, 274 | 'request': self.request, 275 | 'submit_marker': self.submit_marker, 276 | 'parent': self, 277 | }) 278 | self._subforms_kwargs = subforms_kwargs 279 | 280 | def get_subform(self, *, name: str) -> TypeSubform: 281 | """Returns a subform instance by its name 282 | (or possibly a name of a nested subform field, representing a form). 283 | 284 | :param name: 285 | 286 | """ 287 | prefix = self.prefix 288 | 289 | if prefix: 290 | # Strip down field name prefix to get a form name. 291 | name = name.replace(prefix, '', 1).lstrip('-') 292 | 293 | subform = self._subforms.get(name) 294 | 295 | if not subform: 296 | subform_cls = self.subforms[name] 297 | 298 | # Attach Composer automatically if none in subform. 299 | if getattr(subform_cls, 'Composer', None) is None: 300 | setattr(subform_cls, 'Composer', type('DynamicComposer', self.Composer.__bases__, {})) 301 | 302 | kwargs_form = self._subforms_kwargs.copy() 303 | kwargs_form['render_form_tag'] = False 304 | 305 | # Construct a full (including parent prefixes) name prefix 306 | # to support deeply nested forms. 307 | if prefix: 308 | kwargs_form['prefix'] = f'{prefix}-{name}' 309 | 310 | subform = self._spawn_subform( 311 | name=name, 312 | subform_cls=subform_cls, 313 | kwargs_form=kwargs_form, 314 | ) 315 | 316 | self._subforms[name] = subform 317 | 318 | # Set relevant field form attributes 319 | # to have form access from other entities. 320 | field = self.fields[name] 321 | bind_subform(subform=subform, field=field) 322 | 323 | return subform 324 | 325 | def _spawn_subform( 326 | self, 327 | *, 328 | name: str, 329 | subform_cls: Type['SiteformsMixin'], 330 | kwargs_form: dict, 331 | ) -> TypeSubform: 332 | 333 | original_field = self.base_fields[name].original_field 334 | subform_mode = '' 335 | 336 | if hasattr(original_field, 'queryset'): 337 | # Possibly a field represents FK or M2M. 338 | 339 | if isinstance(original_field, ModelMultipleChoiceField): 340 | # Many-to-many. 341 | 342 | formset_cls = modelformset_factory( 343 | original_field.queryset.model, 344 | form=subform_cls, 345 | formset=ModelFormSet, 346 | **self.formset_kwargs.get(name, {}), 347 | ) 348 | 349 | queryset = None 350 | instance = getattr(self, 'instance', None) 351 | 352 | if instance: 353 | if instance.pk: 354 | queryset = getattr(instance, name).all() 355 | else: 356 | queryset = original_field.queryset.none() 357 | 358 | return formset_cls( 359 | data=self.data or None, 360 | files=self.files or None, 361 | prefix=name, 362 | form_kwargs=kwargs_form, 363 | queryset=queryset, 364 | ) 365 | 366 | elif isinstance(original_field, ModelChoiceField): 367 | subform_mode = 'fk' 368 | 369 | # Subform for JSON and FK. 370 | subform = self._spawn_subform_inline( 371 | name=name, 372 | subform_cls=subform_cls, 373 | kwargs_form=kwargs_form, 374 | mode=subform_mode, 375 | ) 376 | 377 | return subform 378 | 379 | def _spawn_subform_inline( 380 | self, 381 | *, 382 | name: str, 383 | subform_cls: Type['SiteformsMixin'], 384 | kwargs_form: dict, 385 | mode: str = '', 386 | ) -> 'SiteformsMixin': 387 | 388 | mode = mode or 'json' 389 | 390 | initial_value = self.initial.get(name, UNSET) 391 | instance_value = getattr(getattr(self, 'instance', None), name, UNSET) 392 | 393 | if initial_value is not UNSET: 394 | 395 | if mode == 'json': 396 | # In case of JSON we get initial from the base form initial by key. 397 | kwargs_form['initial'] = json.loads(initial_value) 398 | 399 | if instance_value is not UNSET: 400 | 401 | if mode == 'fk': 402 | kwargs_form.update({ 403 | 'instance': instance_value, 404 | 'data': self.data or None, 405 | 'files': self.files or None, 406 | }) 407 | 408 | return subform_cls(**{'prefix': name, **kwargs_form}) 409 | 410 | def _iter_subforms(self) -> Generator[TypeSubform, None, None]: 411 | for name in self.subforms: 412 | yield self.get_subform(name=name) 413 | 414 | def is_valid(self): 415 | 416 | valid = True 417 | 418 | for subform in self._iter_subforms(): 419 | subform_valid = subform.is_valid() 420 | valid &= subform_valid 421 | 422 | valid &= super().is_valid() 423 | 424 | return valid 425 | 426 | def get_composer(self) -> 'TypeComposer': 427 | """Spawns a form composer object. 428 | Hook method. May be reimplemented by a subclass 429 | for a further composer modification. 430 | """ 431 | return self.Composer(self) 432 | 433 | def render(self, template_name=None, context=None, renderer=None): 434 | """Renders this form as a string.""" 435 | 436 | if template_name: 437 | # Use Django 4.0+ default implementation to avoid recursion. 438 | return mark_safe((renderer or self.renderer).render( 439 | template_name or self.template_name, 440 | context or self.get_context(), 441 | )) 442 | 443 | def render_(): 444 | return mark_safe(self.get_composer().render( 445 | render_form_tag=self.composer_render_form_tag, 446 | )) 447 | return self._apply_attrs(callback=render_) 448 | 449 | def is_multipart(self): 450 | 451 | is_multipart = super().is_multipart() 452 | 453 | if is_multipart: 454 | return True 455 | 456 | for subform in self._iter_subforms(): 457 | 458 | if isinstance(subform, BaseFormSet): 459 | # special case this since Django's implementation 460 | # won't consider empty form at all. 461 | if subform.forms: 462 | is_multipart = subform.forms[0].is_multipart() 463 | 464 | is_multipart = is_multipart or subform.empty_form.is_multipart() 465 | 466 | else: 467 | is_multipart = subform.is_multipart() 468 | 469 | if is_multipart: 470 | break 471 | 472 | return is_multipart 473 | 474 | def _get_meta_option(self, name: str, default: Any) -> Any: 475 | return getattr(getattr(self, 'Meta', None), name, default) 476 | 477 | def _get_widget_readonly_cls(self, field_name: str) -> Type[ReadOnlyWidget]: 478 | return self._get_meta_option('widgets_readonly', {}).get(field_name, ReadOnlyWidget) 479 | 480 | def _clean_fields(self): 481 | # this ensures valid attributes on validation including that in formsets 482 | self._apply_attrs(callback=super()._clean_fields) 483 | 484 | def _apply_attrs(self, callback: Callable): 485 | 486 | disabled = self.disabled_fields 487 | hidden = self.hidden_fields 488 | readonly = self.readonly_fields 489 | get_readonly_cls = self._get_widget_readonly_cls 490 | 491 | all_macro = MACRO_ALL 492 | 493 | with temporary_fields_patch(self): 494 | 495 | for field in self: 496 | field: EnhancedBoundField 497 | field_name = field.name 498 | base_field = field.field 499 | instance_field = self.fields[field_name] 500 | 501 | made_readonly = False 502 | if readonly == all_macro or field_name in readonly: 503 | original_widget = base_field.widget 504 | 505 | make_read_only = ( 506 | # We do not set this widget if already set, since 507 | # it might be a customized subclass. 508 | not isinstance(original_widget, ReadOnlyWidget) 509 | # And we do not set the widget for subforms, since 510 | # they handle readonly by themselves. 511 | and not isinstance(base_field, SubformField) 512 | ) 513 | if make_read_only: 514 | widget = get_readonly_cls(field_name)( 515 | bound_field=field, 516 | original_widget=original_widget, 517 | ) 518 | base_field.widget = instance_field.disabled = widget 519 | made_readonly = True 520 | 521 | # Readonly fields are disabled automatically. 522 | if made_readonly or (disabled == all_macro or field_name in disabled): 523 | base_field.disabled = instance_field.disabled = True 524 | 525 | if field_name in hidden: 526 | base_field.widget = instance_field.widget = HiddenInput() 527 | 528 | result = callback() 529 | 530 | return result 531 | 532 | 533 | class FilteringSiteformsMixin(SiteformsMixin): 534 | """Filtering forms base mixin.""" 535 | 536 | filtering_rules: Dict[str, str] = {} 537 | """Allows setting rules (instructions) to use this form for queryset filtering. 538 | 539 | Example:: 540 | filtering = { 541 | # for these fields we use special lookups 542 | 'field1': 'icontains', 543 | 'field2_json_sub': 'sub__gt', 544 | } 545 | 546 | """ 547 | 548 | lookup_names: Dict[str, str] = {} 549 | """Allows setting the mapping of a field in the form with a field in the database. 550 | 551 | Example:: 552 | lookup_names = { 553 | 'date_from': 'date', 554 | 'date_till': 'date', 555 | } 556 | """ 557 | 558 | filtering_fields_optional: TypeDefFieldsAll = '__all__' 559 | """Fields that should be considered optional for filtering. 560 | Use __all__ to describe all fields. 561 | 562 | """ 563 | 564 | filtering_fields_choice_undefined: TypeDefFieldsAll = '__all__' 565 | """Fields with choices that should include an item for filtering. 566 | Use __all__ to describe all fields. 567 | 568 | """ 569 | 570 | filtering_choice_undefined_title: str = '----' 571 | """Title for choice describing an item for filtering.""" 572 | 573 | filtering_choice_undefined_value: str = '*' 574 | """Value for choice describing an item for filtering.""" 575 | 576 | @classmethod 577 | def _meta_hook(cls): 578 | super()._meta_hook() 579 | 580 | all_macro = MACRO_ALL 581 | choices_yes_no = YES_NO_CHOICES 582 | fields_optional = cls.filtering_fields_optional 583 | fields_choice_undef = cls.filtering_fields_choice_undefined 584 | undef_choice_title = cls.filtering_choice_undefined_title 585 | undef_choice_value = cls.filtering_choice_undefined_value 586 | 587 | base_fields = cls.base_fields.copy() 588 | cls.base_fields = base_fields 589 | 590 | for field_name, field in base_fields.items(): 591 | 592 | if hasattr(field, '_fltpatched'): 593 | # prevent subsequent patching 594 | continue 595 | 596 | # todo swap boolean with select to allow 597 | # todo note that value leads to filtering by False 598 | # if isinstance(field, BooleanField): 599 | # choices = choices_yes_no.copy() 600 | # field.choices = choices 601 | # field.widget = Select(choices=choices) 602 | 603 | if fields_optional and (fields_optional == all_macro or field_name in fields_optional): 604 | # For proper field rendering. 605 | field.widget.is_required = False 606 | # For value handling. 607 | field.required = False 608 | 609 | if hasattr(field, 'choices') and (fields_choice_undef == all_macro or field_name in fields_choice_undef): 610 | field.initial = field.initial or undef_choice_value 611 | field.widget.choices.insert(0, (undef_choice_value, undef_choice_title)) 612 | 613 | field._fltpatched = True 614 | 615 | def _preprocess_source_data(self, data: Union[dict, QueryDict]) -> Union[dict, QueryDict]: 616 | data = super()._preprocess_source_data(data) 617 | 618 | if not isinstance(data, MultiValueDict): 619 | data = MultiValueDict(data) 620 | 621 | undef_choice_value = self.filtering_choice_undefined_value 622 | 623 | # drop undefined values beforehand not to mess with them later 624 | for key, value_list in data.lists(): 625 | if not isinstance(value_list, list): 626 | value_list = [value_list] 627 | 628 | if undef_choice_value in value_list: 629 | data.setlist(key, [value for value in value_list if value != undef_choice_value]) 630 | 631 | return data 632 | 633 | def filtering_apply(self, queryset: QuerySet) -> Tuple[QuerySet, bool]: 634 | """Applies filtering to the queryset using user-submitted 635 | data cleaned by this form and filtering instructions (see .filtering) if any. 636 | 637 | Returns a tuple of a query set and boolean: 638 | 639 | * QuerySet 640 | * Returns a new filtered queryset if form data is valid. 641 | * If user input is invalid (form is not valid) returns initial queryset. 642 | 643 | * Returns True if filters were applied to a query set. 644 | 645 | :param queryset: 646 | 647 | """ 648 | if not self.is_valid(): 649 | return queryset, False 650 | 651 | filter_kwargs = {} 652 | rules = self.filtering_rules 653 | lookup_names = self.lookup_names 654 | cleaned_data = self.cleaned_data 655 | 656 | for field_name, field in self.fields.items(): 657 | 658 | cleaned_value = cleaned_data.get(field_name) 659 | if cleaned_value in field.empty_values: 660 | continue 661 | 662 | lookup_name = lookup_names.get(field_name, field_name) 663 | rule = rules.get(field_name) 664 | 665 | if rule: 666 | lookup_name = f'{lookup_name}__{rule}' 667 | 668 | filter_kwargs[lookup_name] = cleaned_value 669 | 670 | return queryset.filter(**filter_kwargs), bool(filter_kwargs) 671 | -------------------------------------------------------------------------------- /siteforms/composers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-siteforms/d9df98da64447ab01841495e8c495363cefdc399/siteforms/composers/__init__.py -------------------------------------------------------------------------------- /siteforms/composers/base.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from functools import partial 3 | from typing import Dict, Any, Optional, Union, List, Type, TypeVar 4 | 5 | from django.forms import BoundField, CheckboxInput, Form 6 | from django.forms.utils import flatatt 7 | from django.forms.widgets import Input 8 | from django.middleware.csrf import get_token 9 | from django.utils.translation import gettext_lazy as _ 10 | 11 | from ..utils import merge_dict, UNSET 12 | from ..widgets import ReadOnlyWidget 13 | 14 | if False: # pragma: nocover 15 | from ..base import SiteformsMixin # noqa 16 | 17 | TypeAttrs = Dict[Union[str, Type[Input]], Any] 18 | 19 | 20 | ALL_FIELDS = '__fields__' 21 | """Denotes every field.""" 22 | 23 | ALL_GROUPS = '__groups__' 24 | """Denotes every group.""" 25 | 26 | ALL_ROWS = '__rows__' 27 | """Denotes every row.""" 28 | 29 | FIELDS_STACKED = '__stacked__' 30 | """Denotes stacked fields when layout is applied. 31 | E.g. in 'group': {'a', ['b', 'c']} b and c are stacked. 32 | 33 | """ 34 | 35 | FIELDS_READONLY = '__readonly__' 36 | """Denotes fields considered read only (using ReadOnlyWidget).""" 37 | 38 | FORM = '__form__' 39 | """Denotes a form.""" 40 | 41 | SUBMIT = '__submit__' 42 | """Submit button.""" 43 | 44 | _VALUE = '__value__' 45 | 46 | TypeComposer = TypeVar('TypeComposer', bound='FormComposer') 47 | 48 | 49 | class FormatDict(dict): 50 | 51 | def __missing__(self, key: str) -> str: # pragma: nocover 52 | return '' 53 | 54 | 55 | class FormComposer: 56 | """Base form composer.""" 57 | 58 | opt_render_form_tag: bool = True 59 | """Render form tag.""" 60 | 61 | opt_label_colon: bool = True 62 | """Whether to render colons after label's texts.""" 63 | 64 | opt_render_labels: bool = True 65 | """Render label elements.""" 66 | 67 | opt_render_help: bool = True 68 | """Render hints (help texts).""" 69 | 70 | opt_title_label: bool = False 71 | """Render label as title for form field.""" 72 | 73 | opt_title_help: bool = False 74 | """Render help as title for form field.""" 75 | 76 | opt_placeholder_label: bool = False 77 | """Put title (verbose name) into field's placeholders.""" 78 | 79 | opt_placeholder_help: bool = False 80 | """Put hint (help text) into field's placeholders.""" 81 | 82 | opt_tag_help: str = 'small' 83 | """Tag to be used for hints.""" 84 | 85 | opt_tag_feedback: str = 'div' 86 | """Tag to be used for feedback.""" 87 | 88 | opt_tag_feedback_line: str = 'div' 89 | """Tag to be used for feedback.""" 90 | 91 | opt_submit: str = _('Submit') 92 | """Submit button text.""" 93 | 94 | opt_submit_name: str = '__submit' 95 | """Submit button name.""" 96 | 97 | ######################################################## 98 | 99 | attrs_labels: TypeAttrs = None 100 | """Attributes to apply to labels.""" 101 | 102 | attrs_help: TypeAttrs = None 103 | """Attributes to apply to hints.""" 104 | 105 | attrs_feedback: TypeAttrs = None 106 | """Attributes to apply to feedback (validation notes). 107 | 108 | FORM macros here denotes global (non-field) form feedback attrs. 109 | 110 | """ 111 | 112 | groups: Dict[str, str] = None 113 | """Map alias to group titles.""" 114 | 115 | wrappers: TypeAttrs = { 116 | ALL_FIELDS: '{field}', 117 | ALL_ROWS: '
{fields}
', 118 | ALL_GROUPS: '
{title}{rows}
', 119 | SUBMIT: '{submit}', 120 | FIELDS_STACKED: '
{field}
', 121 | } 122 | """Wrappers for fields, groups, rows, submit button.""" 123 | 124 | layout: TypeAttrs = { 125 | FORM: ALL_FIELDS, 126 | ALL_FIELDS: '{label}{field}{feedback}{help}', 127 | CheckboxInput: '{field}{label}{feedback}{help}', 128 | } 129 | """Layout instructions for fields and form.""" 130 | 131 | ######################################################## 132 | 133 | def __init__(self, form: Union['SiteformsMixin', Form]): 134 | self.form = form 135 | self.groups = self.groups or {} 136 | self.attrs_feedback = self.attrs_feedback or {} 137 | 138 | def __init_subclass__(cls) -> None: 139 | # Implements attributes enrichment - inherits attrs values from parents. 140 | super().__init_subclass__() 141 | 142 | def enrich_attr(attr: str): 143 | attrs_dict = {} 144 | 145 | for base in cls.__bases__: 146 | if issubclass(base, FormComposer): 147 | attrs_dict = merge_dict(getattr(base, attr), attrs_dict) 148 | 149 | setattr(cls, attr, merge_dict(getattr(cls, attr), attrs_dict)) 150 | 151 | enrich_attr('attrs') 152 | enrich_attr('attrs_labels') 153 | enrich_attr('attrs_help') 154 | enrich_attr('wrappers') 155 | enrich_attr('layout') 156 | cls._hook_init_subclass() 157 | 158 | @classmethod 159 | def _hook_init_subclass(cls): 160 | """""" 161 | 162 | def _get_attr_aria_describedby(self, field: BoundField) -> Optional[str]: 163 | if self.opt_render_help: 164 | return f'{field.id_for_label}_help' 165 | return None 166 | 167 | def _get_attr_aria_label(self, field: BoundField) -> Optional[str]: 168 | if not self.opt_render_labels: 169 | return field.label 170 | return None 171 | 172 | def _get_attr_form_enctype(self): 173 | 174 | if self.form.is_multipart(): 175 | return 'multipart/form-data' 176 | 177 | return None 178 | 179 | def _get_attr_form_method(self): 180 | return self.form.src or 'POST' 181 | 182 | attrs: TypeAttrs = { 183 | FORM: { 184 | 'method': _get_attr_form_method, 185 | 'enctype': _get_attr_form_enctype, 186 | }, 187 | ALL_FIELDS: { 188 | 'aria-describedby': _get_attr_aria_describedby, 189 | 'aria-label': _get_attr_aria_label, 190 | }, 191 | } 192 | """Attributes to apply to basic elements (form, fields, widget types, groups).""" 193 | 194 | def _attrs_get( 195 | self, 196 | container: Optional[Dict[str, Any]], 197 | key: str = None, 198 | *, 199 | obj: Any = None, 200 | accumulated: Dict[str, str] = None, 201 | ): 202 | accumulate = accumulated is not None 203 | accumulated = accumulated or {} 204 | container = container or {} 205 | 206 | if key is None: 207 | attrs = container 208 | else: 209 | attrs = container.get(key, {}) 210 | 211 | if attrs: 212 | if not isinstance(attrs, dict): 213 | attrs = {_VALUE: attrs} 214 | 215 | attrs_ = {} 216 | for key, val in attrs.items(): 217 | 218 | if callable(val): 219 | if obj is None: 220 | val = val(self) 221 | else: 222 | val = val(self, obj) 223 | 224 | if val is not None: 225 | 226 | if accumulate: 227 | val_str = f'{val}' 228 | if val_str[0] == '+': 229 | # append to a value, e.g. for 'class' attribute 230 | val = f"{accumulated.get(key, '')} {val_str[1:]}" 231 | 232 | attrs_[key] = val 233 | 234 | attrs = attrs_ 235 | 236 | if accumulate: 237 | accumulated.update(**attrs) 238 | 239 | return attrs 240 | 241 | def _attrs_get_basic(self, container: Dict[str, Any], field: BoundField): 242 | attrs = {} 243 | get_attrs = partial(self._attrs_get, container, obj=field, accumulated=attrs) 244 | 245 | for item in (ALL_FIELDS, field.field.widget.__class__, field.name): 246 | attrs.update(**get_attrs(item)) 247 | 248 | if isinstance(field.field.widget, ReadOnlyWidget): 249 | attrs.update({**get_attrs(FIELDS_READONLY)}) 250 | 251 | return attrs 252 | 253 | def _render_field(self, field: BoundField, attrs: TypeAttrs = None) -> str: 254 | 255 | attrs = attrs or self._attrs_get_basic(self.attrs, field) 256 | 257 | placeholder = attrs.get('placeholder') 258 | 259 | if placeholder is None: 260 | 261 | if self.opt_placeholder_label: 262 | attrs['placeholder'] = field.label 263 | 264 | elif self.opt_placeholder_help: 265 | attrs['placeholder'] = field.help_text 266 | 267 | title = attrs.get('title') 268 | 269 | if title is None: 270 | 271 | title_label = self.opt_title_label 272 | title_help = self.opt_title_help 273 | 274 | if title_label or title_help: 275 | 276 | if title_label: 277 | attrs['title'] = field.label 278 | 279 | elif title_help: 280 | attrs['title'] = field.help_text 281 | 282 | out = field.as_widget(attrs=attrs) 283 | 284 | if field.field.show_hidden_initial: 285 | out += field.as_hidden(only_initial=True) 286 | 287 | return f'{out}' 288 | 289 | def _render_label(self, field: BoundField) -> str: 290 | label = field.label_tag( 291 | attrs=self._attrs_get_basic(self.attrs_labels, field), 292 | label_suffix=( 293 | # Get rid of colons entirely. 294 | '' if not self.opt_label_colon else ( 295 | # Or deduce... 296 | '' if isinstance(field.field.widget, CheckboxInput) else None 297 | ) 298 | ) 299 | ) 300 | return f'{label}' 301 | 302 | def _format_feedback_lines(self, errors: List) -> str: 303 | tag = self.opt_tag_feedback_line 304 | return '\n'.join([f'<{tag}>{error}' for error in errors]) 305 | 306 | def _render_feedback(self, field: BoundField) -> str: 307 | 308 | form = self.form 309 | 310 | if not form.is_submitted: 311 | return '' 312 | 313 | errors = field.errors 314 | if not errors: 315 | return '' 316 | 317 | if field.is_hidden: 318 | # Gather hidden field errors into non-field group. 319 | for error in errors: 320 | form.add_error( 321 | None, 322 | _('Hidden field "%(name)s": %(error)s') % 323 | {'name': field.name, 'error': str(error)}) 324 | return '' 325 | 326 | attrs = self._attrs_get_basic(self.attrs_feedback, field) 327 | tag = self.opt_tag_feedback 328 | 329 | return f'<{tag} {flatatt(attrs)}>{self._format_feedback_lines(errors)}' 330 | 331 | def _render_feedback_nonfield(self) -> str: 332 | errors = self.form.non_field_errors() 333 | if not errors: 334 | return '' 335 | 336 | attrs = self.attrs_feedback.get(FORM, {}) 337 | tag = self.opt_tag_feedback 338 | return f'<{tag} {flatatt(attrs)}>{self._format_feedback_lines(errors)}' 339 | 340 | def _render_help(self, field: BoundField) -> str: 341 | help_text = field.help_text 342 | 343 | if not help_text: 344 | return '' 345 | 346 | attrs = self._attrs_get_basic(self.attrs_help, field) 347 | attrs['id'] = f'{field.id_for_label}_help' 348 | tag = self.opt_tag_help 349 | 350 | return f'<{tag} {flatatt(attrs)}>{help_text}' 351 | 352 | def _format_value(self, src: dict, **kwargs) -> str: 353 | return src[_VALUE].format_map(FormatDict(**kwargs)) 354 | 355 | def _apply_layout(self, *, fld: BoundField, field: str, label: str, hint: str, feedback: str) -> str: 356 | return self._format_value( 357 | self._attrs_get_basic(self.layout, fld), 358 | label=label, 359 | field=field, 360 | help=hint, 361 | feedback=feedback, 362 | ) 363 | 364 | def _apply_wrapper(self, *, fld: BoundField, content) -> str: 365 | return self._format_value( 366 | self._attrs_get_basic(self.wrappers, fld), 367 | field=content, 368 | ) 369 | 370 | def _render_field_box(self, field: BoundField) -> str: 371 | 372 | if field.is_hidden: 373 | self._render_feedback(field) 374 | return str(field) 375 | 376 | label = '' 377 | hint = '' 378 | 379 | if self.opt_render_labels: 380 | label = self._render_label(field) 381 | 382 | if self.opt_render_help: 383 | hint = self._render_help(field) 384 | 385 | out = self._apply_layout( 386 | fld=field, 387 | field=self._render_field(field), 388 | label=label, 389 | hint=hint, 390 | feedback=self._render_feedback(field), 391 | ) 392 | 393 | return self._apply_wrapper(fld=field, content=out) 394 | 395 | def _render_group(self, alias: str, *, rows: List[Union[BoundField, List[BoundField]]]) -> str: 396 | 397 | get_attrs = self._attrs_get 398 | attrs = self.attrs 399 | wrappers = self.wrappers 400 | format_value = self._format_value 401 | render_field_box = self._render_field_box 402 | 403 | def get_group_params(container: dict) -> dict: 404 | get_params = partial(get_attrs, container) 405 | return {**get_params(ALL_GROUPS), **get_params(alias)} 406 | 407 | def render(field: Union[BoundField, List[Union[BoundField, str]]], wrap: bool = False) -> str: 408 | 409 | if isinstance(field, list): 410 | out = [] 411 | 412 | for subfield in field: 413 | 414 | if isinstance(subfield, list): 415 | rendered = render(subfield, wrap=len(subfield) > 1) 416 | 417 | elif isinstance(subfield, str): 418 | rendered = subfield 419 | 420 | else: 421 | rendered = render_field_box(subfield) 422 | 423 | out.append(rendered) 424 | 425 | out = '\n'.join(out) 426 | if wrap: 427 | out = format_value(wrapper_stacked, field=out) 428 | 429 | return out 430 | 431 | return render_field_box(field) 432 | 433 | wrapper_stacked = get_attrs(wrappers, FIELDS_STACKED) 434 | wrapper_rows = get_attrs(wrappers, ALL_ROWS) 435 | 436 | html = format_value( 437 | get_group_params(wrappers), 438 | attrs=flatatt(get_group_params(attrs)), 439 | title=self.groups.get(alias, ''), 440 | rows='\n'.join( 441 | format_value( 442 | wrapper_rows, 443 | attrs=flatatt(get_attrs(attrs, ALL_ROWS, obj=fields)), 444 | fields=render(fields), 445 | ) 446 | for fields in rows 447 | ) 448 | ) 449 | 450 | return html 451 | 452 | def _render_layout(self) -> str: 453 | form = self.form 454 | 455 | fields = {name: form[name] for name in form.fields} 456 | 457 | form_layout = self.layout[FORM] 458 | 459 | out = [] 460 | 461 | if isinstance(form_layout, str): 462 | # Simple layout defined by macros. 463 | 464 | if form_layout == ALL_FIELDS: 465 | # all fields, no grouping 466 | render_field_box = self._render_field_box 467 | out.extend(render_field_box(field) for field in fields.values()) 468 | 469 | else: # pragma: nocover 470 | raise ValueError(f'Unsupported form layout macros: {form_layout}') 471 | 472 | elif isinstance(form_layout, dict): 473 | # Advanced layout with groups. 474 | render_group = self._render_group 475 | grouped = defaultdict(list) 476 | 477 | def add_fields_left(): 478 | group.extend([[field] for field in fields.values()]) 479 | fields.clear() 480 | 481 | def add_fields_left_hidden(): 482 | # This will allow rendering of hidden fields. 483 | # Useful in case of subforms as formsets with custom 484 | # layout when ALL_FIELDS is not used but we need to preserve 485 | # hidden fields with IDs to save form properly. 486 | hidden = [field for field in fields.values() if field.is_hidden] 487 | if hidden: 488 | grouped['_hidden'] = hidden 489 | fields.clear() 490 | 491 | for group_alias, rows in form_layout.items(): 492 | 493 | group = grouped[group_alias] 494 | 495 | if isinstance(rows, str): 496 | # Macros. 497 | 498 | if rows == ALL_FIELDS: 499 | # All the fields left as separate rows. 500 | add_fields_left() 501 | 502 | else: # pragma: nocover 503 | raise ValueError(f'Unsupported group layout macros: {rows}') 504 | 505 | else: 506 | 507 | for row in rows: 508 | if isinstance(row, str): 509 | 510 | if row == ALL_FIELDS: 511 | # All the fields left as separate rows. 512 | add_fields_left() 513 | 514 | else: 515 | # One field in row. 516 | group.append([fields.pop(row)]) 517 | 518 | else: 519 | # Several fields in a row. 520 | row_items = [] 521 | for row_item in row: 522 | if not isinstance(row_item, list): 523 | row_item = [row_item] 524 | row_items.append([fields.pop(row_subitem, '') for row_subitem in row_item]) 525 | group.append(row_items) 526 | 527 | add_fields_left_hidden() 528 | 529 | for group_alias, rows in grouped.items(): 530 | out.append(render_group(group_alias, rows=rows)) 531 | 532 | out.insert(0, self._render_feedback_nonfield()) 533 | 534 | return '\n'.join(out) 535 | 536 | def _render_submit(self) -> str: 537 | get_attr = self._attrs_get 538 | return self._format_value( 539 | get_attr(self.wrappers, SUBMIT), 540 | submit=( 541 | f'' 543 | ) 544 | ) 545 | 546 | def render(self, *, render_form_tag: bool = UNSET) -> str: 547 | """Renders form to string. 548 | 549 | :param render_form_tag: Can be used to override `opt_render_form_tag` class setting. 550 | 551 | """ 552 | html = self._render_layout() 553 | 554 | render_form_tag = self.opt_render_form_tag if render_form_tag is UNSET else render_form_tag 555 | 556 | if render_form_tag: 557 | get_attr = partial(self._attrs_get, self.attrs) 558 | form = self.form 559 | 560 | form_id = form.id or '' 561 | if form_id: 562 | form_id = f' id="{form.id}"' 563 | 564 | request = form.request 565 | 566 | csrf = '' 567 | if request and form.src == 'POST': # do not leak csrf token for GET 568 | csrf = f'' 569 | 570 | action = '' 571 | target_url = form.target_url 572 | if target_url: 573 | action = f' action="{target_url}"' 574 | 575 | html = ( 576 | f'' 577 | f'{csrf}' 578 | f'{html}' 579 | f'{self._render_submit()}' 580 | '' 581 | ) 582 | 583 | return html 584 | -------------------------------------------------------------------------------- /siteforms/composers/bootstrap4.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Tuple, Union 2 | 3 | from django.forms import FileInput, ClearableFileInput, CheckboxInput, BoundField, Select, SelectMultiple 4 | 5 | from .base import ( 6 | FormComposer, TypeAttrs, ALL_FIELDS, FORM, ALL_GROUPS, ALL_ROWS, SUBMIT, FIELDS_STACKED, FIELDS_READONLY, 7 | ) # noqa 8 | from ..utils import UNSET 9 | 10 | 11 | class Bootstrap4(FormComposer): 12 | """Bootstrap 4 theming composer.""" 13 | 14 | SIZE_SMALL = 'sm' 15 | SIZE_NORMAL = '' 16 | SIZE_LARGE = 'lg' 17 | 18 | opt_form_inline: bool = False 19 | """Make form inline.""" 20 | 21 | opt_columns: Union[bool, Tuple[str, str]] = False 22 | """Enabled two-columns mode. 23 | 24 | Expects a columns tuple: (label_columns_count, control_columns_count). 25 | 26 | If `True` default tuple ('col-2', 'col-10') is used. 27 | 28 | """ 29 | 30 | opt_custom_controls: bool = False 31 | """Use custom controls from Bootstrap 4.""" 32 | 33 | opt_checkbox_switch: bool = False 34 | """Use switches for checkboxes (if custom controls).""" 35 | 36 | opt_size: str = SIZE_NORMAL 37 | """Apply size to form elements.""" 38 | 39 | opt_disabled_plaintext: bool = False 40 | """Render disabled fields as plain text.""" 41 | 42 | opt_feedback_tooltips: bool = False 43 | """Whether to render feedback in tooltips.""" 44 | 45 | opt_feedback_valid: bool = True 46 | """Whether to render feedback for valid fields.""" 47 | 48 | _size_mod: Tuple[str, ...] = ('col-form-label', 'form-control', 'input-group') 49 | _file_cls = {'class': 'form-control-file'} 50 | 51 | @classmethod 52 | def _hook_init_subclass(cls): 53 | super()._hook_init_subclass() 54 | 55 | if cls.opt_custom_controls: 56 | 57 | cls._size_mod = tuple(list(cls._size_mod) + [ 58 | 'custom-select', 59 | ]) 60 | 61 | cls._file_cls = {'class': 'custom-file-input'} 62 | 63 | _file_label = {'class': 'custom-file-label'} 64 | _file_wrapper = '
{field}
' 65 | _select_cls = {'class': 'custom-select'} 66 | 67 | cls.attrs.update({ 68 | FileInput: cls._file_cls, 69 | ClearableFileInput: cls._file_cls, 70 | Select: _select_cls, 71 | SelectMultiple: _select_cls, 72 | CheckboxInput: {'class': 'custom-control-input'}, 73 | }) 74 | 75 | cls.attrs_labels.update({ 76 | FileInput: _file_label, 77 | ClearableFileInput: _file_label, 78 | CheckboxInput: {'class': 'custom-control-label'}, 79 | }) 80 | 81 | cls.wrappers.update({ 82 | FileInput: _file_wrapper, 83 | ClearableFileInput: _file_wrapper, 84 | }) 85 | 86 | columns = cls.opt_columns 87 | 88 | if columns: 89 | 90 | if columns is True: 91 | columns = ('col-2', 'col-10') 92 | cls.opt_columns = columns 93 | 94 | cls.attrs_labels.update({ 95 | ALL_FIELDS: {'class': 'col-form-label'}, 96 | }) 97 | 98 | cls.layout.pop(CheckboxInput, None) 99 | 100 | cls.wrappers.update({ 101 | ALL_ROWS: '{fields}', 102 | ALL_FIELDS: '{field}', 103 | }) 104 | 105 | def _get_attr_form(self) -> Optional[str]: 106 | # todo maybe needs-validation 107 | if self.opt_form_inline: 108 | return 'form-inline' 109 | return None 110 | 111 | def _get_attr_check_input(self, field: BoundField): 112 | css = 'form-check-input' 113 | if not self.opt_render_labels: 114 | css += ' position-static' 115 | return css 116 | 117 | def _apply_layout(self, *, fld: BoundField, field: str, label: str, hint: str, feedback: str) -> str: 118 | 119 | opt_columns = self.opt_columns 120 | opt_custom = self.opt_custom_controls 121 | widget = fld.field.widget 122 | 123 | is_cb = isinstance(widget, CheckboxInput) 124 | is_file = isinstance(widget, (FileInput, ClearableFileInput)) 125 | 126 | if is_cb: 127 | 128 | if opt_custom: 129 | variant = 'custom-switch' if self.opt_checkbox_switch else 'custom-checkbox' 130 | css = f'custom-control mx-1 {variant}' # todo +custom-control-inline 131 | 132 | else: 133 | css = 'form-check' # todo +form-check-inline 134 | 135 | field = f'
{field}{label}{feedback}{hint}
' 136 | label = '' 137 | hint = '' 138 | feedback = '' 139 | 140 | if opt_columns and not (opt_custom and is_file): 141 | col_label, col_control = opt_columns 142 | label = f'
{label}
' 143 | field = f'
{field}{feedback}{hint}
' 144 | hint = '' 145 | 146 | return super()._apply_layout(fld=fld, field=field, label=label, hint=hint, feedback=feedback) 147 | 148 | def _apply_wrapper(self, *, fld: BoundField, content) -> str: 149 | wrapped = super()._apply_wrapper(fld=fld, content=content) 150 | 151 | if self.opt_columns: 152 | wrapped = f'
{wrapped}
' 153 | 154 | return wrapped 155 | 156 | def _get_attr_feedback(self, field: BoundField): 157 | return f"invalid-{'tooltip' if self.opt_feedback_tooltips else 'feedback'}" 158 | 159 | def _render_feedback(self, field: BoundField) -> str: 160 | out = super()._render_feedback(field) 161 | 162 | if field.errors: 163 | # prepend hidden input to workaround feedback not shown for subforms 164 | out = f'{out}' 165 | 166 | return out 167 | 168 | def _render_field(self, field: BoundField, attrs: TypeAttrs = None) -> str: 169 | attrs = attrs or self._attrs_get_basic(self.attrs, field) 170 | 171 | css = attrs.get('class', '') 172 | 173 | if self.form.is_submitted: 174 | css = f"{css} {'is-invalid' if field.errors else ('is-valid' if self.opt_feedback_valid else '')}" 175 | 176 | if self.opt_disabled_plaintext: 177 | is_disabled = field.name in self.form.disabled_fields 178 | if is_disabled: 179 | css = ' form-control-plaintext' 180 | 181 | attrs['class'] = css 182 | 183 | return super()._render_field(field, attrs) 184 | 185 | def render(self, *, render_form_tag: bool = UNSET) -> str: 186 | out = super().render(render_form_tag=render_form_tag) 187 | 188 | size = self.opt_size 189 | 190 | if size: 191 | # Apply sizing. 192 | mod = f'-{size}' 193 | 194 | # Workaround form-control- prefix clashes. 195 | clashing = {} 196 | for idx, term in enumerate(('form-control-file', 'form-control-plaintext')): 197 | tmp_key = f'tmp_{idx}' 198 | clashing[tmp_key] = term 199 | out = out.replace(term, tmp_key) 200 | 201 | for val in self._size_mod: 202 | out = out.replace(val, f'{val} {val}{mod}') 203 | 204 | # Roll off the workaround. 205 | for tmp_key, term in clashing.items(): 206 | out = out.replace(tmp_key, term) 207 | 208 | return out 209 | 210 | attrs: TypeAttrs = { 211 | FORM: {'class': _get_attr_form}, 212 | SUBMIT: {'class': 'btn btn-primary mt-3'}, # todo control-group? 213 | ALL_FIELDS: {'class': 'form-control'}, 214 | ALL_ROWS: {'class': 'form-row mx-0'}, 215 | ALL_GROUPS: {'class': 'form-group'}, 216 | FIELDS_READONLY: {'class': 'bg-light form-control border border-light'}, 217 | FileInput: _file_cls, 218 | ClearableFileInput: _file_cls, 219 | CheckboxInput: {'class': _get_attr_check_input}, 220 | } 221 | 222 | attrs_help: TypeAttrs = { 223 | ALL_FIELDS: {'class': 'form-text text-muted'}, 224 | } 225 | 226 | attrs_feedback: TypeAttrs = { 227 | FORM: {'class': 'alert alert-danger mb-4', 'role': 'alert'}, 228 | ALL_FIELDS: {'class': _get_attr_feedback}, 229 | } 230 | 231 | attrs_labels: TypeAttrs = { 232 | CheckboxInput: {'class': 'form-check-label'}, 233 | } 234 | 235 | wrappers: TypeAttrs = { 236 | ALL_FIELDS: '
{field}
', 237 | FIELDS_STACKED: '
{field}
', 238 | } 239 | -------------------------------------------------------------------------------- /siteforms/composers/bootstrap5.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple, Union 2 | 3 | from django.forms import CheckboxInput, BoundField, Select, SelectMultiple 4 | 5 | from .base import FormComposer, TypeAttrs, ALL_FIELDS, FORM, ALL_ROWS, SUBMIT, FIELDS_READONLY 6 | from ..fields import SubformField 7 | from ..utils import UNSET 8 | 9 | 10 | class Bootstrap5(FormComposer): 11 | """Bootstrap 5 theming composer.""" 12 | 13 | SIZE_SMALL = 'sm' 14 | SIZE_NORMAL = '' 15 | SIZE_LARGE = 'lg' 16 | 17 | opt_form_inline: bool = False 18 | """Make form inline.""" 19 | 20 | opt_columns: Union[bool, Tuple[str, str]] = False 21 | """Enabled two-columns mode. 22 | 23 | Expects a columns tuple: (label_columns_count, control_columns_count). 24 | 25 | If `True` default tuple ('col-2', 'col-10') is used. 26 | 27 | """ 28 | 29 | opt_size: str = SIZE_NORMAL 30 | """Apply size to form elements.""" 31 | 32 | opt_disabled_plaintext: bool = False 33 | """Render disabled fields as plain text.""" 34 | 35 | opt_checkbox_switch: bool = False 36 | """Use switches for checkboxes.""" 37 | 38 | opt_feedback_tooltips: bool = False 39 | """Whether to render feedback in tooltips.""" 40 | 41 | opt_feedback_valid: bool = True 42 | """Whether to render feedback for valid fields.""" 43 | 44 | opt_labels_floating : bool = False 45 | """Whether input labels should be floating.""" 46 | 47 | opt_tag_help: str = 'div' 48 | 49 | _size_mod: Tuple[str, ...] = ('col-form-label', 'form-control', 'form-select', 'btn') 50 | 51 | @classmethod 52 | def _hook_init_subclass(cls): 53 | super()._hook_init_subclass() 54 | 55 | columns = cls.opt_columns 56 | 57 | if columns: 58 | 59 | if columns is True: 60 | columns = ('col-2', 'col-10') 61 | cls.opt_columns = columns 62 | 63 | cls.attrs_labels.update({ 64 | ALL_FIELDS: {'class': 'col-form-label'}, 65 | }) 66 | 67 | cls.layout.update({ 68 | CheckboxInput: '{label}{field}{feedback}{help}', 69 | }) 70 | 71 | cls.wrappers.update({ 72 | ALL_ROWS: '{fields}', 73 | ALL_FIELDS: '{field}', 74 | CheckboxInput: '{field}', 75 | }) 76 | 77 | if cls.opt_form_inline: 78 | cls.attrs.update({ 79 | FORM: {'class': 'row row-cols-auto align-items-end'}, 80 | }) 81 | 82 | def _apply_layout(self, *, fld: BoundField, field: str, label: str, hint: str, feedback: str) -> str: 83 | 84 | if isinstance(fld.field.widget, CheckboxInput): 85 | field = self._get_wrapper_checkbox(fld).replace('{field}', f'{field}{label}{feedback}{hint}') 86 | label = '' 87 | hint = '' 88 | feedback = '' 89 | 90 | opt_columns = self.opt_columns 91 | 92 | if opt_columns: 93 | col_label, col_control = opt_columns 94 | label = f'
{label}
' 95 | field = f'
{field}{feedback}{hint}
' 96 | hint = '' 97 | 98 | return super()._apply_layout(fld=fld, field=field, label=label, hint=hint, feedback=feedback) 99 | 100 | def _apply_wrapper(self, *, fld: BoundField, content) -> str: 101 | wrapped = super()._apply_wrapper(fld=fld, content=content) 102 | 103 | if self.opt_columns: 104 | wrapped = f'
{wrapped}
' 105 | 106 | return wrapped 107 | 108 | def _get_attr_feedback(self, field: BoundField): 109 | return f"invalid-{'tooltip' if self.opt_feedback_tooltips else 'feedback'}" 110 | 111 | def _get_wrapper_fields(self, field: BoundField): 112 | 113 | css = '' 114 | if self.opt_labels_floating and not isinstance(field.field, SubformField): 115 | css = ' form-floating' 116 | 117 | return '
{field}
' % css 118 | 119 | def _get_wrapper_checkbox(self, field: BoundField): 120 | 121 | css = 'form-check' 122 | if self.opt_checkbox_switch: 123 | css = f'{css} form-switch' 124 | 125 | return '
{field}
' % css 126 | 127 | def _get_layout_fields(self, field: BoundField): 128 | 129 | if self.opt_labels_floating and not isinstance(field.field, SubformField): 130 | return '{field}{label}{feedback}{help}' 131 | 132 | return '{label}{field}{feedback}{help}' 133 | 134 | def _render_field(self, field: BoundField, attrs: TypeAttrs = None) -> str: 135 | attrs = attrs or self._attrs_get_basic(self.attrs, field) 136 | 137 | css = attrs.get('class', '') 138 | 139 | if self.form.is_submitted: 140 | css = f"{css} {'is-invalid' if field.errors else ('is-valid' if self.opt_feedback_valid else '')}" 141 | 142 | if self.opt_disabled_plaintext: 143 | is_disabled = field.name in self.form.disabled_fields 144 | if is_disabled: 145 | css = ' form-control-plaintext' 146 | 147 | attrs['class'] = css 148 | 149 | return super()._render_field(field, attrs) 150 | 151 | def render(self, *, render_form_tag: bool = UNSET) -> str: 152 | out = super().render(render_form_tag=render_form_tag) 153 | 154 | size = self.opt_size 155 | 156 | if size: 157 | # Apply sizing. 158 | mod = f'-{size}' 159 | 160 | # Workaround form-control- prefix clashes. 161 | clashing = {} 162 | for idx, term in enumerate(('form-control-plaintext', 'btn-')): 163 | tmp_key = f'tmp_{idx}' 164 | clashing[tmp_key] = term 165 | out = out.replace(term, tmp_key) 166 | 167 | for val in self._size_mod: 168 | out = out.replace(val, f'{val} {val}{mod}') 169 | 170 | # Roll off the workaround. 171 | for tmp_key, term in clashing.items(): 172 | out = out.replace(tmp_key, term) 173 | 174 | return out 175 | 176 | attrs: TypeAttrs = { 177 | SUBMIT: {'class': 'btn btn-primary mt-3'}, 178 | ALL_FIELDS: {'class': 'form-control'}, 179 | ALL_ROWS: {'class': 'row mx-1'}, 180 | # ALL_GROUPS: {'class': ''}, # todo maybe 181 | FIELDS_READONLY: {'class': 'bg-light form-control border border-light'}, 182 | CheckboxInput: {'class': 'form-check-input'}, 183 | Select: {'class': 'form-select'}, 184 | SelectMultiple: {'class': 'form-select'}, 185 | } 186 | 187 | attrs_help: TypeAttrs = { 188 | ALL_FIELDS: {'class': 'form-text'}, 189 | } 190 | 191 | attrs_feedback: TypeAttrs = { 192 | FORM: {'class': 'alert alert-danger mb-4', 'role': 'alert'}, 193 | ALL_FIELDS: {'class': _get_attr_feedback}, 194 | } 195 | 196 | attrs_labels: TypeAttrs = { 197 | ALL_FIELDS: {'class': 'form-label'}, 198 | CheckboxInput: {'class': 'form-check-label'}, 199 | } 200 | 201 | wrappers: TypeAttrs = { 202 | ALL_FIELDS: _get_wrapper_fields, 203 | SUBMIT: '
{submit}
', 204 | # FIELDS_STACKED: '
{field}
', # todo maybe 205 | } 206 | 207 | layout: TypeAttrs = { 208 | ALL_FIELDS: _get_layout_fields, 209 | } 210 | -------------------------------------------------------------------------------- /siteforms/fields.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Optional 3 | 4 | from django.core.serializers.json import DjangoJSONEncoder 5 | from django.forms import BoundField, Field, ModelChoiceField 6 | 7 | from .formsets import BaseFormSet 8 | from .widgets import SubformWidget 9 | 10 | if False: # pragma: nocover 11 | from .base import TypeSubform # noqa 12 | 13 | 14 | class EnhancedBoundField(BoundField): 15 | """This custom bound field allows widgets to access the field itself.""" 16 | 17 | def as_widget(self, widget=None, attrs=None, only_initial=False): 18 | widget = widget or self.field.widget 19 | widget.bound_field = self 20 | return super().as_widget(widget, attrs, only_initial) 21 | 22 | 23 | class EnhancedField(Field): 24 | """This custom field offers improvements over the base one.""" 25 | 26 | def get_bound_field(self, form, field_name): 27 | return EnhancedBoundField(form, self, field_name) 28 | 29 | 30 | class SubformField(EnhancedField): 31 | """Field representing a subform.""" 32 | 33 | widget = SubformWidget 34 | 35 | def __init__(self, *args, original_field, **kwargs): 36 | super().__init__(*args, **kwargs) 37 | self.original_field = original_field 38 | self.form: Optional['TypeSubform'] = None 39 | 40 | # todo Maybe proxy other attributes? 41 | self.label = original_field.label 42 | self.help_text = original_field.help_text 43 | self.to_python = original_field.to_python 44 | 45 | @classmethod 46 | def _json_serialize(cls, value: dict) -> str: 47 | return json.dumps(value, cls=DjangoJSONEncoder) 48 | 49 | def clean(self, value): 50 | original_field = self.original_field 51 | 52 | if isinstance(original_field, ModelChoiceField): 53 | form = self.form 54 | 55 | if isinstance(form, BaseFormSet): 56 | value_ = [] 57 | 58 | for item in value or []: 59 | item_id = item.get('id') 60 | 61 | if item_id is None: 62 | # New m2m item is to be initialized on fly. 63 | # todo maybe this should be opt-out 64 | for extra_form in form.extra_forms: 65 | # Try to find an exact form which produced the data. 66 | if extra_form.cleaned_data is item: 67 | instance = extra_form.save() 68 | if instance.pk: 69 | item_id = instance.id 70 | item['id'] = instance 71 | 72 | if item_id: 73 | # item id here is actually a model instance 74 | value_.append(item['id'].id) 75 | 76 | value = value_ 77 | 78 | else: 79 | # For a subform with a model (FK). 80 | value = form.initial.get('id') 81 | 82 | if value is None and form.instance.pk is None: 83 | # New foreign key item is to be initialized on fly. 84 | # todo maybe this should be opt-out 85 | instance = form.save() 86 | if instance.pk: 87 | return instance 88 | 89 | else: 90 | # For a subform with JSON this `value` contains `cleaned_data` dictionary. 91 | # We convert this into json to allow parent form field to clean it. 92 | value = self._json_serialize(value) 93 | 94 | return original_field.clean(value) 95 | -------------------------------------------------------------------------------- /siteforms/formsets.py: -------------------------------------------------------------------------------- 1 | from django.forms import BaseFormSet, BaseModelFormSet 2 | 3 | from .utils import bind_subform 4 | 5 | 6 | class SiteformFormSetMixin(BaseFormSet): 7 | """Custom formset to allow fields rendering subform to have multiple forms.""" 8 | 9 | def render(self, *args, **kwargs): 10 | return f'{self.management_form}' + ('\n'.join(form.render() for form in self)) 11 | 12 | def _construct_form(self, i, **kwargs): 13 | form = super()._construct_form(i, **kwargs) 14 | 15 | # Need to update subform linking for fields since 16 | # a formset doesn't do it. 17 | for field in form.fields.values(): 18 | bind_subform(subform=form, field=field) 19 | 20 | return form 21 | 22 | 23 | class ModelFormSet(SiteformFormSetMixin, BaseModelFormSet): 24 | """Formset for model forms.""" 25 | -------------------------------------------------------------------------------- /siteforms/locale/en/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2021-09-19 09:40+0700\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \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 | 21 | #: composers/base.py:91 22 | msgid "Submit" 23 | msgstr "" 24 | 25 | #: composers/base.py:306 26 | #, python-format 27 | msgid "Hidden field \"%(name)s\": %(error)s" 28 | msgstr "" 29 | 30 | #: config.py:9 31 | msgid "Siteforms" 32 | msgstr "" 33 | 34 | #: widgets.py:67 35 | msgid "unknown" 36 | msgstr "" 37 | 38 | #: widgets.py:78 39 | msgid "Yes" 40 | msgstr "" 41 | 42 | #: widgets.py:78 43 | msgid "No" 44 | msgstr "" 45 | -------------------------------------------------------------------------------- /siteforms/locale/ru/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-siteforms/d9df98da64447ab01841495e8c495363cefdc399/siteforms/locale/ru/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /siteforms/locale/ru/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: \n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2021-09-19 09:40+0700\n" 11 | "PO-Revision-Date: 2021-09-19 09:40+0700\n" 12 | "Last-Translator: Igor 'idle sign' Starikov \n" 13 | "Language-Team: \n" 14 | "Language: ru\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" 19 | "%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n" 20 | "%100>=11 && n%100<=14)? 2 : 3);\n" 21 | "X-Generator: Poedit 2.3\n" 22 | 23 | #: composers/base.py:91 24 | msgid "Submit" 25 | msgstr "Отправить" 26 | 27 | #: composers/base.py:306 28 | #, python-format 29 | msgid "Hidden field \"%(name)s\": %(error)s" 30 | msgstr "Скрытое поле \"%(name)s\": %(error)s" 31 | 32 | #: config.py:9 33 | msgid "Siteforms" 34 | msgstr "Формы" 35 | 36 | #: widgets.py:67 37 | msgid "unknown" 38 | msgstr "неизвестно" 39 | 40 | #: widgets.py:78 41 | msgid "Yes" 42 | msgstr "Да" 43 | 44 | #: widgets.py:78 45 | msgid "No" 46 | msgstr "Нет" 47 | 48 | #~ msgid "Subform field \"%(name)s\": %(error)s" 49 | #~ msgstr "Поле вложенной формы \"%(name)s\": %(error)s" 50 | -------------------------------------------------------------------------------- /siteforms/metas.py: -------------------------------------------------------------------------------- 1 | from django.forms.forms import DeclarativeFieldsMetaclass 2 | from django.forms.models import ModelFormMetaclass 3 | 4 | from .base import SiteformsMixin 5 | 6 | 7 | class BaseMeta(DeclarativeFieldsMetaclass): 8 | 9 | def __new__(mcs, name, bases, attrs): 10 | cls: SiteformsMixin = super().__new__(mcs, name, bases, attrs) 11 | cls._meta_hook() 12 | return cls 13 | 14 | 15 | class ModelBaseMeta(BaseMeta, ModelFormMetaclass): 16 | pass 17 | -------------------------------------------------------------------------------- /siteforms/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # This package is considered both as a django app, and a test package. 2 | 3 | -------------------------------------------------------------------------------- /siteforms/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Type, Union, List 3 | 4 | import pytest 5 | from pytest_djangoapp import configure_djangoapp_plugin 6 | 7 | from siteforms.composers.base import FORM, ALL_FIELDS 8 | from siteforms.toolbox import ModelForm, Form 9 | 10 | pytest_plugins = configure_djangoapp_plugin() 11 | 12 | 13 | @pytest.fixture 14 | def layout(): 15 | return dict( 16 | groups={ 17 | 'basic': 'MYBasicGroup', 18 | 'other': 'somethingmore', 19 | }, 20 | layout={ 21 | FORM: { 22 | 'basic': [ 23 | ['fchar', 'fbool'], 24 | 'ftext', 25 | ], 26 | '_': ['ffile'], 27 | 'other': ALL_FIELDS, 28 | } 29 | }, 30 | ) 31 | 32 | 33 | @pytest.fixture 34 | def form(): 35 | 36 | from siteforms.composers.base import FormComposer 37 | 38 | def form_( 39 | *, 40 | composer=None, 41 | model=None, 42 | options: dict = None, 43 | fields: Union[List[str], str] = None, 44 | model_meta: dict = None, 45 | **kwargs 46 | ) -> Union[Type[Form], Type[ModelForm]]: 47 | 48 | if composer is None: 49 | composer = FormComposer 50 | 51 | form_attrs = dict( 52 | Composer=type('Composer', (composer,), options or {}), 53 | **kwargs 54 | ) 55 | 56 | form_cls = Form 57 | 58 | if model: 59 | model_meta = { 60 | 'model': model, 61 | 'fields': fields or '__all__', 62 | **(model_meta or {}), 63 | } 64 | form_attrs['Meta'] = type('Meta', (object,), model_meta) 65 | form_cls = ModelForm 66 | 67 | return type('DynForm', (form_cls,), form_attrs) 68 | 69 | return form_ 70 | 71 | 72 | @pytest.fixture 73 | def form_html(form): 74 | 75 | def form_html_(options=None, *, composer=None, model=None, **kwargs): 76 | frm = form(model=model, composer=composer, options=options)(**kwargs) 77 | return f'{frm}' 78 | 79 | return form_html_ 80 | 81 | 82 | @pytest.fixture 83 | def form_fixture_match(datafix_read): 84 | 85 | def form_fixture_match_(form, fname): 86 | rendered = str(form).strip() 87 | assert rendered == datafix_read(fname).strip() 88 | 89 | return form_fixture_match_ 90 | 91 | 92 | RE_INPUTS = re.compile(r'\n 2 |
fchar_help
3 |
fchoices_help
11 |
fbool_help
12 |
ftext_help
14 |
15 |
16 |
20 |
22 | -------------------------------------------------------------------------------- /siteforms/tests/datafixtures/bs5_basic_1.html: -------------------------------------------------------------------------------- 1 |
2 |
fchar_help
3 |
fchoices_help
11 |
fbool_help
12 |
ftext_help
14 |
15 |
16 |
20 |
22 | -------------------------------------------------------------------------------- /siteforms/tests/datafixtures/nocss_basic_1.html: -------------------------------------------------------------------------------- 1 |
2 | fchar_help 3 | fchoices_help 11 | fbool_help 12 | ftext_help 14 | 15 | 16 | 20 |
22 | -------------------------------------------------------------------------------- /siteforms/tests/test_bootstrap4.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | import pytest 4 | 5 | from siteforms.composers.bootstrap4 import Bootstrap4 6 | from siteforms.tests.testapp.models import Thing 7 | 8 | 9 | class Composer(Bootstrap4): 10 | """""" 11 | 12 | 13 | @pytest.fixture 14 | def bs4_form_html(form_html): 15 | return partial(form_html, composer=Composer, model=Thing) 16 | 17 | 18 | def test_bs4_basic(bs4_form_html, form_fixture_match): 19 | 20 | thing = Thing() 21 | thing.save() 22 | 23 | html = bs4_form_html({}, instance=thing) 24 | 25 | form_fixture_match(html, 'bs4_basic_1.html') 26 | 27 | 28 | def test_bs4_size(bs4_form_html): 29 | html = bs4_form_html(dict(opt_size=Composer.SIZE_SMALL)) 30 | assert 'form-control form-control-sm' in html 31 | assert 'form-control-file' in html 32 | 33 | html = bs4_form_html(dict(opt_size=Composer.SIZE_LARGE)) 34 | assert 'form-control form-control-lg' in html 35 | 36 | 37 | def test_bs4_custom_controls(bs4_form_html): 38 | html = bs4_form_html(dict(opt_custom_controls=True)) 39 | assert 'custom-control' in html 40 | assert 'custom-control-label' in html 41 | assert 'custom-control-input' in html 42 | assert 'custom-select' in html 43 | assert 'custom-file' in html 44 | assert 'custom-file-label' in html 45 | assert 'custom-file-input' in html 46 | assert 'form-check' not in html 47 | 48 | 49 | def test_bs4_custom_columns(bs4_form_html, form_fixture_match): 50 | html = bs4_form_html(dict(opt_columns=True)) 51 | assert 'form-group row' in html 52 | assert 'class="col-2"' in html 53 | assert 'class="col-10"' in html 54 | assert 'col-form-label' in html 55 | 56 | 57 | def test_bs4_disabled_plaintext(bs4_form_html): 58 | html = bs4_form_html(disabled_fields={'fchar'}) 59 | assert 'form-control-plaintext' not in html 60 | assert 'disabled id="id_fchar"' in html 61 | 62 | html = bs4_form_html(dict(opt_disabled_plaintext=True), disabled_fields={'fchar'}) 63 | assert 'form-control-plaintext' in html 64 | 65 | 66 | def test_bs4_render_labels(bs4_form_html): 67 | html = bs4_form_html(dict(opt_render_labels=True)) 68 | assert 'position-static' not in html 69 | 70 | html = bs4_form_html(dict(opt_render_labels=False)) 71 | assert 'position-static' in html 72 | 73 | 74 | def test_bs4_validation(bs4_form_html, request_factory): 75 | request = request_factory().get('some?__submit=siteform') 76 | html = bs4_form_html(src='GET', request=request) 77 | assert 'is-valid' in html 78 | assert 'is-invalid' in html 79 | assert 'invalid-feedback' in html 80 | 81 | 82 | def test_bs4_form_inline(bs4_form_html): 83 | html = bs4_form_html(dict(opt_form_inline=True)) 84 | assert 'form-inline' in html 85 | 86 | 87 | def test_bs4_groups(bs4_form_html, layout): 88 | html = bs4_form_html(layout) 89 | assert 'MYBasicGroup
' in html 90 | assert '' in html # no-title group 91 | -------------------------------------------------------------------------------- /siteforms/tests/test_bootstrap5.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | import pytest 4 | 5 | from siteforms.composers.bootstrap5 import Bootstrap5 6 | from siteforms.tests.testapp.models import Thing 7 | 8 | 9 | class Composer(Bootstrap5): 10 | """""" 11 | 12 | 13 | @pytest.fixture 14 | def bs5_form_html(form_html): 15 | return partial(form_html, composer=Composer, model=Thing) 16 | 17 | 18 | def test_bs5_basic(bs5_form_html, form_fixture_match): 19 | 20 | thing = Thing() 21 | thing.save() 22 | 23 | html = bs5_form_html({}, instance=thing) 24 | 25 | form_fixture_match(html, 'bs5_basic_1.html') 26 | 27 | 28 | def test_bs5_size(bs5_form_html): 29 | html = bs5_form_html(dict(opt_size=Composer.SIZE_SMALL)) 30 | assert 'form-control form-control-sm' in html 31 | 32 | html = bs5_form_html(dict(opt_size=Composer.SIZE_LARGE)) 33 | assert 'form-control form-control-lg' in html 34 | 35 | 36 | def test_bs5_custom_columns(bs5_form_html, form_fixture_match): 37 | html = bs5_form_html(dict(opt_columns=True)) 38 | assert 'class="col-2"' in html 39 | assert 'class="col-10"' in html 40 | assert 'col-form-label' in html 41 | 42 | 43 | def test_bs5_disabled_plaintext(bs5_form_html): 44 | html = bs5_form_html(disabled_fields={'fchar'}) 45 | assert 'form-control-plaintext' not in html 46 | assert 'disabled id="id_fchar"' in html 47 | 48 | html = bs5_form_html(dict(opt_disabled_plaintext=True), disabled_fields={'fchar'}) 49 | assert 'form-control-plaintext' in html 50 | 51 | 52 | def test_bs5_validation(bs5_form_html, request_factory): 53 | request = request_factory().get('some?__submit=siteform') 54 | html = bs5_form_html(src='GET', request=request) 55 | assert 'is-valid' in html 56 | assert 'is-invalid' in html 57 | assert 'invalid-feedback' in html 58 | 59 | 60 | def test_bs5_form_inline(bs5_form_html): 61 | html = bs5_form_html(dict(opt_form_inline=True)) 62 | assert 'row row-cols-auto' in html 63 | 64 | 65 | def test_bs5_labels_floating(bs5_form_html): 66 | html = bs5_form_html(dict(opt_labels_floating=True)) 67 | assert 'form-floating' in html 68 | 69 | 70 | def test_bs5_switch(bs5_form_html): 71 | html = bs5_form_html(dict(opt_checkbox_switch=True)) 72 | assert 'form-switch' in html 73 | -------------------------------------------------------------------------------- /siteforms/tests/test_common.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | import pytest 4 | from django.forms import ModelMultipleChoiceField 5 | 6 | from siteforms.composers.base import FormComposer, ALL_FIELDS 7 | from siteforms.tests.testapp.models import Thing, Another, Additional, AnotherThing, Link, WithThrough, ThroughModel 8 | from siteforms.toolbox import ModelForm, Form, fields 9 | 10 | 11 | class Composer(FormComposer): 12 | 13 | opt_render_help = False 14 | opt_render_labels = False 15 | 16 | 17 | class ModelFormBase(ModelForm): 18 | 19 | class Composer(Composer): 20 | pass 21 | 22 | 23 | class MyAnotherForm(ModelForm): 24 | 25 | class Meta: 26 | model = Another 27 | fields = '__all__' 28 | 29 | class Composer(Composer): 30 | pass 31 | 32 | 33 | class MyAdditionalForm(ModelForm): 34 | 35 | class Meta: 36 | model = Additional 37 | fields = '__all__' 38 | 39 | class Composer(Composer): 40 | pass 41 | 42 | 43 | class MyForm(ModelForm): 44 | 45 | class Meta: 46 | model = Thing 47 | fields = '__all__' 48 | 49 | class Composer(Composer): 50 | pass 51 | 52 | 53 | class MyFormWithProperty(MyForm): 54 | 55 | aprop = fields.CharField(label='from property', required=False) 56 | 57 | class Meta(MyForm.Meta): 58 | property_fields = ['aprop'] 59 | 60 | 61 | class LinkForm(ModelForm): 62 | 63 | subforms = { 64 | 'fthing': MyForm, 65 | } 66 | 67 | class Meta: 68 | model = Link 69 | fields = '__all__' 70 | 71 | class Composer(Composer): 72 | pass 73 | 74 | 75 | class MyAnotherThingForm(ModelForm): 76 | 77 | class Meta: 78 | model = AnotherThing 79 | fields = '__all__' 80 | 81 | class Composer(Composer): 82 | pass 83 | 84 | 85 | def test_append_attr_class(): 86 | 87 | class AdditionalWithCss(MyAdditionalForm): 88 | 89 | class Composer(MyAdditionalForm.Composer): 90 | attrs = { 91 | ALL_FIELDS: {'class': 'my'}, 92 | 'fnum': {'class': '+yours'} 93 | } 94 | 95 | frm = AdditionalWithCss() 96 | html = f'{frm}' 97 | assert 'class="my yours"' in html 98 | 99 | 100 | def test_id(form_html): 101 | 102 | thing = Thing() 103 | thing.save() 104 | 105 | html = str(form_html( 106 | composer=Composer, model=Thing, 107 | id='dum', instance=thing, 108 | )) 109 | assert ' id="dum"' in html 110 | assert ' id="dum_fchar"' in html 111 | 112 | 113 | def test_args_data(request_post): 114 | 115 | thing = Thing() 116 | thing.save() 117 | 118 | form = MyForm( 119 | {'fchar': '1', 'ftext': 'populated'}, 120 | src='POST', 121 | request=request_post(data={'__submit': 'siteform', 'fchar': '2', 'fchoices': '2'}), 122 | ) 123 | 124 | # both datas are combined, src-request data has lesser priority 125 | # automatic `src` handling doesn't override `data` as the first arg or kwarg 126 | data = form.data.dict() 127 | assert data['fchar'] == '1' 128 | assert data['ftext'] == 'populated' 129 | assert data['fchoices'] == '2' 130 | 131 | 132 | def test_fields_disabled_all(): 133 | 134 | new_form_cls = type('MyFormWithSubform', (MyForm,), { 135 | 'subforms': {'fforeign': MyAnotherForm}, 136 | }) 137 | 138 | form = new_form_cls(disabled_fields='__all__') 139 | rendered = f'{form}' 140 | assert 'disabled id="id_fchoices"' in rendered 141 | assert 'disabled id="id_fforeign-fsome"' in rendered # in subform 142 | 143 | # test not disabled anymore (base fields stay intact) 144 | form = new_form_cls() 145 | rendered = f'{form}' 146 | assert 'disabled id="id_fchoices"' not in rendered 147 | assert 'disabled id="id_fforeign-fsome"' not in rendered # in subform 148 | 149 | 150 | def test_formset_m2m(request_post, request_get, db_queries): 151 | 152 | class MyFormWithSet(MyForm): 153 | 154 | subforms = { 155 | 'fm2m': MyAdditionalForm, 156 | } 157 | 158 | class Composer(MyForm.Composer): 159 | opt_render_help = False 160 | 161 | class Meta(MyForm.Meta): 162 | fields = ['fchar', 'fm2m'] 163 | 164 | form = MyFormWithSet(request=request_get(), src='POST') 165 | html = f'{form}' 166 | expected = '' 167 | assert expected in html 168 | 169 | # Add two m2m items. 170 | add1 = Additional.objects.create(fnum='xxx') 171 | add2 = Additional.objects.create(fnum='yyy') 172 | add3 = Additional.objects.create(fnum='kkk') 173 | 174 | thing = Thing.objects.create(fchar='one') 175 | thing.fm2m.add(add1, add2) 176 | 177 | # get instead of refresh to get brand new objects 178 | thing = Thing.objects.get(id=thing.id) 179 | 180 | # Check subform is rendered with instance data. 181 | form = MyFormWithSet(request=request_get(), src='POST', instance=thing) 182 | html = f'{form}' 183 | assert 'name="fm2m-TOTAL_FORMS"' in html 184 | assert '
888' in html 564 | 565 | form = MyAnotherForm(request=request_get(), src='POST', instance=another1) 566 | html = f'{form}' 567 | assert '" required id="id_fsome"' in html 568 | assert 'disabled' not in html 569 | 570 | 571 | def test_cheap_details_view(request_get): 572 | 573 | add1 = Additional.objects.create(fnum='eee') 574 | another1 = Another.objects.create(fsome='888', fadd=add1) 575 | another2 = Another.objects.create(fsome='999', fadd=add1) 576 | 577 | thing = AnotherThing.objects.create(fchar='one') 578 | thing.fm2m.add(another1, another2) 579 | 580 | form = MyFormWithSet( 581 | request=request_get(), src='POST', instance=thing, 582 | readonly_fields='__all__', render_form_tag=False) 583 | 584 | html = f'{form}' 585 | assert 'disabled>999' in html 586 | assert '
Errr1
\n
Errr2
' in form_html 34 | assert 'Hidden field "some": Enter a valid date.' in form_html 35 | 36 | 37 | def test_nocss_basic(nocss_form_html, form_fixture_match): 38 | 39 | thing = Thing() 40 | thing.save() 41 | 42 | html = nocss_form_html({}, instance=thing) 43 | 44 | form_fixture_match(html, 'nocss_basic_1.html') 45 | 46 | 47 | def test_nocss_validation(nocss_form_html, request_factory): 48 | request = request_factory().get('some?__submit=siteform') 49 | html = nocss_form_html(src='GET', request=request) 50 | assert 'csrfmiddlewaretoken' not in html 51 | assert '
This field is required.
' in html 52 | 53 | html = nocss_form_html(src='POST', request=request) 54 | assert 'csrfmiddlewaretoken' in html 55 | 56 | 57 | def test_nocss_aria_described_by(nocss_form_html): 58 | html = nocss_form_html(dict(opt_render_help=True)) 59 | assert 'aria-describedby="id_fbool_help' in html 60 | 61 | html = nocss_form_html(dict(opt_render_help=False)) 62 | assert 'aria-describedby="id_fbool_help' not in html 63 | 64 | 65 | def test_nocss_placeholders(nocss_form_html): 66 | html = nocss_form_html(dict(opt_placeholder_label=True)) 67 | assert 'id_fchar_help" placeholder="Fchar_name"' in html 68 | 69 | html = nocss_form_html(dict(opt_placeholder_help=True)) 70 | assert 'id_fchar_help" placeholder="fchar_help"' in html 71 | 72 | 73 | def test_nocss_title(nocss_form_html): 74 | html = nocss_form_html(dict(opt_title_label=True)) 75 | assert 'id_fchar_help" title="Fchar_name"' in html 76 | 77 | html = nocss_form_html(dict(opt_title_help=True)) 78 | assert 'id_fchar_help" title="fchar_help"' in html 79 | 80 | 81 | def test_nocss_hidden(nocss_form_html): 82 | html = nocss_form_html(hidden_fields={'fchar'}) 83 | assert '' in html 84 | 85 | 86 | def test_nocss_disabled(nocss_form_html): 87 | html = nocss_form_html(disabled_fields={'fchar'}) 88 | assert 'disabled id="id_fchar">' in html 89 | 90 | 91 | def test_nocss_readonly(nocss_form_html): 92 | thing = Thing(fchoices='two') 93 | html = nocss_form_html(readonly_fields={'fchoices'}, instance=thing) 94 | assert 'id="id_fchoices" disabled required>2MYBasicGroup' in html 100 | assert '\n
somethingmore' in html 101 | assert '' in html # no-title group 102 | 103 | 104 | def test_nocss_layout(nocss_form_html): 105 | 106 | layout = { 107 | 'opt_render_labels': False, 108 | 'opt_render_help': False, 109 | 'layout': { 110 | FORM: { 111 | 'some': [ 112 | ['fchar', ['fbool', 'ftext']], 113 | ['file'], 114 | 'fchoices', 115 | ALL_FIELDS, 116 | ], 117 | }, 118 | }, 119 | } 120 | html = nocss_form_html(layout) 121 | assert ( 122 | '\n' 123 | '
\n' 124 | '
' in html) 126 | 127 | 128 | def test_nocss_nonmultipart(form): 129 | frm = form(composer=Composer, some=fields.CharField())() 130 | assert '' in f'{frm}' 131 | -------------------------------------------------------------------------------- /siteforms/tests/test_widgets.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from siteforms.tests.testapp.models import Thing, Another 4 | from siteforms.toolbox import ReadOnlyWidget 5 | 6 | 7 | def test_basic(form): 8 | 9 | class MyFcharWidget(ReadOnlyWidget): 10 | 11 | def format_value_hook(self, value: Any): 12 | return super().format_value_hook(value) + 'xxx' 13 | 14 | class MyWidget(ReadOnlyWidget): 15 | 16 | template_name = 'mywidget.html' 17 | 18 | class MyMultipleWidget(ReadOnlyWidget): 19 | 20 | def format_value_hook(self, value: Any): 21 | return 'dumdum' 22 | 23 | form_cls = form( 24 | model=Thing, 25 | readonly_fields={'fchar', 'fforeign', 'fchoices', 'fbool', 'fm2m'}, 26 | fields=['fchar', 'ftext', 'fforeign', 'fchoices', 'fbool', 'fm2m'], 27 | model_meta={ 28 | 'widgets': { 29 | 'ftext': MyWidget, 30 | 'fm2m': MyMultipleWidget, 31 | }, 32 | 'widgets_readonly': { 33 | 'fchar': MyFcharWidget, # test custom readonly widget 34 | }, 35 | }, 36 | ) 37 | foreign = Another.objects.create(fsome='that') 38 | thing = Thing.objects.create(fchar='one', ftext='duo', fforeign=foreign, fchoices='q', fbool=False) 39 | form = form_cls(instance=thing) 40 | 41 | html = f'{form}' 42 | assert 'onexxx' in html # Simple readonly 43 | assert 'that' in html # FK 44 | assert '<unknown (q)>' in html # Unknown in choices 45 | assert 'mywidgetdata' in html # data from template 46 | assert 'id="id_fbool" disabled>No' in html # readonly bool 47 | assert '>dumdum<' in html # multiple widget 48 | -------------------------------------------------------------------------------- /siteforms/tests/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idlesign/django-siteforms/d9df98da64447ab01841495e8c495363cefdc399/siteforms/tests/testapp/__init__.py -------------------------------------------------------------------------------- /siteforms/tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Another(models.Model): 5 | 6 | fsome = models.CharField(max_length=20, verbose_name='fsome_name', help_text='fsome_help') 7 | fadd = models.ForeignKey( 8 | 'Additional', verbose_name='fadd_name', help_text='fadd_help', null=True, blank=True, 9 | on_delete=models.CASCADE) 10 | 11 | def __str__(self): 12 | return self.fsome 13 | 14 | 15 | class Additional(models.Model): 16 | 17 | fnum = models.CharField(max_length=5, verbose_name='fnum_name', help_text='fnum_help') 18 | 19 | 20 | class Link(models.Model): 21 | 22 | fadd = models.ForeignKey(Additional, verbose_name='fadd_lnk', on_delete=models.CASCADE) 23 | fthing = models.ForeignKey('Thing', verbose_name='fthing_lnk', on_delete=models.CASCADE) 24 | fmore = models.CharField(max_length=5, verbose_name='fmore_name', help_text='fmore_help') 25 | 26 | 27 | class Thing(models.Model): 28 | 29 | CHOICES1 = { 30 | 'one': '1', 31 | 'two': '2', 32 | } 33 | 34 | fchar = models.CharField(max_length=50, verbose_name='fchar_name', help_text='fchar_help') 35 | fchoices = models.CharField( 36 | max_length=50, verbose_name='fchoices_name', help_text='fchoices_help', choices=CHOICES1.items()) 37 | fbool = models.BooleanField(default=False, verbose_name='fbool_name', help_text='fbool_help') 38 | ftext = models.TextField(verbose_name='ftext_name', help_text='ftext_help') 39 | ffile = models.FileField(verbose_name='ffile_name') 40 | fdate = models.DateField(verbose_name='fdate_name', null=True) 41 | fforeign = models.ForeignKey(Another, verbose_name='fforeign_name', null=True, on_delete=models.CASCADE) 42 | fm2m = models.ManyToManyField(Additional, verbose_name='fm2m_name') 43 | 44 | @property 45 | def aprop(self): 46 | return f'he-{self.fchar}' 47 | 48 | 49 | class AnotherThing(models.Model): 50 | 51 | fchar = models.CharField(max_length=50, verbose_name='fchar_name') 52 | fm2m = models.ManyToManyField(Another, verbose_name='fm2m_name') 53 | 54 | 55 | class ThroughModel(models.Model): 56 | 57 | with_through = models.ForeignKey('WithThrough', on_delete=models.CASCADE) 58 | additional = models.ForeignKey(Additional, on_delete=models.CASCADE) 59 | payload = models.CharField(max_length=10) 60 | notouch = models.CharField(max_length=10) 61 | 62 | 63 | class WithThrough(models.Model): 64 | 65 | title = models.CharField(max_length=10) 66 | additionals = models.ManyToManyField(Additional, through=ThroughModel) 67 | 68 | @property 69 | def through(self): 70 | items = self.additionals.through.objects.filter(with_through=self) 71 | return items 72 | -------------------------------------------------------------------------------- /siteforms/tests/testapp/templates/mywidget.html: -------------------------------------------------------------------------------- 1 | mywidgetdata 2 | -------------------------------------------------------------------------------- /siteforms/toolbox.py: -------------------------------------------------------------------------------- 1 | from django.forms import ModelForm as _ModelForm, Form as _Form 2 | from django.forms import fields # noqa exposed for convenience 3 | 4 | from .base import SiteformsMixin as _Mixin, FilteringSiteformsMixin as _FilteringMixin 5 | from .metas import BaseMeta as _BaseMeta, ModelBaseMeta as _ModelBaseMeta 6 | from .widgets import ReadOnlyWidget # noqa 7 | 8 | 9 | class Form(_Mixin, _Form, metaclass=_BaseMeta): 10 | """Base form with siteforms features enabled.""" 11 | 12 | 13 | class FilteringForm(_FilteringMixin, _Form, metaclass=_BaseMeta): 14 | """Base filtering form with siteforms features enabled.""" 15 | 16 | 17 | class _ModelFormBase(_ModelForm): 18 | 19 | def save(self, commit=True): 20 | 21 | for subform in self._iter_subforms(): 22 | 23 | # Model form can include other types of forms. 24 | save_method = getattr(subform, 'save', None) 25 | if save_method: 26 | save_method(commit=commit) 27 | 28 | return super().save(commit) 29 | 30 | 31 | class ModelForm(_Mixin, _ModelFormBase, metaclass=_ModelBaseMeta): 32 | """Base model form with siteforms features enabled.""" 33 | 34 | 35 | class FilteringModelForm(_FilteringMixin, _ModelFormBase, metaclass=_ModelBaseMeta): 36 | """Base filtering model form with siteforms features enabled.""" 37 | -------------------------------------------------------------------------------- /siteforms/utils.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from typing import Optional, Union 3 | 4 | from django.forms import Field 5 | 6 | if False: # pragma: nocover 7 | from .base import TypeSubform # noqa 8 | 9 | 10 | UNSET = set() 11 | """Value is not set sentinel.""" 12 | 13 | 14 | def merge_dict(src: Optional[dict], dst: Union[dict, str]) -> dict: 15 | 16 | if src is None: 17 | src = {} 18 | 19 | if isinstance(dst, str): 20 | # Strings are possible when base layout (e.g. ALL_FIELDS) 21 | # replaced by user-defined layout. 22 | return src.copy() 23 | 24 | out = dst.copy() 25 | 26 | for k, v in src.items(): 27 | if isinstance(v, dict): 28 | v = merge_dict(v, dst.setdefault(k, {})) 29 | out[k] = v 30 | 31 | return out 32 | 33 | 34 | def bind_subform(*, subform: 'TypeSubform', field: Field): 35 | """Initializes field attributes thus linking them to a subform. 36 | 37 | :param subform: 38 | :param field: 39 | 40 | """ 41 | field.widget.form = subform 42 | field.form = subform 43 | 44 | 45 | @contextmanager 46 | def temporary_fields_patch(form): 47 | """Too bad. Since Django's BoundField uses form base fields attributes, 48 | (but not its own, e.g. for disabled in .build_widget_attrs()) 49 | we are forced to store and restore previous values not to have side 50 | effects on form classes reuse. 51 | 52 | .. warning:: Possible race condition. Maybe fix that someday? 53 | 54 | :param form: 55 | 56 | """ 57 | originals = {} 58 | 59 | for name, field in form.base_fields.items(): 60 | originals[name] = (field.widget, field.disabled) 61 | 62 | try: 63 | yield 64 | 65 | finally: 66 | for name, field in form.base_fields.items(): 67 | field.widget, field.disabled = originals[name] 68 | -------------------------------------------------------------------------------- /siteforms/widgets.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Any, List 2 | 3 | from django.db.models import Manager, Model 4 | from django.forms import Widget, ModelChoiceField, BooleanField, ModelMultipleChoiceField 5 | from django.forms.utils import flatatt 6 | from django.utils.translation import gettext_lazy as _ 7 | 8 | from .utils import UNSET 9 | 10 | if False: # pragma: nocover 11 | from .fields import EnhancedBoundField # noqa 12 | from .base import TypeSubform 13 | 14 | 15 | class SubformWidget(Widget): 16 | """Widget representing a subform""" 17 | 18 | form: Optional['TypeSubform'] = None 19 | """Subform or a formset for which the widget is used. 20 | Bound runtime by .get_subform(). 21 | 22 | """ 23 | 24 | bound_field: Optional['EnhancedBoundField'] = None 25 | """Bound runtime by SubformBoundField when a widget is get.""" 26 | 27 | def render(self, name, value, attrs=None, renderer=None): 28 | # Call form render, or a formset render, or a formset form renderer. 29 | return self.bound_field.form.get_subform(name=name).render() 30 | 31 | def value_from_datadict(self, data, files, name): 32 | form = self.form 33 | if form and form.is_valid(): 34 | # validate to get the cleaned data 35 | # that would be used as data for subform 36 | return form.cleaned_data 37 | return super().value_from_datadict(data, files, name) 38 | 39 | 40 | class ReadOnlyWidget(Widget): 41 | """Can be used to swap form input element with a field value. 42 | Useful to make cheap entity details pages by a simple reuse of forms from entity edit pages. 43 | 44 | """ 45 | template_name = '' 46 | 47 | def __init__( 48 | self, 49 | *args, 50 | bound_field: 'EnhancedBoundField' = None, 51 | original_widget: Widget = None, 52 | **kwargs 53 | ): 54 | super().__init__(*args, **kwargs) 55 | self.bound_field = bound_field 56 | self.original_widget = original_widget 57 | 58 | def get_multiple_items(self, value: Optional[Manager]) -> List[Model]: 59 | """Allows customization of results for ModelMultipleChoiceField 60 | (e.g. .select_related). 61 | 62 | :param value: 63 | 64 | """ 65 | if value is None: 66 | return [] 67 | return list(value.all()) 68 | 69 | def format_value_hook(self, value: Any): 70 | """Allows format value customization right before it's formatted by base format function.""" 71 | return value 72 | 73 | def format_value(self, value): 74 | 75 | bound_field = self.bound_field 76 | field = bound_field.field 77 | use_original_value_format = True 78 | 79 | unknown = _('unknown') 80 | 81 | if isinstance(field, ModelMultipleChoiceField): 82 | try: 83 | value = getattr(bound_field.form.instance, bound_field.name, None) 84 | except ValueError: # generated due to m2m access from model without an id 85 | value = None 86 | 87 | value = self.get_multiple_items(value) 88 | use_original_value_format = False 89 | 90 | elif isinstance(field, ModelChoiceField): 91 | # Do not try to pick all choices for FK. 92 | value = getattr(bound_field.form.instance, bound_field.name, None) 93 | use_original_value_format = False 94 | 95 | elif isinstance(field, BooleanField): 96 | if value is None: 97 | value = f'<{unknown}>' 98 | else: 99 | value = _('Yes') if value else _('No') 100 | 101 | else: 102 | choices = getattr(field, 'choices', UNSET) 103 | if choices is not UNSET: 104 | # Try ro represent a choice value. 105 | use_original_value_format = False 106 | if value is not None: 107 | # Do not try to get title for None. 108 | value = dict(choices or {}).get(value, f'<{unknown} ({value})>') 109 | 110 | if use_original_value_format: 111 | original_widget = self.original_widget 112 | if original_widget: 113 | value = original_widget.format_value(value) 114 | 115 | value = self.format_value_hook(value) 116 | 117 | return super().format_value(value) or '' 118 | 119 | def _render(self, template_name, context, renderer=None): 120 | widget_data = context['widget'] 121 | 122 | if template_name: 123 | # Support template rendering for subclasses. 124 | value = super()._render(template_name, context, renderer) 125 | 126 | else: 127 | value = widget_data['value'] 128 | 129 | return self.wrap_value(value=value, attrs=widget_data['attrs']) 130 | 131 | @classmethod 132 | def wrap_value(cls, *, value: Any, attrs: dict): 133 | return f'
{value}
' 134 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{37,38,39,310}-django{22,30,31,32,40,41} 4 | 5 | install_command = pip install {opts} {packages} 6 | skip_missing_interpreters = True 7 | 8 | [testenv] 9 | commands = python setup.py test 10 | 11 | deps = 12 | django20: Django>=2.0,<2.1 13 | django21: Django>=2.1,<2.2 14 | django22: Django>=2.2,<2.3 15 | django30: Django>=3.0,<3.1 16 | django31: Django>=3.1,<3.2 17 | django32: Django>=3.2,<3.3 18 | django40: Django>=4.0,<4.1 19 | django41: Django>=4.1,<4.2 20 | --------------------------------------------------------------------------------