diff --git a/interventions/views.py b/interventions/views.py index 41a4470c7..1c4c81cca 100644 --- a/interventions/views.py +++ b/interventions/views.py @@ -4,6 +4,8 @@ from main.views import CreateView, UpdateView, AllowErrorsOnBackbuttonMixin from studies.models import Study from studies.utils import get_study_progress +from studies.mixins import StudyFromURLMixin +from proposals.mixins import StepperContextMixin from .forms import InterventionForm from .models import Intervention @@ -12,7 +14,7 @@ ############################## # CRUD actions on Intervention ############################## -class InterventionMixin(object): +class InterventionMixin(StepperContextMixin): """Mixin for an Intervention, to use in both InterventionCreate and InterventionUpdate below""" model = Intervention @@ -48,6 +50,9 @@ def get_next_url(self): next_url = "tasks:session_start" return reverse(next_url, args=(pk,)) + def get_proposal(self): + return self.get_object().study.proposal + def get_back_url(self): return reverse("studies:design", args=(self.get_study().pk,)) @@ -55,7 +60,12 @@ def get_study(self): raise NotImplementedError -class InterventionCreate(InterventionMixin, AllowErrorsOnBackbuttonMixin, CreateView): +class InterventionCreate( + StudyFromURLMixin, + InterventionMixin, + AllowErrorsOnBackbuttonMixin, + CreateView, +): """Creates a Intervention from a InterventionForm""" def form_valid(self, form): @@ -63,10 +73,6 @@ def form_valid(self, form): form.instance.study = self.get_study() return super(InterventionCreate, self).form_valid(form) - def get_study(self): - """Retrieves the Study from the pk kwarg""" - return Study.objects.get(pk=self.kwargs["pk"]) - class InterventionUpdate(InterventionMixin, AllowErrorsOnBackbuttonMixin, UpdateView): """Updates a Intervention from an InterventionForm""" diff --git a/main/templates/base/fetc_form_base.html b/main/templates/base/fetc_form_base.html index 3d5a0d97d..fe713b8a2 100644 --- a/main/templates/base/fetc_form_base.html +++ b/main/templates/base/fetc_form_base.html @@ -9,7 +9,7 @@ {# todo: responsive design #}
{% block sidebar %} - {% include "base/stepper.html" %} + {% include stepper %} {% endblock %}
diff --git a/main/templates/base/stepper.html b/main/templates/base/stepper.html index b16334bbe..3895fc871 100644 --- a/main/templates/base/stepper.html +++ b/main/templates/base/stepper.html @@ -3,21 +3,17 @@
-{% endif %} \ No newline at end of file +{% endif %} diff --git a/proposals/templates/proposals/pre_approved_form.html b/proposals/templates/proposals/pre_approved_form.html index 75aec84e5..676ab1b44 100644 --- a/proposals/templates/proposals/pre_approved_form.html +++ b/proposals/templates/proposals/pre_approved_form.html @@ -9,4 +9,4 @@ {% block pre-form-text %}

{% trans "Informatie over eerdere toesting" %}

-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/proposals/templates/proposals/researcher_form.html b/proposals/templates/proposals/researcher_form.html index f9c0bf2af..588bff4b3 100644 --- a/proposals/templates/proposals/researcher_form.html +++ b/proposals/templates/proposals/researcher_form.html @@ -22,4 +22,4 @@ {% block pre-form-text %}

{% trans "Informatie over de onderzoeker" %}

-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/proposals/utils/checkers.py b/proposals/utils/checkers.py new file mode 100644 index 000000000..d0ba93ce6 --- /dev/null +++ b/proposals/utils/checkers.py @@ -0,0 +1,828 @@ +from django.utils.translation import gettext as _ +from django.urls import reverse + +from proposals import forms as proposal_forms +from studies import forms as study_forms +from interventions import forms as intervention_forms +from observations import forms as observation_forms +from tasks import forms as tasks_forms + +from tasks.views import task_views, session_views +from tasks.models import Task, Session + +from .stepper_helpers import ( + Checker, + PlaceholderItem, + StepperItem, + ContainerItem, + ModelFormChecker, + ModelFormItem, + UpdateOrCreateChecker, +) + + +class ProposalTypeChecker( + Checker, +): + + def check(self): + # TODO: check stepper.proposal_type_hint + # and proposal.is_pre_approved etc. for non-standard layouts + return self.regular_proposal() + + def regular_proposal(self): + from .stepper import RegularProposalLayout + + self.stepper.layout = RegularProposalLayout + return [ProposalCreateChecker] + + +class BasicDetailsItem( + ContainerItem, +): + title = _("Basisgegevens") + location = "create" + + +class ProposalCreateChecker( + ModelFormChecker, +): + title = _("Start") + form_class = proposal_forms.ProposalForm + + def get_url(self): + if self.proposal.pk: + return reverse( + "proposals:update", + args=[self.proposal.pk], + ) + return reverse( + "proposals:create", + ) + + def check(self): + self.parent = BasicDetailsItem(self.stepper) + self.stepper.items.append(self.parent) + if self.proposal.pk: + return self.proposal_exists() + return self.new_proposal() + + def new_proposal(self): + self.stepper.items.append(self.make_stepper_item()) + placeholders = [ + PlaceholderItem( + self.stepper, + title=_("Onderzoeker"), + parent=self.parent, + ), + PlaceholderItem( + self.stepper, + title=_("Andere onderzoekers"), + parent=self.parent, + ), + PlaceholderItem( + self.stepper, + title=_("Financiering"), + parent=self.parent, + ), + PlaceholderItem( + self.stepper, + title=_("Onderzoeksdoel"), + parent=self.parent, + ), + ] + self.stepper.items += placeholders + return [] + + def proposal_exists(self): + stepper_item = ModelFormItem( + self.stepper, + title=self.title, + parent=self.parent, + form_object=self.proposal, + form_class=self.form_class, + url_func=self.get_url, + ) + self.stepper.items.append( + stepper_item, + ) + return [ + ResearcherChecker( + self.stepper, + parent=self.parent, + ) + ] + + +class ResearcherChecker( + ModelFormChecker, +): + title = _("Onderzoeker") + form_class = proposal_forms.ResearcherForm + + def check(self): + self.stepper.items.append(self.make_stepper_item()) + return [OtherResearchersChecker(self.stepper, parent=self.parent)] + + def get_url(self): + return reverse( + "proposals:researcher", + args=(self.proposal.pk,), + ) + + +class OtherResearchersChecker( + ModelFormChecker, +): + title = _("Andere onderzoekers") + form_class = proposal_forms.OtherResearchersForm + + def check(self): + self.stepper.items.append(self.make_stepper_item()) + return [ + FundingChecker( + self.stepper, + parent=self.parent, + ) + ] + + def get_url(self): + return reverse( + "proposals:other_researchers", + args=(self.proposal.pk,), + ) + + +class FundingChecker( + ModelFormChecker, +): + title = _("Financiering") + form_class = proposal_forms.FundingForm + + def check(self): + self.stepper.items.append(self.make_stepper_item()) + return [ + GoalChecker( + self.stepper, + parent=self.parent, + ) + ] + + def get_url(self): + return reverse( + "proposals:funding", + args=(self.proposal.pk,), + ) + + +class GoalChecker( + ModelFormChecker, +): + title = _("Onderzoeksdoel") + form_class = proposal_forms.ResearchGoalForm + + def check(self): + self.stepper.items.append(self.make_stepper_item()) + return [WMOChecker] + + def get_url(self): + return reverse( + "proposals:research_goal", + args=(self.proposal.pk,), + ) + + +class WMOItem( + StepperItem, +): + location = "wmo" + title = _("WMO") + + def __init__( + self, + *args, + **kwargs, + ): + super().__init__(*args, **kwargs) + self.proposal = self.stepper.proposal + self.wmo = self.get_wmo() + + def get_wmo( + self, + ): + if hasattr( + self.proposal, + "wmo", + ): + return self.proposal.wmo + return None + + def get_url( + self, + ): + if self.wmo: + return reverse( + "proposals:wmo_update", + args=[self.wmo.pk], + ) + else: + return reverse( + "proposals:wmo_create", + args=[self.proposal.pk], + ) + + def wmo_exists( + self, + ): + return hasattr( + self.proposal, + "wmo", + ) + + +class WMOChecker( + Checker, +): + + def check( + self, + ): + self.item = WMOItem(self.stepper) + self.stepper.items.append(self.item) + if self.item.wmo: + return self.check_wmo() + else: + return [] + + def check_wmo( + self, + ): + """ + This method should check the correctness of the + WMO object. + """ + # Just assume any WMO is correct as long as it exists + if self.item.wmo: + return [TrajectoriesChecker] + return [] # TODO next item + + +class TrajectoriesChecker( + ModelFormChecker, +): + form_class = proposal_forms.StudyStartForm + title = _("Trajecten") + location = "studies" + + def check( + self, + ): + self.item = self.make_stepper_item() + self.stepper.items.append(self.item) + sub_items = [self.make_study_checker(s) for s in self.get_studies()] + return sub_items + self.remaining_checkers() + + def make_study_checker(self, study): + return StudyChecker( + self.stepper, + parent=self.item, + study=study, + ) + + def remaining_checkers( + self, + ): + return [ + DocumentsChecker, + DataManagementChecker, + SubmitChecker, + ] + + def get_studies( + self, + ): + proposal = self.stepper.proposal + return list( + proposal.study_set.all(), + ) + + def get_url( + self, + ): + return reverse( + "proposals:study_start", + args=[self.proposal.pk], + ) + + +class StudyChecker( + Checker, +): + def __init__(self, *args, **kwargs): + self.study = kwargs.pop("study") + return super().__init__(*args, **kwargs) + + def check( + self, + ): + self.current_parent = self.parent + checkers = [] + if self.stepper.has_multiple_studies(): + # Create an intermediate container item per study, + # if we have more than one + self.current_parent = ContainerItem( + stepper=self.stepper, + title=self.study.name, + parent=self.parent, + ) + self.stepper.items.append(self.current_parent) + # We always have a Participants and StudyDesign item + checkers = [ + ParticipantsChecker( + self.stepper, + study=self.study, + parent=self.current_parent, + ), + DesignChecker( + self.stepper, + study=self.study, + parent=self.current_parent, + ), + ] + end_checker = StudyEndChecker( + self.stepper, + study=self.study, + parent=self.current_parent, + ) + return checkers + self.determine_study_checkers(self.study) + [end_checker] + + def determine_study_checkers(self, study): + tests = { + self.make_intervention: lambda s: s.has_intervention, + self.make_observation: lambda s: s.has_observation, + self.make_sessions: lambda s: s.has_sessions, + } + optional_checkers = [ + maker(study) for maker, test in tests.items() if test(study) + ] + return optional_checkers + + def make_intervention(self, study): + return InterventionChecker( + self.stepper, + study=study, + parent=self.current_parent, + ) + + def make_observation(self, study): + return ObservationChecker( + self.stepper, + study=study, + parent=self.current_parent, + ) + + def make_sessions(self, study): + return SessionsChecker( + self.stepper, + study=study, + parent=self.current_parent, + ) + + +class ParticipantsChecker( + ModelFormChecker, +): + title = _("Deelnemers") + form_class = study_forms.StudyForm + + def __init__( + self, + *args, + **kwargs, + ): + self.study = kwargs.pop("study") + return super().__init__(*args, **kwargs) + + def check( + self, + ): + self.stepper.items.append(self.make_stepper_item()) + return [] + + def get_url( + self, + ): + return reverse( + "studies:update", + args=[ + self.study.pk, + ], + ) + + +class DesignChecker( + ModelFormChecker, +): + title = _("Ontwerp") + form_class = study_forms.StudyDesignForm + + def __init__( + self, + *args, + **kwargs, + ): + self.study = kwargs.pop("study") + return super().__init__(*args, **kwargs) + + def check( + self, + ): + self.stepper.items.append(self.make_stepper_item()) + return [] + + def get_url( + self, + ): + return reverse( + "studies:design", + args=[ + self.study.pk, + ], + ) + + +class StudyEndChecker( + ModelFormChecker, +): + title = _("Afronding") + form_class = study_forms.StudyEndForm + + def __init__( + self, + *args, + **kwargs, + ): + self.study = kwargs.pop("study") + return super().__init__(*args, **kwargs) + + def check( + self, + ): + self.stepper.items.append(self.make_stepper_item()) + return [] + + def get_url( + self, + ): + return reverse( + "studies:design_end", + args=[ + self.study.pk, + ], + ) + + +class InterventionChecker( + UpdateOrCreateChecker, +): + form_class = intervention_forms.InterventionForm + title = _("Interventie") + + def __init__( + self, + *args, + **kwargs, + ): + self.study = kwargs.pop("study") + return super().__init__(*args, **kwargs) + + def check( + self, + ): + self.stepper.items.append( + self.make_stepper_item(), + ) + return [] + + def object_exists( + self, + ): + return hasattr( + self.study, + "intervention", + ) + + def get_form_object( + self, + ): + return self.study.intervention + + def get_create_url( + self, + ): + return reverse( + "interventions:create", + args=[self.study.pk], + ) + + def get_update_url( + self, + ): + return reverse( + "interventions:update", + args=[self.study.intervention.pk], + ) + + +class ObservationChecker( + UpdateOrCreateChecker, +): + + form_class = observation_forms.ObservationForm + title = _("Observatie") + + def __init__( + self, + *args, + **kwargs, + ): + self.study = kwargs.pop("study") + return super().__init__(*args, **kwargs) + + def check( + self, + ): + self.stepper.items.append( + self.make_stepper_item(), + ) + return [] + + def object_exists( + self, + ): + return hasattr( + self.study, + "observation", + ) + + def get_create_url( + self, + ): + return reverse( + "observations:create", + args=[self.study.pk], + ) + + def get_update_url( + self, + ): + return reverse( + "observations:update", + args=[self.study.observation.pk], + ) + + def get_form_object( + self, + ): + return self.study.observation + + +class SessionsChecker( + ModelFormChecker, +): + + form_class = tasks_forms.SessionOverviewForm + title = _("Sessies") + + def __init__( + self, + *args, + **kwargs, + ): + self.study = kwargs.pop("study") + return super().__init__(*args, **kwargs) + + def check( + self, + ): + self.stepper.items.append( + self.make_stepper_item(), + ) + return [] + + def make_stepper_item( + self, + ): + """ + An absolutely ridiculous piece of work just to have the right + stepper item be bold for all underlying task/session views. + Don't ask me how much time I spent on this. + """ + item = super().make_stepper_item() + + def modified_is_current(self, request): + if request.path_info == self.get_url(): + return True + # Strip beginning of request path + subpath = request.path_info.replace( + "/tasks/", + "", + 1, + ) + + # Define function to match PK + def pk_matches_study(view, given_pk, study): + # These views have a Study object + if view in [ + session_views.SessionCreate, + session_views.SessionOverview, + ]: + return given_pk == study.pk + # These have a Task object + if view in [ + task_views.TaskUpdate, + task_views.TaskDelete, + ]: + return Task.objects.filter( + pk=given_pk, + session__in=study.session_set.all(), + ).exists() + # Everything else has a Session object + else: + return Session.objects.filter( + pk=given_pk, + study__pk=study.pk, + ).exists() + + # Check tasks URL patterns + from tasks.urls import urlpatterns + + for pat in urlpatterns: + # If any of them match, check the PK + if match := pat.resolve(subpath): + view = match.func.view_class + pk = match.kwargs["pk"] + return pk_matches_study(view, pk, self.study) + # If all else fails, guess we're not current + return False + + # Overwrite method at the instance level + # Strategy taken from types.MethodType and copied verbatim + # here for clarity + class _C: # NoQA + def _m( + self, + ): + pass + + MethodType = type(_C()._m) + # The type of _m is a method, bound to class instance _C, and + # MethodType is its constructor. MethodType takes a function + # and a class instance and returns a method bound to that instance, + # allowing for self-reference as expected. + item.is_current = MethodType(modified_is_current, item) + # Provide study to the modified is_current + item.study = self.study + return item + + def get_form_object( + self, + ): + return self.study + + def get_url( + self, + ): + return reverse( + "tasks:session_overview", + args=[self.study.pk], + ) + + +class DocumentsChecker( + Checker, +): + def make_stepper_item( + self, + ): + return ContainerItem( + self.stepper, + title=_("Documenten"), + location="attachments", + ) + + def check( + self, + ): + item = self.make_stepper_item() + self.stepper.items.append(item) + return [ + TranslationChecker( + self.stepper, + parent=item, + ), + AttachmentsChecker( + self.stepper, + parent=item, + ), + ] + + +class TranslationChecker( + ModelFormChecker, +): + form_class = proposal_forms.TranslatedConsentForms + title = _("Vertalingen") + location = "data_management" + + def check( + self, + ): + self.stepper.items.append(self.make_stepper_item()) + return [] + + def get_url( + self, + ): + return reverse( + "proposals:translated", + args=[ + self.stepper.proposal.pk, + ], + ) + + +class AttachmentsChecker( + Checker, +): + + def check( + self, + ): + self.stepper.items.append(self.make_stepper_item()) + return [] + + def make_stepper_item(self): + url = reverse( + "proposals:consent", + args=[self.stepper.proposal.pk], + ) + item = PlaceholderItem( + self.stepper, + title=_("Documenten beheren"), + parent=self.parent, + ) + item.get_url = lambda: url + return item + + +class DataManagementChecker( + ModelFormChecker, +): + title = _("Data management") + form_class = proposal_forms.ProposalDataManagementForm + location = "data_management" + + def check( + self, + ): + self.stepper.items.append( + self.make_stepper_item(), + ) + return [] + + def get_url( + self, + ): + return reverse( + "proposals:data_management", + args=[ + self.stepper.proposal.pk, + ], + ) + + +class SubmitChecker( + ModelFormChecker, +): + title = _("Indienen") + form_class = proposal_forms.ProposalSubmitForm + location = "submit" + + def check( + self, + ): + self.stepper.items.append( + self.make_stepper_item(), + ) + return [] + + def get_url( + self, + ): + return reverse( + "proposals:submit", + args=[ + self.stepper.proposal.pk, + ], + ) diff --git a/proposals/utils/stepper.py b/proposals/utils/stepper.py new file mode 100644 index 000000000..723c69a20 --- /dev/null +++ b/proposals/utils/stepper.py @@ -0,0 +1,163 @@ +from copy import copy + +from django.utils.translation import gettext as _ + +from main.utils import renderable + +from .stepper_helpers import ( + PlaceholderItem, + StepperItem, +) + +from .checkers import ProposalTypeChecker + + +class Stepper(renderable): + + template_name = "base/stepper.html" + + def __init__( + self, + proposal, + proposal_type_hint=None, + request=None, + ): + self.proposal = proposal + self.starting_checkers = [ + ProposalTypeChecker, + ] + # The stepper keeps track of the request to determine + # which item is current + self.request = request + # The type can be provided by the view for the case in which + # the proposal has not yet been created but we need to determine + # its type, i.e. the ProposalCreateViews + self.proposal_type_hint = proposal_type_hint + self.items = [] + self.check_all(self.starting_checkers) + + def get_context_data(self): + context = super().get_context_data() + # Provide the stepper bubble classes in order + # of descending size + bubble_list = [ + "stepper-bubble-largest", + "stepper-bubble-large", + "stepper-bubble-medium", + "stepper-bubble-small", + "stepper-bubble-smallest", + ] + context.update( + { + "stepper": self, + "bubble_size": bubble_list, + } + ) + return context + + def get_resume_url(self): + """ + Returns the url of the first page that requires attention, + that being either a page with an error or an incomplete page. + """ + for item in self.items: + if not item.is_complete: + return item.get_url() + + def build_stepper( + self, + ): + """ + The meat and potatoes of the stepper. Returns a list of top-level + StepperItems to be rendered in the template. + """ + # In building the stepper we will be editing this layout in-place, + # which means we need to make a copy. Otherwise we're editing the + # original RegularProposalLayout which causes strange behaviour + # when it is in use by any other steppers. + layout = copy(getattr(self, "layout", False)) + if not layout: + # Layout should be set before building the stepper + # by something like ProposalTypeChecker + raise RuntimeError( + "Base layout was never defined for this stepper", + ) + # First, insert all items into the layout + for item in self.items: + self._insert_item(layout, item) + # Second, replace all remaining empty slots in the layout + # by PlaceholderItems + self._insert_placeholders(layout) + return layout + + def _insert_item(self, layout, new_item): + """ + Inserts a stepper item into a layout, in-place. + """ + # We're only concerned with top-level items, children can sort + # themselves out + if new_item.parent: + return new_item.parent.children.append( + new_item, + ) + # Step through the layout looking for empty slots, which are + # represented by tuples of locations and titles + for index, slot in enumerate(layout): + # If the slot is already filled with an actual item, just + # skip it + if type(slot) is not tuple: + continue + # If the slot location matches that of our new_item, replace + # this slot with the item + if new_item.location == slot[0]: + layout.insert(index, new_item) + layout.remove(slot) + + def _insert_placeholders(self, layout): + # Step through the remaining slots in the layout + for index, slot in enumerate(layout): + # Skip slots that are already items + if isinstance(slot, StepperItem): + continue + # Remaining empty slots are replaced by placeholders + placeholder = PlaceholderItem( + self, + title=slot[1], + ) + layout.insert(index, placeholder) + layout.remove(slot) + return layout + + def check_all(self, next_checkers): + # No more checkers means we are done + if next_checkers == []: + return True + # Instantiate next checker + # and give it access to the stepper + current = next_checkers.pop(0)(self) + # Run the check method + # and gather new checkers from its output + new_checkers = current.check() + # Combine the lists, new checkers come first + next_checkers = new_checkers + next_checkers + # Recurse until next_checkers is empty + return self.check_all(next_checkers) + + def has_multiple_studies( + self, + ): + """ + Returns True if the proposal has more than one trajectory (study). + """ + num_studies = self.proposal.study_set.count() + return num_studies > 1 + + +RegularProposalLayout = [ + ("create", _("Basisgegevens")), + ("wmo", _("WMO")), + ("studies", _("Trajecten")), + ("attachments", _("Documenten")), + ("data_management", _("Datamanagement")), + ("submit", _("Indienen")), +] diff --git a/proposals/utils/stepper_helpers.py b/proposals/utils/stepper_helpers.py new file mode 100644 index 000000000..418113239 --- /dev/null +++ b/proposals/utils/stepper_helpers.py @@ -0,0 +1,288 @@ +from django.core.exceptions import ImproperlyConfigured +from braces.forms import UserKwargModelFormMixin + +from main.utils import renderable + + +class BaseStepperComponent: + + def __init__(self, stepper, parent=None): + self.stepper = stepper + self.proposal = stepper.proposal + self.parent = parent + + +class Checker( + BaseStepperComponent, +): + + def __call__(self, *args, **kwargs): + """ + This class may be called to initialize it when it is + already initialized. For now, we don't do anything with this + and pretend we just got initialized. + """ + return self + + def check(self): + """ + This method gets called to process an item in the proposal creation + process. It finally returns a list of checkers with which to continue + the checking process. This list can be empty. + """ + return [] + + +class StepperItem( + renderable, +): + """ + Represents an item in the stepper + """ + + template_name = "base/stepper_item.html" + title = "Stepper item" + location = None + + def __init__(self, stepper, parent=None, disabled=False, title=None, location=None): + self.stepper = stepper + self.proposal = stepper.proposal + self.children = [] + self.parent = parent + self.available = False + self.disabled = disabled + # Don't override default location if not provided explicitly + if location: + self.location = location + if title: + self.title = title + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + context.update( + { + "item": self, + } + ) + return context + + def get_url(self): + return "#" + + def get_errors(self): + return [] + + def css_classes( + self, + ): + classes = [] + if self.is_current(self.stepper.request): + classes.append( + "active", + ) + if self.disabled: + classes.append( + "disabled", + ) + return " ".join(classes) + + def is_current(self, request): + """ + Returns True if this item represents the page the user + is currently on. + """ + if request.path == self.get_url(): + return True + return False + + +class PlaceholderItem(StepperItem): + + def __init__(self, *args, **kwargs): + return super().__init__(*args, **kwargs) + + def get_url( + self, + ): + return "" + + +class ContainerItem( + StepperItem, +): + """ + A basic stepper item that is nothing more than a parent for its + children. Its url will try to redirect to its first child. + """ + + def get_url(self): + try: + url = self.children[0].get_url() + return url + except: + return "" + + def is_current(self, request): + """ + Because container items by default refer to their first child, + we say they are never current. The child is. + """ + return False + + +class ModelFormChecker( + Checker, +): + """ + A checker base that makes it as easy as possible to go from checking + a ModelForm to making a stepper item. + """ + + form_class = None + title = None + location = None + + def __init__(self, *args, **kwargs): + if not self.form_class: + raise ImproperlyConfigured("form_class must be defined") + return super().__init__(*args, **kwargs) + + def make_stepper_item(self): + if not self.title: + raise ImproperlyConfigured("title must be defined") + stepper_item = ModelFormItem( + self.stepper, + title=self.title, + parent=self.parent, + form_object=self.get_form_object(), + form_class=self.form_class, + url_func=self.get_url, + location=self.location, + ) + return stepper_item + + def get_form_object( + self, + ): + # Overwrite method for other objects + return self.proposal + + +class ModelFormItem( + StepperItem, +): + + def __init__(self, *args, **kwargs): + self.form_class = kwargs.pop( + "form_class", + ) + self.form_object = kwargs.pop( + "form_object", + ) + get_url = kwargs.pop( + "url_func", + None, + ) + self.errors = [] + if get_url: + self.get_url = get_url + return super().__init__(*args, **kwargs) + + @property + def model_form(self): + """ + This property is used to access the bound ModelForm and its errors. + self.instantiated_form can be set by hand (e.g. by a checker) to bypass + default form instantiation. + """ + if not hasattr(self, "instantiated_form"): + self.instantiated_form = self.instantiate_form() + return self.instantiated_form + + def get_form_object(self): + """ + Override this for modelforms that don't relate to a + Proposal model. + """ + return self.proposal + + def get_form_kwargs(self): + """ + This method can be overidden to provide extra kwargs to the form. But + kwargs that pop up often can also be added to this base class. + """ + kwargs = {} + if issubclass(self.form_class, UserKwargModelFormMixin): + kwargs["user"] = self.stepper.request.user + return kwargs + + def instantiate_form(self): + if not self.form_class: + raise ImproperlyConfigured("form_class must be defined") + kwargs = self.get_form_kwargs() + model_form = self.form_class( + instance=self.get_form_object(), + **kwargs, + ) + return model_form + + def get_errors(self): + """ + This is a placeholder that just returns the form errors for now. + But once we've figured out what we want exactly this method should + return both the form errors and any extra errors that a Checker + might insert into this item. + """ + return self.model_form.errors + + +class UpdateOrCreateChecker( + ModelFormChecker, +): + """ + A variation on the ModelFormChecker designed for + forms like the InterventionForm, which link either + to a create or update view depending on if they exist + already. + + If the object in question exists, this class acts like + a normal ModelFormChecker. Otherwise it provides a factory + for a PlaceholderItem that links to the CreateView. + """ + + def make_stepper_item( + self, + ): + if self.object_exists(): + return super().make_stepper_item() + return self.make_placeholder_item() + + def make_placeholder_item( + self, + ): + item = PlaceholderItem( + self.stepper, + title=self.title, + parent=self.parent, + ) + item.get_url = self.get_create_url + return item + + def object_exists( + self, + ): + # By default, assume the object exists + return True + + def get_url(self): + return self.get_update_url() + + def get_create_url( + self, + ): + return "" + + def get_update_url( + self, + ): + return "" diff --git a/proposals/views/proposal_views.py b/proposals/views/proposal_views.py index 9f5a84bb2..4949a7f68 100644 --- a/proposals/views/proposal_views.py +++ b/proposals/views/proposal_views.py @@ -47,6 +47,7 @@ ResearchGoalForm, PreApprovedForm, TranslatedConsentForms, + ProposalForm, ) from ..models import Proposal, Wmo from ..utils import generate_pdf, generate_ref_number @@ -241,6 +242,13 @@ class ProposalCreate(ProposalMixin, AllowErrorsOnBackbuttonMixin, CreateView): # Note: template_name is auto-generated to proposal_form.html success_message = _("Aanvraag %(title)s aangemaakt") + proposal_type_hint = "regular" + form_class = ProposalForm + + def get_proposal( + self, + ): + return self.get_form().instance def form_valid(self, form): """ @@ -264,10 +272,15 @@ def get_context_data(self, **kwargs): context["no_back"] = True return context + def get_next_url(self): + return reverse("proposals:researcher", args=(self.object.pk,)) + class ProposalUpdate( ProposalMixin, ProposalContextMixin, AllowErrorsOnBackbuttonMixin, UpdateView ): + form_class = ProposalForm + def form_valid(self, form): """Sets created_by to current user and generates a reference number""" form.instance.reviewing_committee = form.instance.institution.reviewing_chamber @@ -281,6 +294,9 @@ def get_context_data(self, **kwargs): return context + def get_next_url(self): + return reverse("proposals:researcher", args=(self.object.pk,)) + class ProposalDelete(DeleteView): model = Proposal @@ -372,7 +388,11 @@ def get_context_data(self, **kwargs): class ProposalResearcherFormView( - UserFormKwargsMixin, ProposalContextMixin, AllowErrorsOnBackbuttonMixin, UpdateView + ProposalMixin, + UserFormKwargsMixin, + ProposalContextMixin, + AllowErrorsOnBackbuttonMixin, + UpdateView, ): model = Proposal form_class = ResearcherForm @@ -386,7 +406,10 @@ def get_back_url(self): class ProposalOtherResearchersFormView( - UserFormKwargsMixin, ProposalContextMixin, AllowErrorsOnBackbuttonMixin, UpdateView + UserFormKwargsMixin, + AllowErrorsOnBackbuttonMixin, + ProposalMixin, + UpdateView, ): model = Proposal form_class = OtherResearchersForm @@ -462,7 +485,7 @@ def get_back_url(self): return reverse("proposals:research_goal", args=(self.object.pk,)) -class TranslatedConsentFormsView(UpdateView): +class TranslatedConsentFormsView(ProposalContextMixin, UpdateView): model = Proposal form_class = TranslatedConsentForms template_name = "proposals/translated_consent_forms.html" @@ -476,7 +499,7 @@ def get_back_url(self): return reverse("studies:design_end", args=(self.object.last_study().pk,)) -class ProposalDataManagement(UpdateView): +class ProposalDataManagement(ProposalContextMixin, UpdateView): model = Proposal form_class = ProposalDataManagementForm template_name = "proposals/proposal_data_management.html" diff --git a/proposals/views/study_views.py b/proposals/views/study_views.py index 9d35f404c..79b428d53 100644 --- a/proposals/views/study_views.py +++ b/proposals/views/study_views.py @@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _ from main.views import AllowErrorsOnBackbuttonMixin, UpdateView, FormSetUpdateView +from proposals.mixins import ProposalContextMixin from studies.models import Documents, Study from studies.forms import StudyConsentForm from studies.utils import create_documents_for_study @@ -11,7 +12,11 @@ from ..models import Proposal -class StudyStart(AllowErrorsOnBackbuttonMixin, UpdateView): +class StudyStart( + ProposalContextMixin, + AllowErrorsOnBackbuttonMixin, + UpdateView, +): model = Proposal form_class = StudyStartForm template_name = "proposals/study_start.html" diff --git a/proposals/views/wmo_views.py b/proposals/views/wmo_views.py index 6a1db57aa..098614745 100644 --- a/proposals/views/wmo_views.py +++ b/proposals/views/wmo_views.py @@ -9,6 +9,7 @@ from main.models import YesNoDoubt from main.views import CreateView, UpdateView, AllowErrorsOnBackbuttonMixin from main.utils import get_secretary +from proposals.mixins import StepperContextMixin from ..models import Proposal, Wmo from ..forms import WmoForm, WmoApplicationForm, WmoCheckForm @@ -52,7 +53,11 @@ def get_proposal(self): raise NotImplementedError -class WmoCreate(WmoMixin, CreateView): +class WmoCreate( + StepperContextMixin, + WmoMixin, + CreateView, +): success_message = _("WMO-gegevens opgeslagen") def form_valid(self, form): @@ -65,7 +70,11 @@ def get_proposal(self): return Proposal.objects.get(pk=self.kwargs["pk"]) -class WmoUpdate(WmoMixin, UpdateView): +class WmoUpdate( + StepperContextMixin, + WmoMixin, + UpdateView, +): success_message = _("WMO-gegevens bewerkt") def get_proposal(self): @@ -76,7 +85,10 @@ def get_proposal(self): ###################### # Other actions on WMO ###################### -class WmoApplication(UpdateView): +class WmoApplication( + UpdateView, + StepperContextMixin, +): model = Wmo form_class = WmoApplicationForm template_name = "proposals/wmo_application.html" diff --git a/studies/mixins.py b/studies/mixins.py new file mode 100644 index 000000000..7f00ba24f --- /dev/null +++ b/studies/mixins.py @@ -0,0 +1,24 @@ +from .models import Study +from proposals.mixins import StepperContextMixin + + +class StudyMixin( + StepperContextMixin, +): + + def get_proposal( + self, + ): + return self.get_object().proposal + + +class StudyFromURLMixin: + + def get_study(self): + """Retrieves the Study from the pk kwarg""" + return Study.objects.get(pk=self.kwargs["pk"]) + + def get_proposal( + self, + ): + return self.get_study().proposal diff --git a/studies/views/study_views.py b/studies/views/study_views.py index c9efe4e65..2e7dcc3c6 100644 --- a/studies/views/study_views.py +++ b/studies/views/study_views.py @@ -23,12 +23,17 @@ ) from ..models import Study, Documents from ..utils import check_has_adults, check_necessity_required, get_study_progress +from ..mixins import StudyMixin ####################### # CRUD actions on Study ####################### -class StudyUpdate(AllowErrorsOnBackbuttonMixin, UpdateView): +class StudyUpdate( + StudyMixin, + AllowErrorsOnBackbuttonMixin, + UpdateView, +): """Updates a Study from a StudyForm""" model = Study @@ -39,7 +44,7 @@ def get_context_data(self, **kwargs): """Setting the progress on the context""" context = super(StudyUpdate, self).get_context_data(**kwargs) context["progress"] = get_study_progress(self.object) - context["proposal"] = self.object.proposal + context["proposal"] = self.get_proposal() return context def get_form_kwargs(self): @@ -65,7 +70,9 @@ def get_next_url(self): ############### # Other actions ############### -class StudyDesign(AllowErrorsOnBackbuttonMixin, UpdateView, generic.edit.FormMixin): +class StudyDesign( + StudyMixin, AllowErrorsOnBackbuttonMixin, UpdateView, generic.edit.FormMixin +): model = Study form_class = StudyDesignForm success_message = _("Traject opgeslagen") @@ -134,7 +141,11 @@ def get_back_url(self): return reverse("studies:update", args=(self.kwargs["pk"],)) -class StudyEnd(AllowErrorsOnBackbuttonMixin, UpdateView): +class StudyEnd( + StudyMixin, + AllowErrorsOnBackbuttonMixin, + UpdateView, +): """ Completes a Study """ diff --git a/tasks/views/session_views.py b/tasks/views/session_views.py index 631c5a067..2a7ec30d2 100644 --- a/tasks/views/session_views.py +++ b/tasks/views/session_views.py @@ -6,6 +6,8 @@ from django.utils.translation import gettext_lazy as _ from main.views import AllowErrorsOnBackbuttonMixin, UpdateView, DeleteView, CreateView +from proposals.mixins import StepperContextMixin +from studies.mixins import StudyFromURLMixin, StudyMixin from ..forms import SessionUpdateForm, SessionEndForm, SessionOverviewForm from ..models import Session, Study @@ -44,7 +46,10 @@ def form_valid(self, form): ################## -class SessionMixin(AllowErrorsOnBackbuttonMixin): +class SessionMixin( + StepperContextMixin, + AllowErrorsOnBackbuttonMixin, +): model = Session form_class = SessionUpdateForm @@ -63,8 +68,13 @@ def get_study(self): def get_next_url(self): return reverse("tasks:session_end", args=(self.object.pk,)) + def get_proposal( + self, + ): + return self.get_object().study.proposal -class SessionStart(SessionMixin, UpdateView): + +class SessionStart(StudyMixin, UpdateView): model = Study # This form is just a placeholder to make navigation work. It does not do @@ -87,11 +97,12 @@ def get_back_url(self): pk = study.intervention.pk return reverse(next_url, args=(pk,)) - def get_study(self): - return self.object - -class SessionCreate(SessionMixin, CreateView): +class SessionCreate( + StudyFromURLMixin, + SessionMixin, + CreateView, +): def get_form_kwargs(self): """Sets the Study as a form kwarg""" @@ -106,10 +117,6 @@ def form_valid(self, form): form.instance.order = study.session_set.count() + 1 return super(SessionCreate, self).form_valid(form) - def get_study(self): - """Retrieves the Study from the pk kwarg""" - return Study.objects.get(pk=self.kwargs["pk"]) - def get_back_url(self): return reverse("tasks:session_overview", args=(self.object.study.pk,)) @@ -153,7 +160,7 @@ def get_back_url(self): return reverse("tasks:session_overview", args=(self.object.study.pk,)) -class SessionOverview(UpdateView): +class SessionOverview(StudyMixin, UpdateView): model = Study form_class = SessionOverviewForm diff --git a/tasks/views/task_views.py b/tasks/views/task_views.py index fc746d515..82a86969c 100644 --- a/tasks/views/task_views.py +++ b/tasks/views/task_views.py @@ -8,6 +8,7 @@ from main.views import AllowErrorsOnBackbuttonMixin, UpdateView, DeleteView, CreateView from ..forms import TaskForm from ..models import Task, Session +from proposals.mixins import StepperContextMixin ###################### @@ -15,7 +16,10 @@ ###################### -class TaskMixin(AllowErrorsOnBackbuttonMixin): +class TaskMixin( + StepperContextMixin, + AllowErrorsOnBackbuttonMixin, +): model = Task form_class = TaskForm template_name = "tasks/task_update.html" @@ -25,7 +29,7 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) session = self.get_session() context["session"] = session - context["proposal"] = session.study.proposal + context["proposal"] = self.get_proposal() try: context["order"] = self.object.order except AttributeError: @@ -37,7 +41,10 @@ def get_context_data(self, **kwargs): return context def get_session(self): - return self.object.session + return self.get_object().session + + def get_proposal(self): + return self.get_object().session.study.proposal def get_next_url(self): return reverse("tasks:session_end", args=(self.object.session.pk,))