├── .codeclimate.yml ├── .coveragerc ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── CHANGES.rst ├── COPYING.LGPLv3 ├── Dockerfile ├── MANIFEST.in ├── README.rst ├── docker-compose.yml ├── docker-entrypoint.sh ├── example ├── example │ ├── __init__.py │ ├── app │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── fixtures │ │ │ └── data.json │ │ ├── migrations │ │ │ ├── 0001_initial.py │ │ │ └── __init__.py │ │ ├── models.py │ │ ├── tests.py │ │ ├── urls.py │ │ └── views.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py └── manage.py ├── radiogrid ├── __init__.py ├── compat.py ├── db.py ├── fields.py ├── templates │ └── radiogrid │ │ ├── radiogrid_input.html │ │ └── radiogrid_widget.html └── widgets.py ├── screenshot.png ├── setup.cfg ├── setup.py └── tox.ini /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | languages: 2 | Python: true 3 | 4 | exclude_paths: 5 | - "example/*" 6 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = radiogrid 4 | omit = 5 | example/* 6 | 7 | [report] 8 | ignore_errors = True 9 | 10 | [html] 11 | directory = coverage 12 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | os: 13 | - ubuntu-latest 14 | python-version: 15 | - "2.7" 16 | - "3.7" 17 | - "3.8" 18 | - "3.9" 19 | - "3.10" 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | python -m pip install tox tox-gh-actions 30 | - name: Test with tox 31 | run: tox 32 | - name: Upload coverage to Codecov 33 | uses: codecov/codecov-action@v3 34 | with: 35 | fail_ci_if_error: false 36 | files: ./coverage.xml 37 | flags: unittests 38 | verbose: true 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.pot 3 | *.pyc 4 | local_settings.py 5 | *.py~ 6 | *.egg-info 7 | *.db 8 | .idea 9 | .coverage 10 | .coverage.* 11 | .tox 12 | coverage 13 | build 14 | dist 15 | venv 16 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | 1.1.0 (2022-07-01) 2 | ------------------ 3 | 4 | * Updated for Django 4.0. Thanks @Milutinke92 for the patch! 5 | * Drop support for non-LTS Django versions. 6 | * Drop support for old Python 3 versions. 7 | 8 | 1.0.4 (2017-07-22) 9 | ------------------ 10 | 11 | * Fixed the parameter require_all_fields=False. Thanks @nmalkin for the patch! 12 | 13 | 1.0.3 (2017-03-26) 14 | ------------------ 15 | 16 | * Updated for Django 1.11 17 | 18 | 1.0.2 (2016-09-26) 19 | ------------------ 20 | 21 | * Updated for Django 1.10 22 | 23 | 1.0.1 (2015-08-04) 24 | ------------------ 25 | 26 | * Fixed bug with validation 27 | 28 | 1.0.0 (2015-07-26) 29 | ------------------ 30 | 31 | * First stable release 32 | 33 | 0.0.1 (2015-07-24) 34 | ------------------ 35 | 36 | * Initial version 37 | -------------------------------------------------------------------------------- /COPYING.LGPLv3: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10.4-slim-buster 2 | 3 | ENV PYTHONUNBUFFERED=1 ENVIRONMENT=DOCKER PYTHONPATH="${PYTHONPATH}:/app/" 4 | 5 | RUN pip install --upgrade pip && pip install --no-cache-dir django coverage tox build twine black isort 6 | 7 | WORKDIR /app 8 | 9 | COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh 10 | ENTRYPOINT ["docker-entrypoint.sh"] 11 | 12 | EXPOSE 8001 13 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include CHANGES.rst 3 | include COPYING.LGPLv3 4 | recursive-include radiogrid/templates *.html 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-radiogrid 2 | ================ 3 | 4 | .. image:: https://codecov.io/gh/Sinkler/django-radiogrid/branch/master/graph/badge.svg 5 | :target: https://codecov.io/gh/Sinkler/django-radiogrid 6 | 7 | .. image:: https://codeclimate.com/github/Sinkler/django-radiogrid/badges/gpa.svg 8 | :target: https://codeclimate.com/github/Sinkler/django-radiogrid 9 | 10 | .. image:: https://img.shields.io/pypi/l/django-radiogrid.svg 11 | :target: https://pypi.python.org/pypi/django-radiogrid 12 | 13 | .. image:: https://img.shields.io/pypi/v/django-radiogrid.svg 14 | :target: https://pypi.python.org/pypi/django-radiogrid 15 | 16 | With this you can create a radio grid field: 17 | 18 | .. image:: screenshot.png 19 | :target: screenshot.png 20 | 21 | Installation 22 | ============ 23 | 24 | :: 25 | 26 | pip install django-radiogrid 27 | 28 | In your settings.py 29 | ------------------- 30 | 31 | :: 32 | 33 | INSTALLED_APPS = ( 34 | 35 | # ... 36 | 37 | 'radiogrid', 38 | ) 39 | 40 | In your models.py 41 | ----------------- 42 | 43 | :: 44 | 45 | from radiogrid import RadioGridField 46 | 47 | # ... 48 | 49 | ROWS = ( 50 | (1, 'First'), 51 | (2, 'Second'), 52 | (3, 'Third'), 53 | ) 54 | 55 | VALUES = ( 56 | ('pyha', 'Pyha'), 57 | ('work', 'Work'), 58 | ('happy', 'Happy'), 59 | ('food', 'Food'), 60 | ) 61 | 62 | class MyModel(models.Model): 63 | 64 | # ... 65 | 66 | my_grid = RadioGridField(rows=ROWS, values=VALUES, require_all_fields=True) 67 | 68 | Example project 69 | =============== 70 | 71 | You can run it as usual: 72 | 73 | :: 74 | 75 | virtualenv venv 76 | . venv/bin/activate 77 | pip install django 78 | pip install -e . 79 | cd example 80 | ./manage.py migrate 81 | ./manage.py loaddata data 82 | ./manage.py runserver 83 | ./manage.py test 84 | 85 | or 86 | 87 | :: 88 | 89 | docker-compose up app 90 | docker-compose run --rm app test 91 | 92 | Developing 93 | ========== 94 | 95 | Testing 96 | ------- 97 | 98 | :: 99 | 100 | docker-compose run --rm app test 101 | docker-compose run --rm app coverage 102 | docker-compose run --rm app tox -e py310-django-master 103 | 104 | Releasing 105 | --------- 106 | 107 | - add a new version description in ``CHANGES.rst`` 108 | - change a version in ``__init__.py`` 109 | - add a github release 110 | - ``docker-compose run --rm app release`` 111 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "Э.8" 2 | 3 | services: 4 | app: 5 | build: ./ 6 | image: django-radiogrid 7 | volumes: 8 | - ./:/app/ 9 | - ~/.pypirc:/root/.pypirc:ro 10 | ports: 11 | - 8001:8001 12 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | pip install -e . 6 | 7 | case "$1" in 8 | test) 9 | python example/manage.py test example.app 10 | ;; 11 | coverage) 12 | coverage run example/manage.py test example.app 13 | coverage report 14 | coverage html 15 | ;; 16 | release) 17 | python -m build 18 | twine check --strict dist/* 19 | twine upload dist/* 20 | ;; 21 | shell) 22 | python example/manage.py shell 23 | ;; 24 | manage) 25 | python example/manage.py "${@:2}" 26 | ;; 27 | runserver) 28 | python example/manage.py migrate 29 | python example/manage.py loaddata data 30 | python example/manage.py runserver 0.0.0.0:8001 31 | ;; 32 | *) 33 | exec "$@" 34 | ;; 35 | esac 36 | -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sinkler/django-radiogrid/a20274adb04028fa7acb6c5f671b1a4c48a8eb4b/example/example/__init__.py -------------------------------------------------------------------------------- /example/example/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sinkler/django-radiogrid/a20274adb04028fa7acb6c5f671b1a4c48a8eb4b/example/example/app/__init__.py -------------------------------------------------------------------------------- /example/example/app/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from example.app.models import Octodex, OptionalGridModel 4 | 5 | 6 | class OctodexAdmin(admin.ModelAdmin): 7 | pass 8 | 9 | 10 | admin.site.register(Octodex, OctodexAdmin) 11 | admin.site.register(OptionalGridModel) 12 | -------------------------------------------------------------------------------- /example/example/app/fixtures/data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "pk": 1, 4 | "model": "app.octodex", 5 | "fields": { 6 | "title": "My cat", 7 | "categories": "pyha,work,happy", 8 | "week": "1,2,3,4,5,4,3" 9 | } 10 | }, 11 | { 12 | "pk": 1, 13 | "model": "app.octoduck", 14 | "fields": { 15 | "title": "My duck", 16 | "week": "1,2,duck,4,5,4,3" 17 | } 18 | }, 19 | { 20 | "pk": 1, 21 | "model": "app.optionalgridmodel", 22 | "fields": { 23 | "title": "My optional grid", 24 | "week": "1,2,,4,5,4," 25 | } 26 | }, 27 | { 28 | "model": "auth.user", 29 | "pk": 1, 30 | "fields": { 31 | "username": "admin", 32 | "first_name": "", 33 | "last_name": "", 34 | "is_active": true, 35 | "is_superuser": true, 36 | "is_staff": true, 37 | "last_login": "2015-07-24T13:23:24.785Z", 38 | "groups": [], 39 | "user_permissions": [], 40 | "password": "pbkdf2_sha256$20000$0IGd030gONiK$rvzJf3iod/FzkggydpydmlI+qvFHSm+FXZUY91MUeOc=", 41 | "email": "", 42 | "date_joined": "2015-07-24T13:23:24.785Z" 43 | } 44 | } 45 | ] -------------------------------------------------------------------------------- /example/example/app/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.5 on 2022-07-01 19:08 2 | 3 | from django.db import migrations, models 4 | 5 | import radiogrid.db 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="Octodex", 17 | fields=[ 18 | ( 19 | "id", 20 | models.AutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("title", models.CharField(max_length=50)), 28 | ( 29 | "categories", 30 | radiogrid.db.RadioGridField( 31 | require_all_fields=True, 32 | rows=((1, "First"), (2, "Second"), (3, "Third")), 33 | values=( 34 | ("pyha", "Pyha"), 35 | ("work", "Work"), 36 | ("happy", "Happy"), 37 | ("food", "Food"), 38 | ), 39 | ), 40 | ), 41 | ( 42 | "week", 43 | radiogrid.db.RadioGridField( 44 | require_all_fields=True, 45 | rows=( 46 | (1, "Monday"), 47 | (2, "Tuesday"), 48 | (3, "Wednesday"), 49 | (4, "Thursday"), 50 | (5, "Friday"), 51 | (6, "Saturday"), 52 | (7, "Sunday"), 53 | ), 54 | values=( 55 | (1, "2-3 hours"), 56 | (2, "3-4 hours"), 57 | (3, "5-7 hours"), 58 | (4, "8 hours"), 59 | (5, "Never"), 60 | ), 61 | ), 62 | ), 63 | ], 64 | ), 65 | migrations.CreateModel( 66 | name="Octoduck", 67 | fields=[ 68 | ( 69 | "id", 70 | models.AutoField( 71 | auto_created=True, 72 | primary_key=True, 73 | serialize=False, 74 | verbose_name="ID", 75 | ), 76 | ), 77 | ("title", models.CharField(max_length=50)), 78 | ( 79 | "week", 80 | radiogrid.db.RadioGridField( 81 | require_all_fields=True, 82 | rows=( 83 | (1, "Monday"), 84 | (2, "Tuesday"), 85 | (3, "Wednesday"), 86 | (4, "Thursday"), 87 | (5, "Friday"), 88 | (6, "Saturday"), 89 | (7, "Sunday"), 90 | ), 91 | values=( 92 | (1, "2-3 hours"), 93 | (2, "3-4 hours"), 94 | ("3", "5-7 hours"), 95 | (4, "8 hours"), 96 | (5, "Never"), 97 | ), 98 | ), 99 | ), 100 | ], 101 | ), 102 | migrations.CreateModel( 103 | name="OptionalGridModel", 104 | fields=[ 105 | ( 106 | "id", 107 | models.AutoField( 108 | auto_created=True, 109 | primary_key=True, 110 | serialize=False, 111 | verbose_name="ID", 112 | ), 113 | ), 114 | ("title", models.CharField(max_length=50)), 115 | ( 116 | "week", 117 | radiogrid.db.RadioGridField( 118 | require_all_fields=False, 119 | rows=( 120 | (1, "Monday"), 121 | (2, "Tuesday"), 122 | (3, "Wednesday"), 123 | (4, "Thursday"), 124 | (5, "Friday"), 125 | (6, "Saturday"), 126 | (7, "Sunday"), 127 | ), 128 | values=( 129 | (1, "2-3 hours"), 130 | (2, "3-4 hours"), 131 | (3, "5-7 hours"), 132 | (4, "8 hours"), 133 | (5, "Never"), 134 | ), 135 | ), 136 | ), 137 | ], 138 | ), 139 | ] 140 | -------------------------------------------------------------------------------- /example/example/app/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sinkler/django-radiogrid/a20274adb04028fa7acb6c5f671b1a4c48a8eb4b/example/example/app/migrations/__init__.py -------------------------------------------------------------------------------- /example/example/app/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.db import models 4 | 5 | from radiogrid import RadioGridField 6 | 7 | CATEGORIES_ROWS = ( 8 | (1, "First"), 9 | (2, "Second"), 10 | (3, "Third"), 11 | ) 12 | 13 | CATEGORIES_VALUES = ( 14 | ("pyha", "Pyha"), 15 | ("work", "Work"), 16 | ("happy", "Happy"), 17 | ("food", "Food"), 18 | ) 19 | 20 | WEEK_ROWS = ( 21 | (1, "Monday"), 22 | (2, "Tuesday"), 23 | (3, "Wednesday"), 24 | (4, "Thursday"), 25 | (5, "Friday"), 26 | (6, "Saturday"), 27 | (7, "Sunday"), 28 | ) 29 | 30 | WEEK_VALUES = ( 31 | (1, "2-3 hours"), 32 | (2, "3-4 hours"), 33 | (3, "5-7 hours"), 34 | (4, "8 hours"), 35 | (5, "Never"), 36 | ) 37 | 38 | 39 | class Octodex(models.Model): 40 | title = models.CharField(max_length=50) 41 | categories = RadioGridField( 42 | rows=CATEGORIES_ROWS, values=CATEGORIES_VALUES, require_all_fields=True 43 | ) 44 | week = RadioGridField(rows=WEEK_ROWS, values=WEEK_VALUES, require_all_fields=True) 45 | 46 | def __str__(self): 47 | return self.title 48 | 49 | def __unicode__(self): 50 | return self.__str__() 51 | 52 | 53 | WEEK_VALUES_DUCK = ( 54 | (1, "2-3 hours"), 55 | (2, "3-4 hours"), 56 | ("3", "5-7 hours"), 57 | (4, "8 hours"), 58 | (5, "Never"), 59 | ) 60 | 61 | 62 | class Octoduck(models.Model): 63 | title = models.CharField(max_length=50) 64 | week = RadioGridField( 65 | rows=WEEK_ROWS, values=WEEK_VALUES_DUCK, require_all_fields=True 66 | ) 67 | 68 | def __str__(self): 69 | return self.title 70 | 71 | def __unicode__(self): 72 | return self.__str__() 73 | 74 | 75 | class OptionalGridModel(models.Model): 76 | title = models.CharField(max_length=50) 77 | week = RadioGridField(rows=WEEK_ROWS, values=WEEK_VALUES, require_all_fields=False) 78 | 79 | def __str__(self): 80 | return self.title 81 | 82 | def __unicode__(self): 83 | return self.__str__() 84 | -------------------------------------------------------------------------------- /example/example/app/tests.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.forms.models import modelform_factory 3 | from django.test import TestCase 4 | 5 | from example.app.models import ( 6 | WEEK_ROWS, 7 | WEEK_VALUES, 8 | Octodex, 9 | Octoduck, 10 | OptionalGridModel, 11 | ) 12 | from radiogrid import RadioGridWidget 13 | 14 | 15 | def get_field(model, name): 16 | return model._meta.get_field(name) 17 | 18 | 19 | class MultiSelectTestCase(TestCase): 20 | fixtures = ["data.json"] 21 | 22 | def test_filter(self): 23 | self.assertEqual(Octodex.objects.filter(categories__contains="pyha").count(), 1) 24 | self.assertEqual( 25 | Octodex.objects.filter(categories__contains="crash").count(), 0 26 | ) 27 | 28 | def test_form(self): 29 | form_class = modelform_factory(Octodex, fields=["title", "categories", "week"]) 30 | self.assertEqual(len(form_class.base_fields), 3) 31 | 32 | form = form_class( 33 | { 34 | "title": "new octodex", 35 | "categories_0": "work", 36 | "categories_1": "pyha", 37 | "categories_2": "food", 38 | "week_0": "5", 39 | "week_1": "4", 40 | "week_2": "3", 41 | "week_3": "2", 42 | "week_4": "1", 43 | "week_5": "2", 44 | "week_6": "3", 45 | } 46 | ) 47 | self.assertTrue(form.is_valid()) 48 | 49 | # Invalid due to missing fields 50 | form = form_class( 51 | { 52 | "title": "new octodex", 53 | "categories_0": "work", 54 | "categories_1": "pyha", 55 | "categories_2": "food", 56 | "week_0": "5", 57 | "week_1": "4", 58 | "week_2": "", 59 | "week_3": "2", 60 | "week_4": "1", 61 | "week_5": "2", 62 | "week_6": "", 63 | } 64 | ) 65 | self.assertFalse(form.is_valid()) 66 | 67 | form = form_class( 68 | { 69 | "title": "new octodex", 70 | "categories_0": "die", 71 | "categories_1": "phpforum", 72 | "categories_2": "depression", 73 | "week_0": "11", 74 | "week_1": "23", 75 | "week_2": "34", 76 | "week_3": "666", 77 | "week_4": "74", 78 | "week_5": "123", 79 | "week_6": "9", 80 | } 81 | ) 82 | self.assertFalse(form.is_valid()) 83 | 84 | self.assertTrue(form.as_p()) 85 | 86 | def test_object(self): 87 | octodex = Octodex.objects.get(id=1) 88 | 89 | self.assertEqual(octodex.get_categories_display(), "Pyha, Work, Happy") 90 | self.assertEqual(octodex.get_categories_list(), ["Pyha", "Work", "Happy"]) 91 | 92 | self.assertEqual( 93 | octodex.get_week_display(), 94 | "2-3 hours, 3-4 hours, 5-7 hours, 8 hours, Never, 8 hours, 5-7 hours", 95 | ) 96 | self.assertEqual( 97 | octodex.get_week_list(), 98 | [ 99 | "2-3 hours", 100 | "3-4 hours", 101 | "5-7 hours", 102 | "8 hours", 103 | "Never", 104 | "8 hours", 105 | "5-7 hours", 106 | ], 107 | ) 108 | 109 | self.assertEqual( 110 | octodex.get_categories_list(), octodex.get_categories_display().split(", ") 111 | ) 112 | self.assertEqual( 113 | octodex.get_categories_list(), octodex.get_categories_display().split(", ") 114 | ) 115 | 116 | self.assertEqual( 117 | octodex.get_week_list(), octodex.get_week_display().split(", ") 118 | ) 119 | self.assertEqual( 120 | octodex.get_week_list(), octodex.get_week_display().split(", ") 121 | ) 122 | 123 | octoduck = Octoduck.objects.get(id=1) 124 | self.assertEqual( 125 | octoduck.get_week_display(), 126 | "2-3 hours, 3-4 hours, duck, 8 hours, Never, 8 hours, 5-7 hours", 127 | ) 128 | 129 | def test_validate(self): 130 | octodex = Octodex.objects.get(id=1) 131 | try: 132 | get_field(Octodex, "categories").clean(["phpforum", "work"], octodex) 133 | raise AssertionError() 134 | except ValidationError: 135 | pass 136 | try: 137 | get_field(Octodex, "week").clean(["13", "666"], octodex) 138 | raise AssertionError() 139 | except ValidationError: 140 | pass 141 | 142 | def test_serializer(self): 143 | octodex = Octodex.objects.get(id=1) 144 | self.assertEqual( 145 | get_field(Octodex, "categories").value_to_string(octodex), "pyha,work,happy" 146 | ) 147 | self.assertEqual( 148 | get_field(Octodex, "week").value_to_string(octodex), "1,2,3,4,5,4,3" 149 | ) 150 | self.assertEqual(get_field(Octodex, "week").value_to_string(None), "") 151 | 152 | def test_widget(self): 153 | widget = RadioGridWidget(rows=WEEK_ROWS, values=WEEK_VALUES) 154 | self.assertTrue(widget.render("days", "work,pyha,food")) 155 | self.assertTrue(widget.render("days", None)) 156 | 157 | def test_optional_form(self): 158 | form_class = modelform_factory(OptionalGridModel, fields=["week"]) 159 | self.assertEqual(len(form_class.base_fields), 1) 160 | 161 | # Fully populated form is valid 162 | form = form_class( 163 | { 164 | "title": "new optional", 165 | "week_0": "5", 166 | "week_1": "4", 167 | "week_2": "3", 168 | "week_3": "2", 169 | "week_4": "1", 170 | "week_5": "2", 171 | "week_6": "3", 172 | } 173 | ) 174 | self.assertTrue(form.is_valid()) 175 | 176 | # Valid with missing fields 177 | form = form_class( 178 | { 179 | "title": "new optional", 180 | "week_0": "5", 181 | "week_1": "", 182 | "week_2": "3", 183 | "week_3": "", 184 | "week_4": "1", 185 | "week_5": "", 186 | "week_6": "3", 187 | } 188 | ) 189 | self.assertTrue(form.is_valid()) 190 | 191 | # All-blank is valid 192 | form = form_class( 193 | { 194 | "title": "new optional", 195 | "week_0": "", 196 | "week_1": "", 197 | "week_2": "", 198 | "week_3": "", 199 | "week_4": "", 200 | "week_5": "", 201 | "week_6": "", 202 | } 203 | ) 204 | self.assertTrue(form.is_valid()) 205 | 206 | # Bad values still invalid 207 | form = form_class( 208 | { 209 | "title": "new optional", 210 | "week_0": "11", 211 | "week_1": "23", 212 | "week_2": "34", 213 | "week_3": "666", 214 | "week_4": "74", 215 | "week_5": "123", 216 | "week_6": "9", 217 | } 218 | ) 219 | self.assertFalse(form.is_valid()) 220 | 221 | 222 | def test_optional_serializer(self): 223 | optional = OptionalGridModel.objects.get(id=1) 224 | self.assertEqual( 225 | get_field(Octodex, "week").value_to_string(optional), "1,2,,4,5,4," 226 | ) 227 | -------------------------------------------------------------------------------- /example/example/app/urls.py: -------------------------------------------------------------------------------- 1 | from django import VERSION 2 | 3 | from example.app.views import app_index 4 | 5 | if VERSION <= (2, 2): 6 | from django.conf.urls import url 7 | else: 8 | from django.urls import re_path as url 9 | 10 | urlpatterns = [ 11 | url(r"^$", app_index, name="app_index"), 12 | ] 13 | -------------------------------------------------------------------------------- /example/example/app/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.conf import settings 4 | from django.contrib.auth import login 5 | from django.contrib.auth.models import User 6 | from django.http import HttpResponseRedirect 7 | 8 | try: 9 | from django.core.urlresolvers import reverse 10 | except ImportError: 11 | from django.urls import reverse 12 | 13 | 14 | def app_index(request): 15 | user = User.objects.get(username="admin") 16 | if not hasattr(user, "backend"): 17 | user.backend = settings.AUTHENTICATION_BACKENDS[0] 18 | login(request, user) 19 | return HttpResponseRedirect(reverse("admin:app_octodex_change", args=(1,))) 20 | -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | from django import VERSION 4 | 5 | DEBUG = True 6 | BASE_DIR = path.dirname(path.abspath(__file__)) 7 | DATABASES = { 8 | "default": { 9 | "ENGINE": "django.db.backends.sqlite3", 10 | "NAME": "example.db", 11 | "USER": "", 12 | "PASSWORD": "", 13 | "HOST": "", 14 | "PORT": "", 15 | } 16 | } 17 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 18 | ALLOWED_HOSTS = ["localhost"] 19 | TIME_ZONE = "America/Chicago" 20 | LANGUAGE_CODE = "en-us" 21 | SITE_ID = 1 22 | USE_I18N = True 23 | USE_L10N = True 24 | USE_TZ = True 25 | MEDIA_ROOT = path.join(BASE_DIR, "media") 26 | MEDIA_URL = "/media/" 27 | STATIC_ROOT = path.join(BASE_DIR, "static") 28 | STATIC_URL = "/static/" 29 | STATICFILES_FINDERS = ( 30 | "django.contrib.staticfiles.finders.FileSystemFinder", 31 | "django.contrib.staticfiles.finders.AppDirectoriesFinder", 32 | ) 33 | SECRET_KEY = "lwfoqweffq#$5casdfwfqmg$1t%8)g6hr%&b4%)%_ualb8s" 34 | MIDDLEWARE_CLASSES = ( 35 | "django.middleware.common.CommonMiddleware", 36 | "django.contrib.sessions.middleware.SessionMiddleware", 37 | "django.middleware.csrf.CsrfViewMiddleware", 38 | "django.contrib.auth.middleware.AuthenticationMiddleware", 39 | "django.contrib.messages.middleware.MessageMiddleware", 40 | ) 41 | MIDDLEWARE = MIDDLEWARE_CLASSES 42 | ROOT_URLCONF = "example.urls" 43 | WSGI_APPLICATION = "example.wsgi.application" 44 | TEMPLATES = [ 45 | { 46 | "BACKEND": "django.template.backends.django.DjangoTemplates", 47 | "DIRS": [], 48 | "OPTIONS": { 49 | "context_processors": ( 50 | "django.contrib.auth.context_processors.auth", 51 | "django.template.context_processors.debug", 52 | "django.template.context_processors.i18n", 53 | "django.template.context_processors.media", 54 | "django.template.context_processors.request", 55 | "django.template.context_processors.tz", 56 | "django.template.context_processors.static", 57 | "django.contrib.messages.context_processors.messages", 58 | ), 59 | "debug": DEBUG, 60 | "loaders": ( 61 | "django.template.loaders.filesystem.Loader", 62 | "django.template.loaders.app_directories.Loader", 63 | ), 64 | }, 65 | }, 66 | ] 67 | INSTALLED_APPS = ( 68 | "django.contrib.auth", 69 | "django.contrib.contenttypes", 70 | "django.contrib.sessions", 71 | "django.contrib.sites", 72 | "django.contrib.messages", 73 | "django.contrib.staticfiles", 74 | "django.contrib.admin", 75 | "radiogrid", 76 | "example.app", 77 | ) 78 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | from django import VERSION 2 | from django.conf import settings 3 | from django.conf.urls import include 4 | from django.contrib import admin 5 | from django.views.static import serve 6 | 7 | if VERSION <= (2, 2): 8 | from django.conf.urls import url 9 | else: 10 | from django.urls import re_path as url 11 | 12 | 13 | admin.autodiscover() 14 | 15 | urlpatterns = [ 16 | url(r"^", include("example.app.urls")), 17 | url(r"^admin/", include(admin.site.urls) if VERSION < (2, 0) else admin.site.urls), 18 | ] 19 | 20 | urlpatterns += [ 21 | url( 22 | r"^%s(?P.*)$" % settings.MEDIA_URL[1:], 23 | serve, 24 | {"document_root": settings.MEDIA_ROOT, "show_indexes": True}, 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /example/example/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 6 | application = get_wsgi_application() 7 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | 6 | from django.conf import ENVIRONMENT_VARIABLE 7 | 8 | if __name__ == "__main__": 9 | os.environ.setdefault(ENVIRONMENT_VARIABLE, "example.settings") 10 | from django.core.management import execute_from_command_line 11 | 12 | execute_from_command_line(sys.argv) 13 | -------------------------------------------------------------------------------- /radiogrid/__init__.py: -------------------------------------------------------------------------------- 1 | from .db import RadioGridField 2 | from .fields import RadioGridFormField 3 | from .widgets import RadioGridWidget 4 | 5 | __version__ = "1.1.0" 6 | -------------------------------------------------------------------------------- /radiogrid/compat.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django import VERSION 4 | 5 | 6 | def get_val_from_obj(self, obj): 7 | if VERSION < (2, 0): 8 | return self._get_val_from_obj(obj) 9 | if obj is not None: 10 | return getattr(obj, self.attname) 11 | else: 12 | return self.get_default() 13 | -------------------------------------------------------------------------------- /radiogrid/db.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.core.exceptions import ValidationError 4 | from django.db.models import TextField 5 | 6 | from .compat import get_val_from_obj 7 | from .fields import RadioGridFormField 8 | 9 | 10 | class RadioGridField(TextField): 11 | def __init__(self, *args, **kwargs): 12 | self.rows = kwargs.pop("rows") 13 | self.values = kwargs.pop("values") 14 | self.require_all_fields = kwargs.pop("require_all_fields", True) 15 | self.verbose_name = kwargs.get("verbose_name", "") 16 | super(RadioGridField, self).__init__(*args, **kwargs) 17 | 18 | def deconstruct(self): 19 | name, path, args, kwargs = super(RadioGridField, self).deconstruct() 20 | kwargs["rows"] = self.rows 21 | kwargs["values"] = self.values 22 | kwargs["require_all_fields"] = self.require_all_fields 23 | return name, path, args, kwargs 24 | 25 | def from_db_value(self, value, *args, **kwargs): 26 | return self.to_python(value) 27 | 28 | def to_python(self, value): 29 | return value if isinstance(value, list) else value.split(",") 30 | 31 | def get_prep_value(self, value): 32 | return "" if value is None else ",".join(value) 33 | 34 | def formfield(self, **kwargs): 35 | defaults = { 36 | "rows": self.rows, 37 | "values": self.values, 38 | "require_all_fields": self.require_all_fields, 39 | "label": self.verbose_name, 40 | "required": self.require_all_fields, 41 | } 42 | defaults.update(kwargs) 43 | return RadioGridFormField(**defaults) 44 | 45 | def value_to_string(self, obj): 46 | return self.get_prep_value(get_val_from_obj(self, obj)) 47 | 48 | def validate(self, value, model_instance): 49 | allowed_values = [str(key) for key, _ in self.values] 50 | if not self.require_all_fields: 51 | allowed_values += [""] 52 | 53 | for v in value: 54 | if str(v) not in allowed_values: 55 | raise ValidationError( 56 | self.error_messages["invalid_choice"] % {"value": value} 57 | ) 58 | 59 | def contribute_to_class(self, cls, name, **kwargs): 60 | super(RadioGridField, self).contribute_to_class(cls, name, **kwargs) 61 | 62 | def get_list(obj): 63 | values = dict(self.values) 64 | display = [] 65 | for value in getattr(obj, name): 66 | item = values.get(value, None) 67 | if item is None: 68 | try: 69 | item = values.get(int(value), value) 70 | except (ValueError, TypeError): 71 | item = value 72 | display.append(item) 73 | return display 74 | 75 | def get_display(obj): 76 | return ", ".join(get_list(obj)) 77 | 78 | setattr(cls, "get_%s_list" % self.name, get_list) 79 | setattr(cls, "get_%s_display" % self.name, get_display) 80 | -------------------------------------------------------------------------------- /radiogrid/fields.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.forms import ChoiceField, MultiValueField 4 | 5 | from .widgets import RadioGridWidget 6 | 7 | 8 | class RadioGridFormField(MultiValueField): 9 | def __init__(self, rows, values, *args, **kwargs): 10 | kwargs["widget"] = RadioGridWidget(rows, values) 11 | kwargs["fields"] = [ChoiceField(choices=values, required=False) for _ in rows] 12 | super(RadioGridFormField, self).__init__(*args, **kwargs) 13 | 14 | def compress(self, data_list): 15 | return "" if data_list is None else ",".join(data_list) 16 | -------------------------------------------------------------------------------- /radiogrid/templates/radiogrid/radiogrid_input.html: -------------------------------------------------------------------------------- 1 | {% with id=widget.attrs.id %} 2 | {% for _, options, _ in tr.optgroups %} 3 | {% for option in options %} 4 | {% include option.template_name with widget=option %} 5 | {% endfor %} 6 | {% endfor %} 7 | {% endwith %} 8 | -------------------------------------------------------------------------------- /radiogrid/templates/radiogrid/radiogrid_widget.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% for value, name in values %} 6 | 7 | {% endfor %} 8 | 9 | 10 | 11 | {% for name, tr in rows %} 12 | 13 | 16 | {% if tr.template_name %} 17 | {% include tr.template_name %} 18 | {% else %} 19 | {{ tr }} 20 | {% endif %} 21 | 22 | {% endfor %} 23 | 24 |
 {{ name }}
14 | {{ name }} 15 |
25 | -------------------------------------------------------------------------------- /radiogrid/widgets.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.forms import MultiWidget, RadioSelect 4 | 5 | 6 | class RadioRadioSelect(RadioSelect): 7 | template_name = "radiogrid/radiogrid_input.html" 8 | 9 | 10 | class RadioGridWidget(MultiWidget): 11 | input_type = "grid" 12 | template_name = "radiogrid/radiogrid_widget.html" 13 | 14 | def __init__(self, rows, values, attrs=None): 15 | self.rows = rows 16 | self.values = values 17 | choices = [(k, "") for k, _ in values] 18 | widgets = [RadioRadioSelect(choices=choices, attrs=attrs) for _ in rows] 19 | super(RadioGridWidget, self).__init__(widgets, attrs) 20 | 21 | def get_context(self, name, value, attrs): 22 | context = super(RadioGridWidget, self).get_context(name, value, attrs) 23 | widgets = context["widget"]["subwidgets"] 24 | return { 25 | "rows": [(v[1], widgets[i]) for i, v in enumerate(self.rows)], 26 | "values": self.values, 27 | "attrs": self.attrs, 28 | } 29 | 30 | def decompress(self, value): 31 | if value: 32 | return value.split(",") 33 | return [None for _ in self.rows] 34 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sinkler/django-radiogrid/a20274adb04028fa7acb6c5f671b1a4c48a8eb4b/screenshot.png -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import re 5 | import sys 6 | 7 | from setuptools import setup 8 | 9 | if sys.version_info[0] < 3: 10 | from setuptools import find_packages as find_namespace_packages 11 | else: 12 | from setuptools import find_namespace_packages 13 | 14 | 15 | def read(filename): 16 | return open(os.path.join(os.path.dirname(__file__), filename)).read() 17 | 18 | 19 | def get_version(package): 20 | init_py = open(os.path.join(package, "__init__.py")).read() 21 | return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) 22 | 23 | 24 | setup( 25 | name="django-radiogrid", 26 | version=get_version("radiogrid"), 27 | author="Anton Shurashov", 28 | author_email="sinkler@sinkler.ru", 29 | description="Django radio grid field", 30 | long_description=(read("README.rst") + "\n\n" + read("CHANGES.rst")), 31 | long_description_content_type="text/x-rst", 32 | install_requires=["django>=1.7.0", "six>=1.16.0"], 33 | classifiers=[ 34 | "Development Status :: 5 - Production/Stable", 35 | "Framework :: Django", 36 | "License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)", 37 | "Programming Language :: Python :: 2", 38 | "Programming Language :: Python :: 3", 39 | ], 40 | license="LGPL 3", 41 | keywords="django,radio,grid,field,choices", 42 | url="https://github.com/Sinkler/django-radiogrid", 43 | packages=find_namespace_packages(), 44 | include_package_data=True, 45 | zip_safe=False, 46 | ) 47 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27-django{111}, py{37,38,39,310}-django{111,22,32,40,master} 3 | 4 | [gh-actions] 5 | python = 6 | 2.7: py27 7 | 3.7: py37 8 | 3.8: py38 9 | 3.9: py39 10 | 3.10: py310 11 | 12 | [testenv] 13 | usedevelop = True 14 | commands = 15 | {envbindir}/coverage run -p example/manage.py test example.app 16 | coverage combine 17 | coverage report 18 | coverage xml -o ./coverage.xml 19 | deps = 20 | coverage 21 | django-111: Django>=1.11rc1,<2.0 22 | django-22: Django>=2.2,<3.0 23 | django-32: Django>=3.2,<4.0 24 | django-40: Django>=4.0,<4.1 25 | django-master: https://github.com/django/django/archive/master.tar.gz 26 | --------------------------------------------------------------------------------