├── .github
└── workflows
│ └── django.yml
├── .gitignore
├── .vscode
├── launch.json
└── settings.json
├── Procfile
├── README.md
├── api
├── __init__.py
├── admin.py
├── apps.py
├── migrations
│ └── __init__.py
├── serializers.py
├── test_api.py
├── urls.py
└── views.py
├── general
├── __init__.py
├── assets
│ ├── general.js
│ └── general.scss
├── context_processors.py
├── migrations
│ ├── 0001_initial.py
│ ├── 0002_auto_20200627_1611.py
│ └── __init__.py
├── models
│ ├── __init__.py
│ ├── base.py
│ ├── customer.py
│ └── person.py
├── templates
│ └── base.html
└── test_models.py
├── manage.py
├── multistep-form.png
├── multistepform
├── __init__.py
├── admin.py
├── apps.py
├── assets
│ └── multistepform
│ │ ├── form-bg.jpg
│ │ ├── form.js
│ │ └── form.scss
├── forms
│ ├── __init__.py
│ ├── customerForm.py
│ ├── messageForm.py
│ └── surveyForm.py
├── migrations
│ ├── 0001_initial.py
│ ├── 0002_auto_20200627_1611.py
│ └── __init__.py
├── models
│ ├── __init__.py
│ ├── formMessage.py
│ └── formSurvey.py
├── templates
│ └── multistepform
│ │ ├── feedback.html
│ │ └── form.html
├── test_forms.py
├── test_models.py
├── urls.py
└── views
│ ├── __init__.py
│ ├── cancelView.py
│ └── formView.py
├── requirements.txt
└── web_project
├── __init__.py
├── asgi.py
├── settings.py
├── urls.py
└── wsgi.py
/.github/workflows/django.yml:
--------------------------------------------------------------------------------
1 | name: Django CI
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 | pull_request:
7 | branches: [ master ]
8 |
9 | jobs:
10 | build:
11 |
12 | runs-on: ubuntu-latest
13 | strategy:
14 | max-parallel: 4
15 | matrix:
16 | python-version: [3.6, 3.7, 3.8]
17 |
18 | steps:
19 | - uses: actions/checkout@v2
20 | - name: Set up Python ${{ matrix.python-version }}
21 | uses: actions/setup-python@v1
22 | with:
23 | python-version: ${{ matrix.python-version }}
24 | - name: Install Dependencies
25 | run: |
26 | python -m pip install --upgrade pip
27 | pip install -r requirements.txt
28 | - name: Run Tests
29 | run: |
30 | python manage.py test
31 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | static/
7 | staticfiles/
8 |
9 | # C extensions
10 | *.so
11 |
12 | # Distribution / packaging
13 | .Python
14 | build/
15 | develop-eggs/
16 | dist/
17 | downloads/
18 | eggs/
19 | .eggs/
20 | lib/
21 | lib64/
22 | parts/
23 | sdist/
24 | var/
25 | wheels/
26 | pip-wheel-metadata/
27 | share/python-wheels/
28 | *.egg-info/
29 | .installed.cfg
30 | *.egg
31 | MANIFEST
32 |
33 | # PyInstaller
34 | # Usually these files are written by a python script from a template
35 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
36 | *.manifest
37 | *.spec
38 |
39 | # Installer logs
40 | pip-log.txt
41 | pip-delete-this-directory.txt
42 |
43 | # Unit test / coverage reports
44 | htmlcov/
45 | .tox/
46 | .nox/
47 | .coverage
48 | .coverage.*
49 | .cache
50 | nosetests.xml
51 | coverage.xml
52 | *.cover
53 | *.py,cover
54 | .hypothesis/
55 | .pytest_cache/
56 |
57 | # Translations
58 | *.mo
59 | *.pot
60 |
61 | # Django stuff:
62 | *.log
63 | local_settings.py
64 | db.sqlite3
65 | db.sqlite3-journal
66 |
67 | # Flask stuff:
68 | instance/
69 | .webassets-cache
70 |
71 | # Scrapy stuff:
72 | .scrapy
73 |
74 | # Sphinx documentation
75 | docs/_build/
76 |
77 | # PyBuilder
78 | target/
79 |
80 | # Jupyter Notebook
81 | .ipynb_checkpoints
82 |
83 | # IPython
84 | profile_default/
85 | ipython_config.py
86 |
87 | # pyenv
88 | .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
98 | __pypackages__/
99 |
100 | # Celery stuff
101 | celerybeat-schedule
102 | celerybeat.pid
103 |
104 | # SageMath parsed files
105 | *.sage.py
106 |
107 | # Environments
108 | .env
109 | .venv
110 | env/
111 | venv/
112 | ENV/
113 | env.bak/
114 | venv.bak/
115 |
116 | # Spyder project settings
117 | .spyderproject
118 | .spyproject
119 |
120 | # Rope project settings
121 | .ropeproject
122 |
123 | # mkdocs documentation
124 | /site
125 |
126 | # mypy
127 | .mypy_cache/
128 | .dmypy.json
129 | dmypy.json
130 |
131 | # Pyre type checker
132 | .pyre/
133 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "name": "Python: Django",
9 | "type": "python",
10 | "request": "launch",
11 | "program": "${workspaceFolder}\\manage.py",
12 | "console": "integratedTerminal",
13 | "args": [
14 | "runserver",
15 | ],
16 | "django": true
17 | }
18 | ]
19 | }
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "python.pythonPath": "env\\Scripts\\python.exe",
3 | "git.ignoreLimitWarning": true
4 | }
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | release: python manage.py migrate
2 | web: gunicorn web_project.wsgi --log-file -
3 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Django multiple-steps form
live at https://django-multistep-form.herokuapp.com/
2 |
3 | This is an example of a Django application (my first experiment) implementing a form splitted through multiple screens
4 |
5 | ## Code
6 |
7 | * Global resources (for different projects) at [/general](./general): models, styles, ...
8 | * Multistep form project at [/multistepform](/multistepform); most of the multistep logic implemented in [formView.py](/multistepform/views/formView.py)
9 | * REST api (using Django Rest framework) at [/api](./api); live api [deployed at Heroku](https://django-multistep-form.herokuapp.com/api/)
10 | * Calls to get customer data using Vanilla JS: [JS module](general/assets/general.js) and [usage](/multistepform/assets/multistepform/form.js)
11 | * Compile CSS using SASS [global styles](/general/assets/general.scss) and [form styles](/multistepform/assets/multistepform/form.scss)
12 |
13 | ## Details
14 |
15 | * Temporary data are stored in session and stored to database in last step
16 | * Root url redirects to first non-filled step
17 | * Forms are automatically generated from models
18 |
19 | ### Devops
20 |
21 | * Includes some tests for [models](/general/test_models.py), [forms](/multistepform/test_forms.py), [API](/api/test_api.py), ...
22 | * [CI workflow (action) in GitHub](../../actions?query=workflow%3A%22Django+CI%22): build, tests
23 | * Automatically [deployed to Heroku](https://django-multistep-form.herokuapp.com/)
24 |
25 | ## (I'd like) To-do
26 |
27 | * Add info/placeholders for some fields
28 | * Last step showing all data before submitting
29 | * Multilanguage
30 | * Use TypeScript
31 | * Some more tests
32 |
33 |
34 | 
35 |
--------------------------------------------------------------------------------
/api/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jordisan/Django-multistep-form/2e370f5e1310885cd0763a2caea9975a611dec8d/api/__init__.py
--------------------------------------------------------------------------------
/api/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | # Register your models here.
4 |
--------------------------------------------------------------------------------
/api/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class ApiConfig(AppConfig):
5 | name = 'api'
6 |
--------------------------------------------------------------------------------
/api/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jordisan/Django-multistep-form/2e370f5e1310885cd0763a2caea9975a611dec8d/api/migrations/__init__.py
--------------------------------------------------------------------------------
/api/serializers.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 | from general.models.customer import Customer
3 | from rest_framework import serializers
4 |
5 |
6 | class CustomerSerializer(serializers.ModelSerializer):
7 | class Meta:
8 | model = Customer
9 | fields = '__all__'
10 | lookup_field = 'email'
11 |
--------------------------------------------------------------------------------
/api/test_api.py:
--------------------------------------------------------------------------------
1 | from rest_framework import status
2 | from rest_framework.test import APITestCase
3 |
4 | class TestAPI(APITestCase):
5 | def test_get_customers(self):
6 | ''' Get list of customers '''
7 | response = self.client.get('/api/customers', format='json')
8 | self.assertEqual(response.status_code, status.HTTP_200_OK)
--------------------------------------------------------------------------------
/api/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import include, path
2 | from rest_framework import routers
3 | from .views import CustomerViewSet
4 |
5 | router = routers.DefaultRouter(trailing_slash=False)
6 | router.register(r'customers', CustomerViewSet)
7 |
8 | urlpatterns = [
9 | path('', include(router.urls)),
10 | path('api-auth/', include('rest_framework.urls', namespace='rest_framework'))
11 | ]
--------------------------------------------------------------------------------
/api/views.py:
--------------------------------------------------------------------------------
1 | from rest_framework import viewsets
2 |
3 | from general.models.customer import Customer
4 | from .serializers import CustomerSerializer
5 |
6 | class CustomerViewSet(viewsets.ReadOnlyModelViewSet):
7 | queryset = Customer.objects.all()
8 | serializer_class = CustomerSerializer
9 | lookup_field = 'email'
10 | lookup_value_regex = '[^/]+'
--------------------------------------------------------------------------------
/general/__init__.py:
--------------------------------------------------------------------------------
1 | """ Global resources for different projects """
--------------------------------------------------------------------------------
/general/assets/general.js:
--------------------------------------------------------------------------------
1 | // global namespace
2 | var general = general || {};
3 |
4 | // module for API calls
5 | general.API = (function(){
6 | 'use strict';
7 |
8 | var _api_url = null;
9 |
10 | /**
11 | * Generic function to get JSON data from API
12 | * @param {string} path
13 | */
14 | function _getJson(path) {
15 | document.getElementsByTagName('body')[0].classList.add('loading')
16 | return fetch(_api_url + '/' + path)
17 | .then(function(response) {
18 | if (response.ok) {
19 | return response.json();
20 | } else {
21 | // if not found, just return null
22 | return Promise.resolve(null);
23 | }
24 | })
25 | .catch(function(err) {
26 | console.log(err);
27 | return Promise.reject();
28 | })
29 | .finally(function() {
30 | document.getElementsByTagName("body")[0].classList.remove('loading')
31 | })
32 | }
33 |
34 | /**
35 | * Get JSON customer data
36 | * @param {string} email
37 | */
38 | function _getCustomerData(email) {
39 | return _getJson('customers/' + encodeURIComponent(email));
40 | };
41 |
42 | /**
43 | * Initialize
44 | */
45 | function _init(api_url) {
46 | _api_url = api_url;
47 | };
48 |
49 | return {
50 | getJson: _getJson,
51 | getCustomerData: _getCustomerData,
52 | init: _init
53 | };
54 | })();
55 |
--------------------------------------------------------------------------------
/general/assets/general.scss:
--------------------------------------------------------------------------------
1 | // global styles (for all applications)
2 | body {
3 | font-family: 'Raleway', sans-serif;
4 | position: relative;
5 |
6 | &.loading {
7 | /* show overlay to block UI while waiting for AJAX responses */
8 | opacity: .5;
9 | &.loading::after {
10 | content: "";
11 | position: absolute;
12 | left: 0;
13 | right: 0;
14 | top: 0;
15 | bottom: 0;
16 | cursor: wait;
17 | background-color: transparent;
18 | }
19 | }
20 | }
21 |
22 | p {
23 | margin-top: 0.5rem;
24 | margin-bottom: 0.5rem;
25 | }
26 |
27 | footer {
28 | margin-top: 30px;
29 | text-align: center;
30 | font-size: 0.8rem;
31 | }
32 |
33 | .clear::before,
34 | .clear::after {
35 | clear: both;
36 | content: "";
37 | display: table;
38 | }
--------------------------------------------------------------------------------
/general/context_processors.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 |
3 |
4 | def custom_settings(request):
5 | return {
6 | 'API_URL': settings.API_URL
7 | }
--------------------------------------------------------------------------------
/general/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.0.6 on 2020-06-12 16:24
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='Person',
17 | fields=[
18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19 | ('created_at', models.DateTimeField(auto_now_add=True)),
20 | ('updated_at', models.DateTimeField(auto_now=True)),
21 | ('first_name', models.CharField(max_length=30)),
22 | ('last_name', models.CharField(max_length=100)),
23 | ],
24 | options={
25 | 'abstract': False,
26 | },
27 | ),
28 | migrations.CreateModel(
29 | name='Customer',
30 | fields=[
31 | ('person_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, to='general.Person')),
32 | ('email', models.EmailField(db_index=True, max_length=254, primary_key=True, serialize=False, unique=True)),
33 | ('phone_number', models.CharField(max_length=20)),
34 | ],
35 | options={
36 | 'abstract': False,
37 | },
38 | bases=('general.person',),
39 | ),
40 | ]
41 |
--------------------------------------------------------------------------------
/general/migrations/0002_auto_20200627_1611.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.0.6 on 2020-06-27 14:11
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ('general', '0001_initial'),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterField(
14 | model_name='customer',
15 | name='phone_number',
16 | field=models.CharField(blank=True, max_length=20),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/general/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jordisan/Django-multistep-form/2e370f5e1310885cd0763a2caea9975a611dec8d/general/migrations/__init__.py
--------------------------------------------------------------------------------
/general/models/__init__.py:
--------------------------------------------------------------------------------
1 | """ Global models (for different projects) """
--------------------------------------------------------------------------------
/general/models/base.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 | class Base(models.Model):
4 | """ Base for all models """
5 |
6 | created_at = models.DateTimeField(auto_now_add=True)
7 | updated_at = models.DateTimeField(auto_now=True)
8 |
9 | class Meta:
10 | abstract = True
--------------------------------------------------------------------------------
/general/models/customer.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 | from .person import Person
3 |
4 | class Customer(Person):
5 | """ Customer (existing or future) """
6 |
7 | email = models.EmailField(unique=True, db_index=True, primary_key=True)
8 | phone_number = models.CharField(max_length=20, blank=True) # PhoneNumberField()
--------------------------------------------------------------------------------
/general/models/person.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 | from .base import Base
3 |
4 | class Person(Base):
5 | """ Any real person """
6 |
7 | first_name = models.CharField(max_length=30)
8 | last_name = models.CharField(max_length=100)
--------------------------------------------------------------------------------
/general/templates/base.html:
--------------------------------------------------------------------------------
1 | {% load static %}
2 | {% load compress %}
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | {% compress css %}
11 |
12 |
13 | {% block styles %}
14 |
15 | {% endblock styles %}
16 | {% endcompress %}
17 |
18 |
19 | {% block title %}
20 | {{ page_title|default:"Untitled Page" }}
21 | {% endblock title %}
22 |
23 |
24 |
25 |
26 |
27 | {{ page_title }}
28 |
29 | {% block content %}
30 | Placeholder text in base template. Replace with page content.
31 | {% endblock content %}
32 |
33 |
34 |
42 |
43 |
44 |
45 |
51 |
52 | {% block scripts %}
53 |
54 | {% endblock scripts %}
55 |
56 |
--------------------------------------------------------------------------------
/general/test_models.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 | from general.models.customer import Customer
3 | from django.db.utils import IntegrityError
4 |
5 | class TestModels(TestCase):
6 | def test_no_duplicated_emails(self):
7 | ''' Make sure there can't be duplicated emails in database '''
8 |
9 | customer1 = Customer(email='test@email.com')
10 | customer1.save()
11 | customer2 = Customer(email='test@email.com')
12 | with self.assertRaises(IntegrityError, msg='allows duplicated emails'):
13 | customer2.save()
--------------------------------------------------------------------------------
/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks."""
3 | import os
4 | import sys
5 |
6 |
7 | def main():
8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'web_project.settings')
9 | try:
10 | from django.core.management import execute_from_command_line
11 | except ImportError as exc:
12 | raise ImportError(
13 | "Couldn't import Django. Are you sure it's installed and "
14 | "available on your PYTHONPATH environment variable? Did you "
15 | "forget to activate a virtual environment?"
16 | ) from exc
17 | execute_from_command_line(sys.argv)
18 |
19 |
20 | if __name__ == '__main__':
21 | main()
22 |
--------------------------------------------------------------------------------
/multistep-form.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jordisan/Django-multistep-form/2e370f5e1310885cd0763a2caea9975a611dec8d/multistep-form.png
--------------------------------------------------------------------------------
/multistepform/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jordisan/Django-multistep-form/2e370f5e1310885cd0763a2caea9975a611dec8d/multistepform/__init__.py
--------------------------------------------------------------------------------
/multistepform/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from general.models.person import Person
3 | from general.models.customer import Customer
4 | from .models.formMessage import FormMessage
5 |
6 | # Register your models here.
7 | admin.site.register(Person)
8 | admin.site.register(Customer)
9 | admin.site.register(FormMessage)
10 |
--------------------------------------------------------------------------------
/multistepform/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class MultistepformConfig(AppConfig):
5 | name = 'multistepform'
6 |
--------------------------------------------------------------------------------
/multistepform/assets/multistepform/form-bg.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jordisan/Django-multistep-form/2e370f5e1310885cd0763a2caea9975a611dec8d/multistepform/assets/multistepform/form-bg.jpg
--------------------------------------------------------------------------------
/multistepform/assets/multistepform/form.js:
--------------------------------------------------------------------------------
1 | // global namespace
2 | var general = general || {};
3 |
4 | // module for multip-step form
5 | general.Multistepform = (function(){
6 | 'use strict';
7 |
8 | var _container = null;
9 |
10 | /**
11 | * Update form data from API using email
12 | * TODO: obvious privacy issues here
13 | * @param {*} email
14 | */
15 | function _updateCustomerData(email) {
16 | general.API.getCustomerData(email).then(function(data) {
17 | if (!!data) {
18 | _container.querySelector('#id_first_name').value=data.first_name;
19 | _container.querySelector('#id_last_name').value=data.last_name;
20 | _container.querySelector('#id_phone_number').value=data.phone_number;
21 | }
22 | });
23 | }
24 |
25 | /**
26 | * Initialize form
27 | * @param {*} container
28 | */
29 | function _init(container) {
30 | _container = container;
31 | if (!! _container) {
32 | // attach listener to email field to get data from API
33 | var email_field = _container.querySelector('#id_email');
34 | if (!! email_field) {
35 | email_field.addEventListener('blur', function(e) {
36 | _updateCustomerData(e.target.value);
37 | });
38 | }
39 | }
40 | };
41 |
42 | return {
43 | init: _init
44 | };
45 | })();
46 |
--------------------------------------------------------------------------------
/multistepform/assets/multistepform/form.scss:
--------------------------------------------------------------------------------
1 | // styles only for form
2 |
3 | @import '../../../general/assets/general.scss';
4 |
5 | html {
6 | background-color: #ccc;
7 | padding: 40px 20px;
8 | }
9 |
10 | main {
11 | background-color: transparent;
12 | padding: 20px;
13 | margin: 0 auto;
14 | max-width: 800px;
15 | position: relative;
16 |
17 | &::after {
18 | content: "";
19 | background: url('form-bg.jpg');
20 | background-size: cover;
21 | background-position: right top;
22 | opacity: 0.5;
23 | top: 0;
24 | left: 0;
25 | bottom: 0;
26 | right: 0;
27 | position: absolute;
28 | z-index: -1;
29 | }
30 | }
31 |
32 | form {
33 | & > label {
34 | display: block;
35 | margin-top: 30px;
36 |
37 | &.required::after {
38 | content: '\2732';
39 | color: #dd3333;
40 | vertical-align: super;
41 | font-size: .7rem;
42 | padding-left: 5px;
43 | }
44 | }
45 |
46 | .field--helptext {
47 | opacity: .7;
48 | font-size: 0.8em;
49 | font-style: italic;
50 | padding-left: 10px;
51 | }
52 |
53 | ul, ol {
54 | /* used in radio buttons */
55 | padding-left: 1.0rem;
56 | font-size: .9rem;
57 |
58 | li {
59 | list-style-type: none;
60 | }
61 | }
62 |
63 | .form--actions {
64 | @extend .clear;
65 |
66 | margin: 50px 0 0 0;
67 | padding: 30px 0 20px;
68 | border-top: solid 2px #fff;
69 |
70 | a {
71 | text-decoration: none;
72 | }
73 |
74 | .nav--prev,
75 | .nav--next,
76 | .nav--send {
77 | text-transform: uppercase;
78 | padding: 10px;
79 | display: inline-block;
80 | }
81 |
82 | .nav--next,
83 | .nav--send {
84 | border-radius: 5px;
85 | margin-left: 20px;
86 | text-transform: uppercase;
87 | font-weight: bold;
88 | }
89 |
90 | .nav--prev::before {
91 | content: '\21E6 ';
92 | }
93 | .nav--next::after {
94 | content: ' \21E8';
95 | }
96 | .nav--send::after {
97 | content: ' \2713';
98 | }
99 |
100 |
101 | &__main {
102 | /* main form actions right floated */
103 | display: block;
104 | float: right;
105 | text-align: right;
106 | }
107 | }
108 | }
109 |
110 | nav {
111 | margin-top: 30px;
112 | margin-bottom: 30px;
113 |
114 | a {
115 | text-decoration: none;
116 | }
117 | }
118 |
119 | @media (max-width: 600px) {
120 | html {
121 | padding: 10px 5px;
122 | }
123 |
124 | input:not([type="checkbox"]):not([type="radio"]),
125 | textarea,
126 | select {
127 | width: 100%;
128 | box-sizing: border-box;
129 | }
130 |
131 | main {
132 | padding: 5px;
133 | background-color: #fff;
134 | }
135 | }
--------------------------------------------------------------------------------
/multistepform/forms/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jordisan/Django-multistep-form/2e370f5e1310885cd0763a2caea9975a611dec8d/multistepform/forms/__init__.py
--------------------------------------------------------------------------------
/multistepform/forms/customerForm.py:
--------------------------------------------------------------------------------
1 | from django import forms
2 | from general.models.customer import Customer
3 |
4 | class CustomerForm(forms.ModelForm):
5 | class Meta:
6 | model = Customer
7 | fields = ['email', 'first_name', 'last_name', 'phone_number']
8 | help_texts = {
9 | 'email': 'if email already exists, stored data will be retrieved',
10 | }
11 |
12 | def validate_unique(self):
13 | '''
14 | Do not check uniqueness of email address here as we want to allow existing emails
15 | to add new messages; we will do it at the view.
16 | This is not a 'new customer' form, but a form to provide customer data to the message.
17 | '''
18 | pass
19 |
--------------------------------------------------------------------------------
/multistepform/forms/messageForm.py:
--------------------------------------------------------------------------------
1 | from django import forms
2 | from general.models.customer import Customer
3 | from ..models.formMessage import FormMessage
4 |
5 | class MessageForm(forms.ModelForm):
6 | class Meta:
7 | model = FormMessage
8 | exclude = ('customer',) # we will assign it from CustomerForm
9 | widgets = {
10 | 'additional_message': forms.Textarea(),
11 | }
12 |
--------------------------------------------------------------------------------
/multistepform/forms/surveyForm.py:
--------------------------------------------------------------------------------
1 | from django import forms
2 | from ..models.formSurvey import FormSurvey
3 |
4 | CHOICES = ( (True,'Yes'),
5 | (False,'No'),
6 | )
7 |
8 | class SurveyForm(forms.ModelForm):
9 | are_you_happy = forms.TypedChoiceField(
10 | choices=CHOICES, widget=forms.RadioSelect
11 | )
12 | do_you_know_it = forms.TypedChoiceField(
13 | choices=CHOICES, widget=forms.RadioSelect
14 | )
15 | class Meta:
16 | model = FormSurvey
17 | exclude = ('customer',) # we will assign it from CustomerForm
18 |
19 |
--------------------------------------------------------------------------------
/multistepform/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.0.6 on 2020-06-12 16:24
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 | ('general', '__first__'),
13 | ]
14 |
15 | operations = [
16 | migrations.CreateModel(
17 | name='FormMessage',
18 | fields=[
19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20 | ('created_at', models.DateTimeField(auto_now_add=True)),
21 | ('updated_at', models.DateTimeField(auto_now=True)),
22 | ('subject', models.CharField(max_length=30)),
23 | ('message', models.CharField(max_length=500)),
24 | ('customer', models.ForeignKey(db_column='email', on_delete=django.db.models.deletion.CASCADE, to='general.Customer')),
25 | ],
26 | options={
27 | 'abstract': False,
28 | },
29 | ),
30 | ]
31 |
--------------------------------------------------------------------------------
/multistepform/migrations/0002_auto_20200627_1611.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.0.6 on 2020-06-27 14:11
2 |
3 | from django.db import migrations, models
4 | import django.db.models.deletion
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | dependencies = [
10 | ('general', '0002_auto_20200627_1611'),
11 | ('multistepform', '0001_initial'),
12 | ]
13 |
14 | operations = [
15 | migrations.RemoveField(
16 | model_name='formmessage',
17 | name='message',
18 | ),
19 | migrations.RemoveField(
20 | model_name='formmessage',
21 | name='subject',
22 | ),
23 | migrations.AddField(
24 | model_name='formmessage',
25 | name='additional_message',
26 | field=models.CharField(blank=True, max_length=500),
27 | ),
28 | migrations.CreateModel(
29 | name='FormSurvey',
30 | fields=[
31 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
32 | ('created_at', models.DateTimeField(auto_now_add=True)),
33 | ('updated_at', models.DateTimeField(auto_now=True)),
34 | ('are_you_happy', models.BooleanField()),
35 | ('do_you_know_it', models.BooleanField()),
36 | ('customer', models.ForeignKey(db_column='email', on_delete=django.db.models.deletion.CASCADE, to='general.Customer')),
37 | ],
38 | options={
39 | 'abstract': False,
40 | },
41 | ),
42 | ]
43 |
--------------------------------------------------------------------------------
/multistepform/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jordisan/Django-multistep-form/2e370f5e1310885cd0763a2caea9975a611dec8d/multistepform/migrations/__init__.py
--------------------------------------------------------------------------------
/multistepform/models/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jordisan/Django-multistep-form/2e370f5e1310885cd0763a2caea9975a611dec8d/multistepform/models/__init__.py
--------------------------------------------------------------------------------
/multistepform/models/formMessage.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 | from general.models.base import Base
3 | from general.models.customer import Customer
4 |
5 | class FormMessage(Base):
6 | """ Message from customer """
7 |
8 | additional_message = models.CharField(max_length=500, blank=True)
9 | customer = models.ForeignKey(Customer, on_delete=models.CASCADE, db_column='email')
10 |
--------------------------------------------------------------------------------
/multistepform/models/formSurvey.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 | from general.models.base import Base
3 | from general.models.customer import Customer
4 |
5 | class FormSurvey(Base):
6 | """ Ask some questions """
7 |
8 | are_you_happy = models.BooleanField()
9 | do_you_know_it = models.BooleanField()
10 | customer = models.ForeignKey(Customer, on_delete=models.CASCADE, db_column='email')
11 |
--------------------------------------------------------------------------------
/multistepform/templates/multistepform/feedback.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 | {% load static %}
3 |
4 | {% block styles %}
5 |
6 | {% endblock styles %}
7 |
8 | {% block content %}
9 | {{ msg }}
10 |
11 |
14 | {% endblock content %}
--------------------------------------------------------------------------------
/multistepform/templates/multistepform/form.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 | {% load static %}
3 |
4 | {% block styles %}
5 |
6 | {% endblock styles %}
7 |
8 | {% block content %}
9 |
56 | {% endblock content %}
57 |
58 | {% block scripts %}
59 |
60 |
66 | {% endblock scripts %}
--------------------------------------------------------------------------------
/multistepform/test_forms.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 | from .forms.surveyForm import SurveyForm
3 | from .forms.messageForm import MessageForm
4 | from .forms.customerForm import CustomerForm
5 |
6 | class TestForms(TestCase):
7 |
8 | def test_non_empty(self):
9 | ''' Make sure empty forms aren't allowed '''
10 |
11 | form = SurveyForm()
12 | self.assertFalse(form.is_valid(), msg='empty messages allowed')
13 | form = CustomerForm()
14 | self.assertFalse(form.is_valid(), msg='empty customer allowed')
15 |
16 | def test_valid_data(self):
17 | ''' Make sure valid data is accepted '''
18 |
19 | form_data = {
20 | 'additional_message': 'test message'
21 | }
22 | form = MessageForm(form_data)
23 | self.assertTrue(form.is_valid(), 'unable to validate message data')
24 |
25 |
26 |
--------------------------------------------------------------------------------
/multistepform/test_models.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 | from django.db.utils import IntegrityError
3 | from .models.formMessage import FormMessage
4 |
5 | class TestModels(TestCase):
6 | def test_no_empty_customer(self):
7 | ''' Make sure there can't be a message without customer '''
8 |
9 | message = FormMessage(
10 | additional_message = 'test message'
11 | )
12 | with self.assertRaises(IntegrityError, msg='message without customer should not be allowed'):
13 | message.save()
--------------------------------------------------------------------------------
/multistepform/urls.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.urls import path
3 | from multistepform.views.formView import FormView
4 | from multistepform.views.cancelView import CancelView
5 |
6 | urlpatterns = [
7 | path('', FormView, {'step': None}),
8 | path('step/', FormView),
9 | path('cancel', CancelView),
10 | ]
11 |
12 | if settings.DEBUG:
13 | # test mode
14 | from django.conf.urls.static import static
15 | urlpatterns += static(settings.STATIC_URL,
16 | document_root=settings.STATIC_ROOT)
17 | # urlpatterns += static(settings.MEDIA_URL,
18 | # document_root=settings.MEDIA_ROOT)
--------------------------------------------------------------------------------
/multistepform/views/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jordisan/Django-multistep-form/2e370f5e1310885cd0763a2caea9975a611dec8d/multistepform/views/__init__.py
--------------------------------------------------------------------------------
/multistepform/views/cancelView.py:
--------------------------------------------------------------------------------
1 | from django.http import HttpResponse
2 | from django.shortcuts import render
3 |
4 | def CancelView(request):
5 | ''' View after finishing process '''
6 | # remove all data stored in session
7 | request.session.flush()
8 | return render(request, 'multistepform/feedback.html', {
9 | 'page_title': 'Cancelled',
10 | 'msg': 'All your data has been removed. Thanks.'
11 | })
12 |
13 |
--------------------------------------------------------------------------------
/multistepform/views/formView.py:
--------------------------------------------------------------------------------
1 | from django.http import HttpResponse
2 | from django.shortcuts import render
3 | from django.forms.models import model_to_dict
4 | from django.shortcuts import redirect
5 |
6 | from ..forms.messageForm import MessageForm
7 | from ..forms.customerForm import CustomerForm
8 | from ..forms.surveyForm import SurveyForm
9 |
10 | from general.models.customer import Customer
11 |
12 | SESSIONKEY_PREFIX = 'multistepform_step_'
13 |
14 | STEPS = {
15 | 1: {
16 | 'form': 'SurveyForm'
17 | },
18 | 2: {
19 | 'form': 'MessageForm'
20 | },
21 | 3: {
22 | 'form': 'CustomerForm'
23 | }
24 | }
25 |
26 | def __getSessionData(request, step):
27 | ''' Get session data for a step '''
28 | return request.session.get(SESSIONKEY_PREFIX + str(step))
29 |
30 | def __getFormData(request, step):
31 | ''' Get form data stored in session, or empty otherwise '''
32 | return globals()[STEPS[step]['form']](__getSessionData(request, step))
33 |
34 | def __setFormData(request, step, data):
35 | ''' Store form data in session '''
36 | request.session[SESSIONKEY_PREFIX + str(step)] = data
37 |
38 | def __getNextStep(request):
39 | ''' Try to get first step not completed by user '''
40 | for i in range(1, len(STEPS)):
41 | if __getSessionData(request, i) == None:
42 | return i
43 | return len(STEPS) # there's data in all steps => go to last step
44 |
45 |
46 | def FormView(request, step):
47 | ''' View for multiple steps form '''
48 |
49 | if step == None:
50 | # no step in url => check previously stored data and redirect to non-completed step
51 | step = __getNextStep(request)
52 | return redirect('/step/' + str(step))
53 |
54 | form = globals()[STEPS[step]['form']]() # default form for current step
55 |
56 | if request.method == 'POST':
57 | if step == len(STEPS):
58 | # last step => save in database
59 |
60 | form = globals()[STEPS[step]['form']](request.POST)
61 | # check that user is not already on the database
62 | if form.is_valid():
63 | existing_customers = Customer.objects.filter(email=form.instance.email)
64 | if existing_customers.count() > 0:
65 | # customer already exists => update data
66 | existing_customer = existing_customers[0]
67 | existing_customer.first_name = form.instance.first_name
68 | existing_customer.last_name = form.instance.last_name
69 | existing_customer.phone_number = form.instance.phone_number
70 | form.instance = existing_customer
71 | form.instance.save()
72 | else:
73 | form.save() # save new customer
74 |
75 | # retrieve previous forms (stored in session) and save them
76 | for i in range(1, len(STEPS)):
77 | form_stored = __getFormData(request, i)
78 | form_stored.instance.customer = form.instance # set saved customer in related entities
79 | form_stored.save()
80 |
81 | request.session.flush() # remove session data
82 | return render(request, 'multistepform/feedback.html', {
83 | 'page_title': 'Thanks',
84 | 'msg': 'Your data has been saved. We will contact you soon.'
85 | })
86 |
87 | else:
88 | # not last step => store data in session (temporarily) using model_to_dict to make it serializable
89 |
90 | form = globals()[STEPS[step]['form']](request.POST)
91 | if form.is_valid():
92 | __setFormData(request, step, model_to_dict(form.instance))
93 | return redirect('/step/' + str(step + 1))
94 |
95 | else:
96 | # GET => try to get data from session (in case it was previously stored)
97 | form = __getFormData(request, step)
98 |
99 | return render(request, 'multistepform/form.html', {
100 | 'page_title': 'Multiple steps form (' + str(step) + '/' + str(len(STEPS)) + ')',
101 | 'form': form,
102 | 'step': step,
103 | 'step_last': len(STEPS)
104 | })
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | asgiref==3.2.7
2 | astroid==2.4.1
3 | Babel==2.8.0
4 | colorama==0.4.3
5 | dj-database-url==0.5.0
6 | Django==3.0.7
7 | django-appconf==1.0.4
8 | django-compressor==2.4
9 | django-heroku==0.3.1
10 | django-libsass==0.8
11 | django-phonenumber-field==4.0.0
12 | djangorestframework==3.11.0
13 | gunicorn==20.0.4
14 | isort==4.3.21
15 | lazy-object-proxy==1.4.3
16 | libsass==0.20.0
17 | mccabe==0.6.1
18 | phonenumberslite==8.12.5
19 | psycopg2==2.8.5
20 | pylint==2.5.2
21 | pytz==2020.1
22 | rcssmin==1.0.6
23 | rjsmin==1.1.0
24 | six==1.14.0
25 | sqlparse==0.3.1
26 | toml==0.10.0
27 | whitenoise==5.1.0
28 | wrapt==1.12.1
29 |
--------------------------------------------------------------------------------
/web_project/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jordisan/Django-multistep-form/2e370f5e1310885cd0763a2caea9975a611dec8d/web_project/__init__.py
--------------------------------------------------------------------------------
/web_project/asgi.py:
--------------------------------------------------------------------------------
1 | """
2 | ASGI config for web_project project.
3 |
4 | It exposes the ASGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.asgi import get_asgi_application
13 |
14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'web_project.settings')
15 |
16 | application = get_asgi_application()
17 |
--------------------------------------------------------------------------------
/web_project/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | Django settings for web_project project.
3 |
4 | Generated by 'django-admin startproject' using Django 3.0.6.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/3.0/topics/settings/
8 |
9 | For the full list of settings and their values, see
10 | https://docs.djangoproject.com/en/3.0/ref/settings/
11 | """
12 |
13 | import os
14 | import django_heroku
15 |
16 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
17 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
18 |
19 |
20 | # Quick-start development settings - unsuitable for production
21 | # See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/
22 |
23 | # SECURITY WARNING: keep the secret key used in production secret!
24 | SECRET_KEY = 'od*(s3!p^1he%mp39i7_dw1u-h&)36*b7wlvxkut4r$1!0&!u*'
25 |
26 | # SECURITY WARNING: don't run with debug turned on in production!
27 | DEBUG = True
28 |
29 | ALLOWED_HOSTS = ['127.0.0.1', 'localhost', 'django-demo-jordisan.herokuapp.com']
30 |
31 |
32 | # Application definition
33 |
34 | INSTALLED_APPS = [
35 | 'django.contrib.admin',
36 | 'django.contrib.auth',
37 | 'django.contrib.contenttypes',
38 | 'django.contrib.sessions',
39 | 'django.contrib.messages',
40 | 'django.contrib.staticfiles',
41 | 'compressor',
42 | 'rest_framework',
43 | 'general',
44 | 'api',
45 | 'multistepform',
46 | ]
47 |
48 | MIDDLEWARE = [
49 | 'django.middleware.security.SecurityMiddleware',
50 | 'django.contrib.sessions.middleware.SessionMiddleware',
51 | 'django.middleware.common.CommonMiddleware',
52 | 'django.middleware.csrf.CsrfViewMiddleware',
53 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
54 | 'django.contrib.messages.middleware.MessageMiddleware',
55 | 'django.middleware.clickjacking.XFrameOptionsMiddleware',
56 | ]
57 |
58 | ROOT_URLCONF = 'web_project.urls'
59 |
60 | TEMPLATES = [
61 | {
62 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
63 | 'DIRS': [
64 | os.path.join(BASE_DIR, 'general/templates'),
65 | os.path.join(BASE_DIR, 'multistepform/templates')
66 | ],
67 | 'APP_DIRS': True,
68 | 'OPTIONS': {
69 | 'context_processors': [
70 | 'django.template.context_processors.debug',
71 | 'django.template.context_processors.request',
72 | 'django.contrib.auth.context_processors.auth',
73 | 'django.contrib.messages.context_processors.messages',
74 | 'general.context_processors.custom_settings',
75 | ],
76 | },
77 | },
78 | ]
79 |
80 | WSGI_APPLICATION = 'web_project.wsgi.application'
81 |
82 |
83 | # Database
84 | # https://docs.djangoproject.com/en/3.0/ref/settings/#databases
85 |
86 | DATABASES = {
87 | 'default': {
88 | 'ENGINE': 'django.db.backends.sqlite3',
89 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
90 | }
91 | }
92 |
93 |
94 | # Password validation
95 | # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators
96 |
97 | AUTH_PASSWORD_VALIDATORS = [
98 | {
99 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
100 | },
101 | {
102 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
103 | },
104 | {
105 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
106 | },
107 | {
108 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
109 | },
110 | ]
111 |
112 |
113 | # Internationalization
114 | # https://docs.djangoproject.com/en/3.0/topics/i18n/
115 |
116 | LANGUAGE_CODE = 'en-us'
117 |
118 | TIME_ZONE = 'UTC'
119 |
120 | USE_I18N = True
121 |
122 | USE_L10N = True
123 |
124 | USE_TZ = True
125 |
126 |
127 | # Static files (CSS, JavaScript, Images)
128 | # https://docs.djangoproject.com/en/3.0/howto/static-files/
129 |
130 | STATIC_URL = '/static/'
131 |
132 | STATIC_ROOT = 'static'
133 |
134 | STATICFILES_DIRS = [
135 | os.path.join(BASE_DIR, 'general/assets'),
136 | os.path.join(BASE_DIR, 'multistepform/assets'),
137 | ]
138 |
139 | STATICFILES_FINDERS = (
140 | 'django.contrib.staticfiles.finders.FileSystemFinder',
141 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
142 | # other finders..
143 | 'compressor.finders.CompressorFinder',
144 | )
145 |
146 | COMPRESS_PRECOMPILERS = (
147 | ('text/x-scss', 'django_libsass.SassCompiler'),
148 | )
149 |
150 | # URL for REST API
151 | API_URL = '/api'
152 |
153 | # Activate Django-Heroku.
154 | django_heroku.settings(locals(), test_runner=False)
--------------------------------------------------------------------------------
/web_project/urls.py:
--------------------------------------------------------------------------------
1 | """web_project URL Configuration
2 |
3 | The `urlpatterns` list routes URLs to views. For more information please see:
4 | https://docs.djangoproject.com/en/3.0/topics/http/urls/
5 | Examples:
6 | Function views
7 | 1. Add an import: from my_app import views
8 | 2. Add a URL to urlpatterns: path('', views.home, name='home')
9 | Class-based views
10 | 1. Add an import: from other_app.views import Home
11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
12 | Including another URLconf
13 | 1. Import the include() function: from django.urls import include, path
14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
15 | """
16 | from django.contrib import admin
17 | from django.urls import path, include
18 |
19 | urlpatterns = [
20 | path("", include("multistepform.urls")),
21 | path("api/", include("api.urls")),
22 | path('admin/', admin.site.urls),
23 | ]
24 |
--------------------------------------------------------------------------------
/web_project/wsgi.py:
--------------------------------------------------------------------------------
1 | """
2 | WSGI config for web_project project.
3 |
4 | It exposes the WSGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/3.0/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', 'web_project.settings')
15 |
16 | application = get_wsgi_application()
17 |
--------------------------------------------------------------------------------