diff --git a/core/forms/__init__.py b/core/forms/__init__.py index b1e1156f..abff8ed9 100644 --- a/core/forms/__init__.py +++ b/core/forms/__init__.py @@ -10,6 +10,8 @@ from .legal_basis import LegalBasisForm from .publication import PublicationForm, PickPublicationForm from .share import ShareForm +from .use_restriction import UseRestrictionForm +from .access import AccessForm @@ -34,5 +36,6 @@ "PublicationForm", "ShareForm", "PickPublicationForm", - "UseRestrictionForm" + "UseRestrictionForm", + "AccessForm", ] diff --git a/core/forms/access.py b/core/forms/access.py index f43cf8d0..5caed33e 100644 --- a/core/forms/access.py +++ b/core/forms/access.py @@ -1,9 +1,10 @@ from django.forms import ModelForm, DateInput, Textarea +from core.forms.dataset import SkipFieldValidationMixin from core.models import Access -class AccessForm(ModelForm): +class AccessForm(SkipFieldValidationMixin, ModelForm): class Meta: model = Access fields = "__all__" @@ -15,15 +16,17 @@ class Meta: # Textareas "access_notes": Textarea(attrs={"rows": 2, "cols": 40}), } - + heading = "Access" + heading_help = "Please provide a help text for Access form" def __init__(self, *args, **kwargs): dataset = kwargs.pop("dataset", None) super().__init__(*args, **kwargs) # we don't allow editing dataset self.fields.pop("dataset") - self.fields["defined_on_locations"].choices = [ - (d.id, d) for d in dataset.data_locations.all() - ] + if dataset: + self.fields["defined_on_locations"].choices = [ + (d.id, d) for d in dataset.data_locations.all() + ] field_order = [ "contact", diff --git a/core/forms/data_declaration.py b/core/forms/data_declaration.py index 04554f68..52df957a 100644 --- a/core/forms/data_declaration.py +++ b/core/forms/data_declaration.py @@ -3,6 +3,7 @@ from django.shortcuts import get_object_or_404 from django.urls import reverse_lazy +from core.forms.dataset import SkipFieldValidationMixin from core.forms.use_restriction import UseRestrictionForm from core.models import DataDeclaration, Partner, Contract, GDPRRole from core.models.contract import PartnerRole @@ -169,10 +170,12 @@ def after_save(self, data_declaration): data_declaration.save() -class DataDeclarationForm(forms.ModelForm): +class DataDeclarationForm(SkipFieldValidationMixin, forms.ModelForm): class Meta: model = DataDeclaration fields = ['title'] + heading = "Data declaration" + heading_help = "Please provide a help text for the Data declaration form" def __init__(self, *args, **kwargs): self.dataset = kwargs.pop('dataset') diff --git a/core/forms/dataset.py b/core/forms/dataset.py index 1f81e9c5..7e62ef73 100644 --- a/core/forms/dataset.py +++ b/core/forms/dataset.py @@ -1,10 +1,22 @@ from django import forms +from django.core.exceptions import ObjectDoesNotExist from django.shortcuts import get_object_or_404 from django.forms import ValidationError from core.models import Dataset, User, Project from core.models.contract import Contract +class SkipFieldValidationMixin: + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['skip_wizard'] = forms.BooleanField(initial=False, required=False, widget=forms.HiddenInput()) + + def is_valid(self): + if self.data.get(f'{self.prefix}-skip_wizard') == 'True': + return True + return super().is_valid() + + class DatasetForm(forms.ModelForm): class Meta: model = Dataset @@ -13,7 +25,8 @@ class Meta: widgets = { 'comments': forms.Textarea(attrs={'rows': 2, 'cols': 40}), } - + heading = "Dataset" + heading_help = "Please provide a help text for the dataset form" def __init__(self, *args, **kwargs): dataset = None diff --git a/core/forms/legal_basis.py b/core/forms/legal_basis.py index 9b4e8f53..e62e3ca3 100644 --- a/core/forms/legal_basis.py +++ b/core/forms/legal_basis.py @@ -1,20 +1,24 @@ from django.forms import ModelForm +from core.forms.dataset import SkipFieldValidationMixin from core.models import LegalBasis -class LegalBasisForm(ModelForm): +class LegalBasisForm(SkipFieldValidationMixin,ModelForm): class Meta: model = LegalBasis fields = '__all__' exclude = [] + heading = "Data Legal Basis" + heading_help = "Please provide a title for this data declaration" def __init__(self, *args, **kwargs): dataset = kwargs.pop('dataset', None) super().__init__(*args, **kwargs) # we don't allow editing dataset self.fields.pop('dataset') - self.fields['data_declarations'].choices = [(d.id, d.title) for d in dataset.data_declarations.all()] + if dataset: + self.fields['data_declarations'].choices = [(d.id, d.title) for d in dataset.data_declarations.all()] field_order = [ 'data_declarations', diff --git a/core/forms/storage_location.py b/core/forms/storage_location.py index 8e3ae95e..1ccfa986 100644 --- a/core/forms/storage_location.py +++ b/core/forms/storage_location.py @@ -1,19 +1,24 @@ from django import forms + +from core.forms.dataset import SkipFieldValidationMixin from core.models.storage_location import DataLocation -class StorageLocationForm(forms.ModelForm): +class StorageLocationForm(SkipFieldValidationMixin, forms.ModelForm): class Meta: model = DataLocation fields = '__all__' exclude = [] + heading = "Add a new storage location" + heading_help = "Please provide a title for this storage location" def __init__(self, *args, **kwargs): dataset = kwargs.pop('dataset', None) super().__init__(*args, **kwargs) # we don't allow editing dataset self.fields.pop('dataset') - self.fields['data_declarations'].choices = [(d.id, d.title) for d in dataset.data_declarations.all()] + if dataset: + self.fields['data_declarations'].choices = [(d.id, d.title) for d in dataset.data_declarations.all()] field_order = [ 'category', diff --git a/web/static/css/dataset_wizard.css b/web/static/css/dataset_wizard.css new file mode 100644 index 00000000..a8433810 --- /dev/null +++ b/web/static/css/dataset_wizard.css @@ -0,0 +1,105 @@ +.step-wizard-list { + background: #fff; + color: #333; + list-style-type: none; + border-radius: 10px; + display: flex; + padding: 20px 10px; + position: relative; + z-index: 10; +} + +.step-wizard-item { + padding: 0 20px; + flex-basis: 0; + -webkit-box-flex: 1; + -ms-flex-positive: 1; + flex-grow: 1; + max-width: 100%; + display: flex; + flex-direction: column; + text-align: center; + min-width: 170px; + position: relative; +} + +.step-wizard-item + .step-wizard-item:after { + content: ""; + position: absolute; + left: 0; + top: 19px; + background: #023452; + width: 100%; + height: 2px; + transform: translateX(-50%); + z-index: -10; +} + +.progress-count { + height: 40px; + width: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-weight: 600; + margin: 0 auto; + position: relative; + z-index: 10; + color: transparent; +} + +.progress-count:after { + content: ""; + height: 40px; + width: 40px; + background: #023452; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + border-radius: 50%; + z-index: -10; +} + +.progress-count:before { + content: ""; + height: 10px; + width: 20px; + border-left: 3px solid #fff; + border-bottom: 3px solid #fff; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -60%) rotate(-45deg); + transform-origin: center center; +} + +.progress-label { + font-size: 14px; + font-weight: 600; + margin-top: 10px; +} + +.current-item .progress-count:before, +.current-item ~ .step-wizard-item .progress-count:before { + display: none; +} + +.current-item ~ .step-wizard-item .progress-count:after { + height: 10px; + width: 10px; +} + +.current-item ~ .step-wizard-item .progress-label { + opacity: 0.5; +} + +.current-item .progress-count:after { + background: #fff; + border: 2px solid #023452; +} + +.current-item .progress-count { + color: #023452; +} \ No newline at end of file diff --git a/web/static/js/data_declaration.js b/web/static/js/data_declaration.js new file mode 100644 index 00000000..80f249bf --- /dev/null +++ b/web/static/js/data_declaration.js @@ -0,0 +1,29 @@ +const skipInput = document.getElementById(skipInputID) +const skipButton = document.getElementById('skipButton') +if (!skipInput) { + skipButton.remove(); +} +if (skipButton) { + skipButton.addEventListener('click', function () { + if (skipInput) { + skipInput.value = 'True'; + document.getElementById('wizard-form').submit(); + } + }); +} +$(document).ready(function () { + $('input[type=radio]').change(function () { + const submit_button = $("#buttons") + $("#sub-form").remove(); + const sub_form = $("
"); + submit_button.before(sub_form); + const declaration_type = this.value; + const forms_url = dataDeclarationsAddSubFormUrl + '?declaration_type=' + declaration_type + '&dataset_id=' + dataset_id; + sub_form.load(forms_url, function () { + sub_form.find('select').select2(); + sub_form.bootstrapMaterialDesign(); + } + ) + ; + }); +}); \ No newline at end of file diff --git a/web/templates/_includes/forms.html b/web/templates/_includes/forms.html index ff4c6a99..a74a10e6 100644 --- a/web/templates/_includes/forms.html +++ b/web/templates/_includes/forms.html @@ -25,13 +25,9 @@ {% include '_includes/field.html' with field=field %} {% endfor %} {% if not hide_submit %} - {% if wizard and wizard_url_name %} -
+ {% if wizard %} +
- {% if wizard.steps.prev %} - previous step - {% endif %}
diff --git a/web/templates/datasets/dataset_wizard_form.html b/web/templates/datasets/dataset_wizard_form.html new file mode 100644 index 00000000..1b268e0a --- /dev/null +++ b/web/templates/datasets/dataset_wizard_form.html @@ -0,0 +1,69 @@ +{% extends 'layout.html' %} +{% load static %} +{% block head_end %} + {{ wizard.form.media }} + +{% endblock %} + +{% block content %} +
+
+
    + {% for step, step_verbose_name in steps_verbose_data %} +
  • + {{ forloop.counter }} + {{ step_verbose_name }} +
  • + {% endfor %} +
+
+ +
+ +
+ {% csrf_token %} + + {{ wizard.management_form }} + {% if wizard.form.forms %} + + {{ wizard.form.management_form }} + {% for form in wizard.form.forms %} + +
+

{{ form.Meta.heading }}

+

{{ form.Meta.heading_help }}

+
+ {% include '_includes/forms.html' with form=form %} + {% endfor %} + {% else %} + + +
+

{{ form.Meta.heading }}

+

{{ form.Meta.heading_help }}

+
+ {% include '_includes/forms.html' with form=wizard.form %} + {% endif %} +
+
+
+
+{% endblock %} + +{% block js %} + + +{% endblock %} diff --git a/web/templates/search/search_page.html b/web/templates/search/search_page.html index a05d230d..a23bc44d 100644 --- a/web/templates/search/search_page.html +++ b/web/templates/search/search_page.html @@ -34,7 +34,7 @@

Filter the results

{% block floating-buttons %} - + add diff --git a/web/urls.py b/web/urls.py index 4e02d282..5e76aa29 100644 --- a/web/urls.py +++ b/web/urls.py @@ -17,7 +17,7 @@ from web.views.dashboard import dashboard from web.views.data_declarations import DatadeclarationDetailView, DatadeclarationEditView from web.views.datasets import DatasetCreateView, DatasetDetailView, DatasetEditView, DatasetDelete, \ - dataset_list + dataset_list, DatasetWizardView from web.views.export import cohorts_export, contacts_export, contracts_export, \ datasets_export, partners_export, projects_export from web.views.partner import PartnerCreateView, PartnerDelete, PartnerDetailView, \ @@ -33,6 +33,8 @@ from web.views.users import add_personnel_to_project, remove_personnel_from_project from web.views.log_entry import LogEntryListView +wizard_view = DatasetWizardView.as_view(url_name='wizard_step') + web_urls = [ # Single pages path('', dashboard, name='dashboard'), @@ -75,6 +77,8 @@ path('data-dec-paginated-search', data_declarations.data_dec_paginated_search, name="data_dec_paginated_search"), # Datasets + path('wizard//', wizard_view, name='wizard_step'), + path('wizard/', wizard_view, name='wizard'), path('datasets/', dataset_list, name="datasets"), path('datasets/export', datasets_export, name="datasets_export"), path('dataset/add/', DatasetCreateView.as_view(), name='dataset_add'), diff --git a/web/views/datasets.py b/web/views/datasets.py index ede468be..fbc8fb25 100644 --- a/web/views/datasets.py +++ b/web/views/datasets.py @@ -1,9 +1,12 @@ from django.conf import settings -from django.shortcuts import render +from django.shortcuts import render, redirect from django.urls import reverse_lazy from django.views.generic import CreateView, DetailView, UpdateView, DeleteView - -from core.forms import DatasetForm +from formtools.wizard.views import NamedUrlSessionWizardView +from django.http import HttpResponseRedirect, Http404 +from core.constants import Permissions +from core.forms.storage_location import StorageLocationForm +from core.forms import DatasetForm, DataDeclarationForm, LegalBasisForm, AccessForm from core.forms.dataset import DatasetFormEdit from core.models import Dataset, Exposure from core.models.utils import COMPANY @@ -16,6 +19,62 @@ FACET_FIELDS = settings.FACET_FIELDS['dataset'] + +class DatasetWizardView(NamedUrlSessionWizardView): + template_name = "datasets/dataset_wizard_form.html" + form_list = [ + ("dataset", DatasetForm), + ("data_declaration", DataDeclarationForm), + ("storage_location", StorageLocationForm), + ("legal_basis", LegalBasisForm), + ("access", AccessForm), + ] + + def render_done(self, form, **kwargs): + if kwargs.get('step', None) != self.done_step_name: + return redirect(self.get_step_url(self.done_step_name)) + + dataset_id = self.storage.extra_data.get('dataset_id') + self.storage.reset() + if dataset_id: + done_response = HttpResponseRedirect(reverse_lazy('dataset', kwargs={'pk': dataset_id})) + else: + done_response = HttpResponseRedirect(reverse_lazy('datasets')) + return done_response + + def get_form_kwargs(self, step=None): + kwargs = super().get_form_kwargs(step) + dataset_id = self.storage.extra_data.get('dataset_id', None) + if dataset_id is not None: + kwargs['dataset'] = Dataset.objects.get(pk=dataset_id) + return kwargs + + def process_step(self, form, **kwargs): + if self.steps.current == 'dataset': + dataset = form.save() + self.storage.extra_data['dataset_id'] = dataset.id + elif form.data.get(f'{self.steps.current}-skip_wizard') != 'True': + dataset_id = self.storage.extra_data.get('dataset_id') + try: + dataset = Dataset.objects.get(pk=dataset_id) + instance = form.save(commit=False) + instance.dataset = dataset + instance.save() + except Dataset.DoesNotExist: + raise Http404(f"You need to have a dataset first") + return self.get_form_step_data(form) + + def get_context_data(self, form, **kwargs): + context = super().get_context_data(form=form, **kwargs) + context.update({'step_name': self.steps.current}) + names = [form_key.replace('_', ' ').title() for form_key, _ in self.form_list.items()] + context['steps_verbose_data'] = list(zip(self.form_list, names)) + dataset_id = self.storage.extra_data.get('dataset_id', None) + if dataset_id is not None: + context['dataset_id'] = Dataset.objects.get(pk=dataset_id).id + return context + + class DatasetCreateView(CreateView): model = Dataset template_name = 'datasets/dataset_form.html'