diff --git a/hypha/apply/determinations/admin.py b/hypha/apply/determinations/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..4ec7dfb98c7ec6079e65c78393c6bb8feac85590 --- /dev/null +++ b/hypha/apply/determinations/admin.py @@ -0,0 +1,45 @@ +from django.conf.urls import url +from wagtail.contrib.modeladmin.options import ModelAdmin +from wagtail.contrib.modeladmin.views import CreateView, InstanceSpecificView + +from hypha.apply.determinations.models import DeterminationForm +from hypha.apply.review.admin_helpers import ButtonsWithClone +from hypha.apply.utils.admin import ListRelatedMixin + + +class CloneView(CreateView, InstanceSpecificView): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.instance.pk = None + + +class DeterminationFormAdmin(ListRelatedMixin, ModelAdmin): + model = DeterminationForm + menu_icon = 'form' + list_display = ('name', 'used_by') + button_helper_class = ButtonsWithClone + clone_view_class = CloneView + + related_models = [ + ('applicationbasedeterminationform', 'application'), + ('roundbasedeterminationform', 'round'), + ('labbasedeterminationform', 'lab'), + ] + + def get_admin_urls_for_registration(self): + urls = super().get_admin_urls_for_registration() + + urls += ( + url( + self.url_helper.get_action_url_pattern('clone'), + self.clone_view, + name=self.url_helper.get_action_url_name('clone') + ), + ) + + return urls + + def clone_view(self, request, **kwargs): + kwargs.update(**{'model_admin': self}) + view_class = self.clone_view_class + return view_class.as_view(**kwargs)(request) diff --git a/hypha/apply/determinations/blocks.py b/hypha/apply/determinations/blocks.py new file mode 100644 index 0000000000000000000000000000000000000000..0eb76027269a0860e33fed03334273c6b393e14f --- /dev/null +++ b/hypha/apply/determinations/blocks.py @@ -0,0 +1,69 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ +from wagtail.core.blocks import RichTextBlock + +from hypha.apply.stream_forms.blocks import ( + CharFieldBlock, + CheckboxFieldBlock, + DropdownFieldBlock, + TextFieldBlock, +) +from hypha.apply.utils.blocks import CustomFormFieldsBlock, MustIncludeFieldBlock +from hypha.apply.utils.options import RICH_TEXT_WIDGET + +from .options import DETERMINATION_CHOICES + + +class DeterminationMustIncludeFieldBlock(MustIncludeFieldBlock): + pass + + +class DeterminationBlock(DeterminationMustIncludeFieldBlock): + name = 'determination' + description = 'Overall determination' + field_class = forms.TypedChoiceField + + class Meta: + icon = 'pick' + + def get_field_kwargs(self, struct_value): + kwargs = super().get_field_kwargs(struct_value) + kwargs['choices'] = DETERMINATION_CHOICES + return kwargs + + def render(self, value, context=None): + data = int(context['data']) + choices = dict(DETERMINATION_CHOICES) + context['data'] = choices[data] + return super().render(value, context) + + +class DeterminationMessageBlock(DeterminationMustIncludeFieldBlock): + name = 'message' + description = 'Determination message' + widget = RICH_TEXT_WIDGET + + class Meta: + icon = 'openquote' + template = 'stream_forms/render_unsafe_field.html' + + def get_field_kwargs(self, struct_value): + kwargs = super().get_field_kwargs(struct_value) + kwargs['required'] = False + return kwargs + + +class SendNoticeBlock(CheckboxFieldBlock): + + class Meta: + label = _('Send Notice') + + +class DeterminationCustomFormFieldsBlock(CustomFormFieldsBlock): + char = CharFieldBlock(group=_('Fields')) + text = TextFieldBlock(group=_('Fields')) + text_markup = RichTextBlock(group=_('Fields'), label=_('Section text/header')) + checkbox = CheckboxFieldBlock(group=_('Fields')) + dropdown = DropdownFieldBlock(group=_('Fields')) + send_notice = SendNoticeBlock(group=_('Fields')) + required_blocks = DeterminationMustIncludeFieldBlock.__subclasses__() diff --git a/hypha/apply/determinations/forms.py b/hypha/apply/determinations/forms.py index 45d7b4eb3887e27adad45a9ab6b3c6b69813fa63..19c265fdc644deeef26e1dd1bdff45771d5867b6 100644 --- a/hypha/apply/determinations/forms.py +++ b/hypha/apply/determinations/forms.py @@ -3,14 +3,11 @@ from django.contrib.auth import get_user_model from django.core.exceptions import NON_FIELD_ERRORS from hypha.apply.funds.models import ApplicationSubmission +from hypha.apply.stream_forms.forms import StreamBaseForm from hypha.apply.utils.fields import RichTextField -from .models import ( - DETERMINATION_CHOICES, - TRANSITION_DETERMINATION, - Determination, - DeterminationFormSettings, -) +from .models import Determination, DeterminationFormSettings +from .options import DETERMINATION_CHOICES, TRANSITION_DETERMINATION from .utils import determination_actions User = get_user_model() @@ -138,14 +135,14 @@ class BaseBatchDeterminationForm(BaseDeterminationForm, forms.Form): def data_fields(self): return [ field for field in self.fields - if field not in ['submissions', 'outcome', 'author', 'send_notice'] + if field not in ['submissions', 'outcome', 'author', 'send_notice', 'message'] ] def _post_clean(self): submissions = self.cleaned_data['submissions'].undetermined() data = { field: self.cleaned_data[field] - for field in ['author', 'data', 'outcome'] + for field in ['author', 'data', 'outcome', 'message'] } self.instances = [ @@ -411,3 +408,158 @@ class BatchProposalDeterminationForm(BaseProposalDeterminationForm, BaseBatchDet self.fields['outcome'].widget = forms.HiddenInput() self.fields = self.apply_form_settings('proposal', self.fields) + + +class MixedMetaClass(type(StreamBaseForm), type(forms.ModelForm)): + pass + + +class DeterminationModelForm(StreamBaseForm, forms.ModelForm, metaclass=MixedMetaClass): + draft_button_name = "save draft" + + class Meta: + model = Determination + fields = ['outcome', 'message', 'submission', 'author', 'send_notice'] + + widgets = { + 'outcome': forms.HiddenInput(), + 'message': forms.HiddenInput(), + 'submission': forms.HiddenInput(), + 'author': forms.HiddenInput(), + 'send_notice': forms.HiddenInput(), + } + + error_messages = { + NON_FIELD_ERRORS: { + 'unique_together': "You have already created a determination for this submission", + } + } + + def __init__( + self, *args, submission, action, user=None, + edit=False, initial={}, instance=None, site=None, + **kwargs + ): + initial.update(submission=submission.id) + initial.update(author=user.id) + if instance: + for key, value in instance.form_data.items(): + if key not in self._meta.fields: + initial[key] = value + super().__init__(*args, initial=initial, instance=instance, **kwargs) + + for field in self._meta.widgets: + # Need to disable the model form fields as these fields would be + # rendered via streamfield form. + self.fields[field].disabled = True + + if self.draft_button_name in self.data: + for field in self.fields.values(): + field.required = False + + if edit: + self.fields.pop('outcome') + self.draft_button_name = None + + def clean(self): + cleaned_data = super().clean() + cleaned_data['form_data'] = { + key: value + for key, value in cleaned_data.items() + if key not in self._meta.fields + } + + return cleaned_data + + def save(self, commit=True): + self.instance.send_notice = ( + self.cleaned_data[self.instance.send_notice_field.id] + if self.instance.send_notice_field else True + ) + self.instance.message = self.cleaned_data[self.instance.message_field.id] + try: + self.instance.outcome = int(self.cleaned_data[self.instance.determination_field.id]) + # Need to catch KeyError as outcome field would not exist in case of edit. + except KeyError: + pass + self.instance.is_draft = self.draft_button_name in self.data + self.instance.form_data = self.cleaned_data['form_data'] + return super().save(commit) + + +class FormMixedMetaClass(type(StreamBaseForm), type(forms.Form)): + pass + + +class BatchDeterminationForm(StreamBaseForm, forms.Form, metaclass=FormMixedMetaClass): + submissions = forms.ModelMultipleChoiceField( + queryset=ApplicationSubmission.objects.all(), + widget=forms.ModelMultipleChoiceField.hidden_widget, + ) + author = forms.ModelChoiceField( + queryset=User.objects.staff(), + widget=forms.ModelChoiceField.hidden_widget, + required=True, + ) + outcome = forms.ChoiceField( + choices=DETERMINATION_CHOICES, + label='Determination', + help_text='Do you recommend requesting a proposal based on this concept note?', + widget=forms.HiddenInput() + ) + + def __init__( + self, *args, user, submissions, action, initial={}, + edit=False, site=None, **kwargs + ): + initial.update(submissions=submissions.values_list('id', flat=True)) + try: + initial.update(outcome=TRANSITION_DETERMINATION[action]) + except KeyError: + pass + initial.update(author=user.id) + super().__init__(*args, initial=initial, **kwargs) + self.fields['submissions'].disabled = True + self.fields['author'].disabled = True + self.fields['outcome'].disabled = True + + def data_fields(self): + return [ + field for field in self.fields + if field not in ['submissions', 'outcome', 'author', 'send_notice'] + ] + + def clean(self): + cleaned_data = super().clean() + cleaned_data['form_data'] = { + key: value + for key, value in cleaned_data.items() + if key in self.data_fields() + } + return cleaned_data + + def clean_outcome(self): + # Enforce outcome as an int + return int(self.cleaned_data['outcome']) + + def _post_clean(self): + submissions = self.cleaned_data['submissions'].undetermined() + data = { + field: self.cleaned_data[field] + for field in ['author', 'form_data', 'outcome'] + } + + self.instances = [ + Determination( + submission=submission, + **data, + ) + for submission in submissions + ] + + return super()._post_clean() + + def save(self): + determinations = Determination.objects.bulk_create(self.instances) + self.instances = determinations + return determinations diff --git a/hypha/apply/determinations/migrations/0010_add_determination_stream_field_forms.py b/hypha/apply/determinations/migrations/0010_add_determination_stream_field_forms.py new file mode 100644 index 0000000000000000000000000000000000000000..195a5f683f2c34784878d7bb4b7756c8ddc0e8d8 --- /dev/null +++ b/hypha/apply/determinations/migrations/0010_add_determination_stream_field_forms.py @@ -0,0 +1,44 @@ +# Generated by Django 2.2.13 on 2020-07-01 10:19 + +import django.contrib.postgres.fields.jsonb +import django.core.serializers.json +from django.db import migrations, models +import wagtail.core.blocks +import wagtail.core.blocks.static_block +import wagtail.core.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('determinations', '0009_add_send_notice_field'), + ] + + operations = [ + migrations.CreateModel( + name='DeterminationForm', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('form_fields', wagtail.core.fields.StreamField([('rich_text', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.TextBlock(label='Default value', required=False)), ('word_limit', wagtail.core.blocks.IntegerBlock(default=1000, label='Word limit'))], group='Fields')), ('markdown_text', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.TextBlock(label='Default value', required=False)), ('word_limit', wagtail.core.blocks.IntegerBlock(default=1000, label='Word limit'))], group='Fields')), ('char', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('format', wagtail.core.blocks.ChoiceBlock(choices=[('email', 'Email'), ('url', 'URL')], label='Format', required=False)), ('default_value', wagtail.core.blocks.CharBlock(label='Default value', required=False))], group='Fields')), ('text', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.TextBlock(label='Default value', required=False)), ('word_limit', wagtail.core.blocks.IntegerBlock(default=1000, label='Word limit'))], group='Fields')), ('text_markup', wagtail.core.blocks.RichTextBlock(group='Fields', label='Section text/header')), ('checkbox', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.BooleanBlock(required=False))], group='Fields')), ('dropdown', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice')))], group='Fields')), ('send_notice', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.BooleanBlock(required=False))], group='Fields')), ('determination', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('info', wagtail.core.blocks.static_block.StaticBlock())], group=' Required')), ('message', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('info', wagtail.core.blocks.static_block.StaticBlock())], group=' Required'))], default=[])), + ('name', models.CharField(max_length=255)), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='determination', + name='form_data', + field=django.contrib.postgres.fields.jsonb.JSONField(default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='determination', + name='form_fields', + field=wagtail.core.fields.StreamField([('rich_text', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.TextBlock(label='Default value', required=False)), ('word_limit', wagtail.core.blocks.IntegerBlock(default=1000, label='Word limit'))], group='Fields')), ('markdown_text', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.TextBlock(label='Default value', required=False)), ('word_limit', wagtail.core.blocks.IntegerBlock(default=1000, label='Word limit'))], group='Fields')), ('char', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('format', wagtail.core.blocks.ChoiceBlock(choices=[('email', 'Email'), ('url', 'URL')], label='Format', required=False)), ('default_value', wagtail.core.blocks.CharBlock(label='Default value', required=False))], group='Fields')), ('text', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.TextBlock(label='Default value', required=False)), ('word_limit', wagtail.core.blocks.IntegerBlock(default=1000, label='Word limit'))], group='Fields')), ('text_markup', wagtail.core.blocks.RichTextBlock(group='Fields', label='Section text/header')), ('checkbox', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.BooleanBlock(required=False))], group='Fields')), ('dropdown', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice')))], group='Fields')), ('send_notice', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('required', wagtail.core.blocks.BooleanBlock(label='Required', required=False)), ('default_value', wagtail.core.blocks.BooleanBlock(required=False))], group='Fields')), ('determination', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('info', wagtail.core.blocks.static_block.StaticBlock())], group=' Required')), ('message', wagtail.core.blocks.StructBlock([('field_label', wagtail.core.blocks.CharBlock(label='Label')), ('help_text', wagtail.core.blocks.TextBlock(label='Help text', required=False)), ('help_link', wagtail.core.blocks.URLBlock(label='Help link', required=False)), ('info', wagtail.core.blocks.static_block.StaticBlock())], group=' Required'))], default=[]), + ), + migrations.AlterField( + model_name='determination', + name='data', + field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True), + ), + ] diff --git a/hypha/apply/determinations/models.py b/hypha/apply/determinations/models.py index 28e233ba18c6ed57c98d6763ed50ce8c2ceac22c..4b72205b31d620685f81b43a0add72709f63ffd3 100644 --- a/hypha/apply/determinations/models.py +++ b/hypha/apply/determinations/models.py @@ -1,6 +1,7 @@ import bleach from django.conf import settings from django.contrib.postgres.fields import JSONField +from django.core.serializers.json import DjangoJSONEncoder from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -8,33 +9,22 @@ from wagtail.admin.edit_handlers import ( FieldPanel, MultiFieldPanel, ObjectList, + StreamFieldPanel, TabbedInterface, ) from wagtail.contrib.settings.models import BaseSetting, register_setting -from wagtail.core.fields import RichTextField +from wagtail.core.fields import RichTextField, StreamField -from hypha.apply.funds.workflow import DETERMINATION_OUTCOMES +from hypha.apply.funds.models.mixins import AccessFormData -REJECTED = 0 -NEEDS_MORE_INFO = 1 -ACCEPTED = 2 - -DETERMINATION_CHOICES = ( - (REJECTED, _('Dismissed')), - (NEEDS_MORE_INFO, _('More information requested')), - (ACCEPTED, _('Approved')), +from .blocks import ( + DeterminationBlock, + DeterminationCustomFormFieldsBlock, + DeterminationMessageBlock, + DeterminationMustIncludeFieldBlock, + SendNoticeBlock, ) - -DETERMINATION_TO_OUTCOME = { - 'rejected': REJECTED, - 'accepted': ACCEPTED, - 'more_info': NEEDS_MORE_INFO, -} - -TRANSITION_DETERMINATION = { - name: DETERMINATION_TO_OUTCOME[type] - for name, type in DETERMINATION_OUTCOMES.items() -} +from .options import ACCEPTED, DETERMINATION_CHOICES, REJECTED class DeterminationQuerySet(models.QuerySet): @@ -49,7 +39,52 @@ class DeterminationQuerySet(models.QuerySet): return self.submitted().filter(outcome__in=[ACCEPTED, REJECTED]) -class Determination(models.Model): +class DeterminationFormFieldsMixin(models.Model): + class Meta: + abstract = True + + form_fields = StreamField(DeterminationCustomFormFieldsBlock(), default=[]) + + @property + def determination_field(self): + return self._get_field_type(DeterminationBlock) + + @property + def message_field(self): + return self._get_field_type(DeterminationMessageBlock) + + @property + def send_notice_field(self): + return self._get_field_type(SendNoticeBlock) + + def _get_field_type(self, block_type, many=False): + fields = list() + for field in self.form_fields: + try: + if isinstance(field.block, block_type): + if many: + fields.append(field) + else: + return field + except AttributeError: + pass + if many: + return fields + + +class DeterminationForm(DeterminationFormFieldsMixin, models.Model): + name = models.CharField(max_length=255) + + panels = [ + FieldPanel('name'), + StreamFieldPanel('form_fields'), + ] + + def __str__(self): + return self.name + + +class Determination(DeterminationFormFieldsMixin, AccessFormData, models.Model): submission = models.ForeignKey( 'funds.ApplicationSubmission', on_delete=models.CASCADE, @@ -62,7 +97,12 @@ class Determination(models.Model): outcome = models.IntegerField(verbose_name=_("Determination"), choices=DETERMINATION_CHOICES, default=1) message = models.TextField(verbose_name=_("Determination message"), blank=True) - data = JSONField(blank=True) + + # Stores old determination forms data + data = JSONField(blank=True, null=True) + + # Stores data submitted via streamfield determination forms + form_data = JSONField(default=dict, encoder=DjangoJSONEncoder) is_draft = models.BooleanField(default=False, verbose_name=_("Draft")) created_at = models.DateTimeField(verbose_name=_('Creation time'), auto_now_add=True) updated_at = models.DateTimeField(verbose_name=_('Update time'), auto_now=True) @@ -92,12 +132,48 @@ class Determination(models.Model): return f'Determination for {self.submission.title} by {self.author!s}' def __repr__(self): - return f'<{self.__class__.__name__}: {str(self.data)}>' + return f'<{self.__class__.__name__}: {str(self.form_data)}>' + + @property + def use_new_determination_form(self): + """ + Checks if a submission has the new streamfield determination form + attached to it and along with that it also verify that if self.data is None. + + self.data would be set as None for the determination which are created using + streamfield determination forms. + + But old lab forms can be edited to add new determination forms + so we need to use old determination forms for already submitted determination. + """ + return self.submission.is_determination_form_attached and self.data is None @property def detailed_data(self): - from .views import get_form_for_stage - return get_form_for_stage(self.submission).get_detailed_response(self.data) + if not self.use_new_determination_form: + from .views import get_form_for_stage + return get_form_for_stage(self.submission).get_detailed_response(self.data) + return self.get_detailed_response() + + def get_detailed_response(self): + data = {} + group = 0 + data.setdefault(group, {'title': None, 'questions': list()}) + for field in self.form_fields: + if issubclass( + field.block.__class__, DeterminationMustIncludeFieldBlock + ) or isinstance(field.block, SendNoticeBlock): + continue + try: + value = self.form_data[field.id] + except KeyError: + group = group + 1 + data.setdefault(group, {'title': field.value.source, 'questions': list()}) + else: + data[group]['questions'].append( + (field.value.get('field_label'), value) + ) + return data @register_setting diff --git a/hypha/apply/determinations/options.py b/hypha/apply/determinations/options.py new file mode 100644 index 0000000000000000000000000000000000000000..1da2980b18ac439b72483f50a62e163cfbb90d17 --- /dev/null +++ b/hypha/apply/determinations/options.py @@ -0,0 +1,24 @@ +from django.utils.translation import gettext_lazy as _ + +from hypha.apply.funds.workflow import DETERMINATION_OUTCOMES + +REJECTED = 0 +NEEDS_MORE_INFO = 1 +ACCEPTED = 2 + +DETERMINATION_CHOICES = ( + (REJECTED, _('Dismissed')), + (NEEDS_MORE_INFO, _('More information requested')), + (ACCEPTED, _('Approved')), +) + +DETERMINATION_TO_OUTCOME = { + 'rejected': REJECTED, + 'accepted': ACCEPTED, + 'more_info': NEEDS_MORE_INFO, +} + +TRANSITION_DETERMINATION = { + name: DETERMINATION_TO_OUTCOME[type] + for name, type in DETERMINATION_OUTCOMES.items() +} diff --git a/hypha/apply/determinations/templates/determinations/base_determination_form.html b/hypha/apply/determinations/templates/determinations/base_determination_form.html index fddfcb2d4d16fecb13c37b6f5c80ad366052a7a1..e5c6f53a5302111fb16c4e851607e458a5153584 100644 --- a/hypha/apply/determinations/templates/determinations/base_determination_form.html +++ b/hypha/apply/determinations/templates/determinations/base_determination_form.html @@ -52,6 +52,7 @@ </form> {% for type, message in message_templates.items %} <div class="is-hidden" data-type="{{ type }}" id="determination-message-{{ type }}"> + <h1>message</h1> {{ message|bleach }} </div> {% endfor %} diff --git a/hypha/apply/determinations/templates/determinations/determination_detail.html b/hypha/apply/determinations/templates/determinations/determination_detail.html index 64a465cd4d0bfe8c6c40a8b87205cedbb69dcd80..06f562be2f71e82b31c0a2427a6ab6381bfa34a1 100644 --- a/hypha/apply/determinations/templates/determinations/determination_detail.html +++ b/hypha/apply/determinations/templates/determinations/determination_detail.html @@ -30,7 +30,9 @@ <h4>Determination message</h4> {{ determination.message|bleach }} {% for group in determination.detailed_data.values %} - <h4>{{ group.title }}</h4> + {% if group.title %} + <h4>{{ group.title|bleach }}</h4> + {% endif %} {% for question, answer in group.questions %} <h5>{{ question }}</h5> {% if answer %}{{ answer|bleach }}{% else %}-{% endif %} diff --git a/hypha/apply/determinations/tests/factories.py b/hypha/apply/determinations/tests/factories.py index 53708005d129597526db4b7e10b5cceba4fa56f6..2d4d791cf30a4c089e40f4e818c298135ee0b331 100644 --- a/hypha/apply/determinations/tests/factories.py +++ b/hypha/apply/determinations/tests/factories.py @@ -1,8 +1,18 @@ +import random + import factory from hypha.apply.funds.tests.factories import ApplicationSubmissionFactory +from hypha.apply.stream_forms.testing.factories import ( + CharFieldBlockFactory, + FormFieldBlockFactory, + StreamFieldUUIDFactory, +) +from hypha.apply.utils.testing.factories import RichTextFieldBlockFactory -from ..models import ACCEPTED, NEEDS_MORE_INFO, REJECTED, Determination +from ..blocks import DeterminationBlock, DeterminationMessageBlock, SendNoticeBlock +from ..models import Determination, DeterminationForm +from ..options import ACCEPTED, NEEDS_MORE_INFO, REJECTED from ..views import get_form_for_stage @@ -44,3 +54,39 @@ class DeterminationFactory(factory.DjangoModelFactory): }, dict_factory=DeterminationDataFactory) is_draft = True + + +class DeterminationBlockFactory(FormFieldBlockFactory): + class Meta: + model = DeterminationBlock + + @classmethod + def make_answer(cls, params=dict()): + return random.choices([ACCEPTED, NEEDS_MORE_INFO, REJECTED]) + + +class DeterminationMessageBlockFactory(FormFieldBlockFactory): + class Meta: + model = DeterminationMessageBlock + + +class SendNoticeBlockFactory(FormFieldBlockFactory): + class Meta: + model = SendNoticeBlock + + +DeterminationFormFieldsFactory = StreamFieldUUIDFactory({ + 'char': CharFieldBlockFactory, + 'text': RichTextFieldBlockFactory, + 'send_notice': SendNoticeBlockFactory, + 'determination': DeterminationBlockFactory, + 'message': DeterminationMessageBlockFactory, +}) + + +class DeterminationFormFactory(factory.DjangoModelFactory): + class Meta: + model = DeterminationForm + + name = factory.Faker('word') + form_fields = DeterminationFormFieldsFactory diff --git a/hypha/apply/determinations/tests/test_views.py b/hypha/apply/determinations/tests/test_views.py index 3c773fddb796829e6ca065b41c9aca6f4fd9480f..34990a9f528c2e0d29995b84a63e46315c2613c8 100644 --- a/hypha/apply/determinations/tests/test_views.py +++ b/hypha/apply/determinations/tests/test_views.py @@ -6,7 +6,7 @@ from django.test import RequestFactory, override_settings from django.urls import reverse_lazy from hypha.apply.activity.models import Activity -from hypha.apply.determinations.models import ACCEPTED, NEEDS_MORE_INFO, REJECTED +from hypha.apply.determinations.options import ACCEPTED, NEEDS_MORE_INFO, REJECTED from hypha.apply.determinations.views import BatchDeterminationCreateView from hypha.apply.funds.models import ApplicationSubmission from hypha.apply.funds.tests.factories import ApplicationSubmissionFactory diff --git a/hypha/apply/determinations/utils.py b/hypha/apply/determinations/utils.py index 6fe8381cb35c57e210e225f6bcdce27dec77b7b6..40210657a364577953b0871195d2b2e8ecb4d3b2 100644 --- a/hypha/apply/determinations/utils.py +++ b/hypha/apply/determinations/utils.py @@ -1,6 +1,6 @@ from hypha.apply.funds.workflow import DETERMINATION_OUTCOMES -from .models import DETERMINATION_TO_OUTCOME, TRANSITION_DETERMINATION +from .options import DETERMINATION_TO_OUTCOME, TRANSITION_DETERMINATION OUTCOME_TO_DETERMINATION = { v: k diff --git a/hypha/apply/determinations/views.py b/hypha/apply/determinations/views.py index 9e92c9da60c5e182e616c48c3a64a48871937aa6..cb65e3a343060faf686405d2a917b7e59acd412c 100644 --- a/hypha/apply/determinations/views.py +++ b/hypha/apply/determinations/views.py @@ -19,24 +19,25 @@ from hypha.apply.activity.models import Activity from hypha.apply.funds.models import ApplicationSubmission from hypha.apply.funds.workflow import DETERMINATION_OUTCOMES from hypha.apply.projects.models import Project +from hypha.apply.stream_forms.models import BaseStreamForm from hypha.apply.users.decorators import staff_required from hypha.apply.utils.views import CreateOrUpdateView, ViewDispatcher +from .blocks import DeterminationBlock from .forms import ( BatchConceptDeterminationForm, + BatchDeterminationForm, BatchProposalDeterminationForm, ConceptDeterminationForm, + DeterminationModelForm, ProposalDeterminationForm, ) -from .models import ( - NEEDS_MORE_INFO, - TRANSITION_DETERMINATION, - Determination, - DeterminationMessageSettings, -) +from .models import Determination, DeterminationMessageSettings +from .options import DETERMINATION_CHOICES, NEEDS_MORE_INFO, TRANSITION_DETERMINATION from .utils import ( can_create_determination, can_edit_determination, + determination_actions, has_final_determination, outcome_from_actions, transition_from_outcome, @@ -63,8 +64,47 @@ def get_form_for_stage(submission, batch=False, edit=False): return forms[index] +def get_fields_for_stages(submissions): + forms_fields = [ + get_fields_for_stage(submission) + for submission in submissions + ] + if not all(i == forms_fields[0] for i in forms_fields): + raise ValueError('Submissions expect different forms - please contact admin') + return forms_fields[0] + + +def get_fields_for_stage(submission): + forms = submission.get_from_parent('determination_forms').all() + index = submission.workflow.stages.index(submission.stage) + try: + return forms[index].form.form_fields + except IndexError: + return forms[0].form.form_fields + + +def outcome_choices_for_phase(submission, user): + """ + Outcome choices correspond to Phase transitions. + We need to filter out non-matching choices. + i.e. a transition to In Review is not a determination, while Needs more info or Rejected are. + """ + available_choices = set() + choices = dict(DETERMINATION_CHOICES) + for transition_name in determination_actions(user, submission): + try: + determination_type = TRANSITION_DETERMINATION[transition_name] + except KeyError: + pass + else: + available_choices.add((determination_type, choices[determination_type])) + + return available_choices + + @method_decorator(staff_required, name='dispatch') -class BatchDeterminationCreateView(CreateView): +class BatchDeterminationCreateView(BaseStreamForm, CreateView): + submission_form_class = BatchDeterminationForm template_name = 'determinations/batch_determination_form.html' def dispatch(self, *args, **kwargs): @@ -100,8 +140,44 @@ class BatchDeterminationCreateView(CreateView): kwargs.pop('instance') return kwargs + def check_all_submissions_are_of_same_type(self, submissions): + """ + Checks if all the submission as the new determination form attached to it. + + Or all should be using the old determination forms. + + We can not create batch determination with submissions using two different + type of forms. + """ + return len(set( + [ + submission.is_determination_form_attached + for submission in submissions + ] + )) == 1 + def get_form_class(self): - return get_form_for_stages(self.get_submissions()) + submissions = self.get_submissions() + if not self.check_all_submissions_are_of_same_type(submissions): + raise ValueError( + "All selected submissions excpects determination forms attached" + " - please contact admin" + ) + if not submissions[0].is_determination_form_attached: + # If all the submission has same type of forms but they are not the + # new streamfield forms then use the old determination forms. + return get_form_for_stages(submissions) + form_fields = self.get_form_fields() + field_blocks = self.get_defined_fields() + for field_block in field_blocks: + if isinstance(field_block.block, DeterminationBlock): + # Outcome is already set in case of batch determinations so we do + # not need to render this field. + form_fields.pop(field_block.id) + return type('WagtailStreamForm', (self.submission_form_class,), form_fields) + + def get_defined_fields(self): + return get_fields_for_stages(self.get_submissions()) def get_context_data(self, **kwargs): outcome = TRANSITION_DETERMINATION[self.get_action()] @@ -121,7 +197,6 @@ class BatchDeterminationCreateView(CreateView): determination.submission.id: determination for determination in form.instances } - messenger( MESSAGES.BATCH_DETERMINATION_OUTCOME, request=self.request, @@ -139,6 +214,10 @@ class BatchDeterminationCreateView(CreateView): 'Unable to determine submission "{title}" as already determined'.format(title=submission.title), ) else: + if submission.is_determination_form_attached: + determination.form_fields = self.get_defined_fields() + determination.message = form.cleaned_data[determination.message_field.id] + determination.save() transition = transition_from_outcome(form.cleaned_data.get('outcome'), submission) if determination.outcome == NEEDS_MORE_INFO: @@ -190,7 +269,8 @@ class BatchDeterminationCreateView(CreateView): @method_decorator(staff_required, name='dispatch') -class DeterminationCreateOrUpdateView(CreateOrUpdateView): +class DeterminationCreateOrUpdateView(BaseStreamForm, CreateOrUpdateView): + submission_form_class = DeterminationModelForm model = Determination template_name = 'determinations/determination_form.html' @@ -234,8 +314,8 @@ class DeterminationCreateOrUpdateView(CreateOrUpdateView): **kwargs ) - def get_form_class(self): - return get_form_for_stage(self.submission) + def get_defined_fields(self): + return get_fields_for_stage(self.submission) def get_form_kwargs(self): kwargs = super().get_form_kwargs() @@ -245,12 +325,29 @@ class DeterminationCreateOrUpdateView(CreateOrUpdateView): kwargs['site'] = Site.find_for_request(self.request) return kwargs + def get_form_class(self): + if not self.submission.is_determination_form_attached: + # If new determination forms are not attached use the old ones. + return get_form_for_stage(self.submission) + form_fields = self.get_form_fields() + field_blocks = self.get_defined_fields() + for field_block in field_blocks: + if isinstance(field_block.block, DeterminationBlock): + outcome_choices = outcome_choices_for_phase( + self.submission, self.request.user + ) + # Outcome field choices need to be set according to the phase. + form_fields[field_block.id].choices = outcome_choices + return type('WagtailStreamForm', (self.submission_form_class,), form_fields) + def get_success_url(self): return self.submission.get_absolute_url() def form_valid(self, form): - super().form_valid(form) + if self.submission.is_determination_form_attached: + form.instance.form_fields = self.get_defined_fields() + super().form_valid(form) if self.object.is_draft: return HttpResponseRedirect(self.submission.get_absolute_url()) @@ -263,8 +360,7 @@ class DeterminationCreateOrUpdateView(CreateOrUpdateView): related=self.object, ) proposal_form = form.cleaned_data.get('proposal_form') - - transition = transition_from_outcome(form.cleaned_data.get('outcome'), self.submission) + transition = transition_from_outcome(int(self.object.outcome), self.submission) if self.object.outcome == NEEDS_MORE_INFO: # We keep a record of the message sent to the user in the comment @@ -275,7 +371,6 @@ class DeterminationCreateOrUpdateView(CreateOrUpdateView): source=self.submission, related_object=self.object, ) - self.submission.perform_transition( transition, self.request.user, @@ -434,41 +529,57 @@ class DeterminationDetailView(ViewDispatcher): @method_decorator(staff_required, name='dispatch') -class DeterminationEditView(UpdateView): +class DeterminationEditView(BaseStreamForm, UpdateView): + submission_form_class = DeterminationModelForm model = Determination - - def get_object(self, queryset=None): - return self.model.objects.get(submission=self.submission, id=self.kwargs['pk']) - - def dispatch(self, request, *args, **kwargs): - self.submission = get_object_or_404(ApplicationSubmission, id=self.kwargs['submission_pk']) - return super().dispatch(request, *args, **kwargs) + template_name = 'determinations/determination_form.html' + raise_exception = True def get_context_data(self, **kwargs): site = Site.find_for_request(self.request) determination_messages = DeterminationMessageSettings.for_site(site) + determination = self.get_object() return super().get_context_data( - submission=self.submission, - message_templates=determination_messages.get_for_stage(self.submission.stage.name), + submission=determination.submission, + message_templates=determination_messages.get_for_stage( + determination.submission.stage.name + ), **kwargs ) - def get_form_class(self): - return get_form_for_stage(self.submission) + def get_defined_fields(self): + determination = self.get_object() + return get_fields_for_stage(determination.submission) def get_form_kwargs(self): + determiantion = self.get_object() kwargs = super().get_form_kwargs() kwargs['user'] = self.request.user - kwargs['submission'] = self.submission + kwargs['submission'] = determiantion.submission + kwargs['edit'] = True kwargs['action'] = self.request.GET.get('action') kwargs['site'] = Site.find_for_request(self.request) - kwargs['edit'] = True + if self.object: + kwargs['initial'] = self.object.form_data return kwargs + def get_form_class(self): + determination = self.get_object() + if not determination.use_new_determination_form: + return get_form_for_stage(determination.submission) + form_fields = self.get_form_fields() + field_blocks = self.get_defined_fields() + for field_block in field_blocks: + if isinstance(field_block.block, DeterminationBlock): + # Outcome can not be edited after being set once, so we do not + # need to render this field. + form_fields.pop(field_block.id) + return type('WagtailStreamForm', (self.submission_form_class,), form_fields) + def form_valid(self, form): super().form_valid(form) - + determination = self.get_object() messenger( MESSAGES.DETERMINATION_OUTCOME, request=self.request, @@ -477,4 +588,4 @@ class DeterminationEditView(UpdateView): related=self.object, ) - return HttpResponseRedirect(self.submission.get_absolute_url()) + return HttpResponseRedirect(determination.submission.get_absolute_url()) diff --git a/hypha/apply/funds/admin.py b/hypha/apply/funds/admin.py index 91d7bc8b3947eafbaa2023a6b85610c1ede9d223..5a48fa379e1c497aa6126ccdcf483e7d2ec96eed 100644 --- a/hypha/apply/funds/admin.py +++ b/hypha/apply/funds/admin.py @@ -5,6 +5,7 @@ from wagtail.contrib.modeladmin.helpers import PermissionHelper from wagtail.contrib.modeladmin.options import ModelAdmin, ModelAdminGroup from hypha.apply.categories.admin import CategoryAdmin, MetaTermAdmin +from hypha.apply.determinations.admin import DeterminationFormAdmin from hypha.apply.funds.models import ReviewerRole, ScreeningStatus from hypha.apply.review.admin import ReviewFormAdmin from hypha.apply.utils.admin import ListRelatedMixin @@ -76,6 +77,22 @@ class RoundAdmin(BaseRoundAdmin): return mark_safe('<br />'.join(urls)) + def determination_forms(self, obj): + def build_urls(determinations): + for determination in determinations: + url = reverse( + 'determination_determinationform_modeladmin_edit', + args=[determination.form.id] + ) + yield f'<a href="{url}">{determination}</a>' + + urls = list(build_urls(obj.determination_forms.all())) + + if not urls: + return + + return mark_safe('<br />'.join(urls)) + class ScreeningStatusPermissionHelper(PermissionHelper): def user_can_edit_obj(self, user, obj): @@ -185,6 +202,7 @@ class ApplyAdminGroup(ModelAdminGroup): RFPAdmin, ApplicationFormAdmin, ReviewFormAdmin, + DeterminationFormAdmin, CategoryAdmin, ScreeningStatusAdmin, ReviewerRoleAdmin, diff --git a/hypha/apply/funds/admin_forms.py b/hypha/apply/funds/admin_forms.py index 4d6c7b0b74526aa1d25a51041ea9245e8cd49626..1cca6f9d2be4d20c9e877f863a8a8fdb9730a63b 100644 --- a/hypha/apply/funds/admin_forms.py +++ b/hypha/apply/funds/admin_forms.py @@ -12,12 +12,16 @@ class WorkflowFormAdminForm(WagtailAdminPageForm): workflow = WORKFLOWS[cleaned_data['workflow_name']] application_forms = self.formsets['forms'] review_forms = self.formsets['review_forms'] + determination_forms = self.formsets['determination_forms'] number_of_stages = len(workflow.stages) self.validate_application_forms(workflow, application_forms) if number_of_stages == 1: self.validate_stages_equal_forms(workflow, application_forms) self.validate_stages_equal_forms(workflow, review_forms, form_type="Review form") + self.validate_stages_equal_forms( + workflow, determination_forms, form_type="Determination form" + ) return cleaned_data diff --git a/hypha/apply/funds/migrations/0077_add_determination_stream_field_forms.py b/hypha/apply/funds/migrations/0077_add_determination_stream_field_forms.py new file mode 100644 index 0000000000000000000000000000000000000000..7c84636f35de28766e593862d99feac8fdc5b1c0 --- /dev/null +++ b/hypha/apply/funds/migrations/0077_add_determination_stream_field_forms.py @@ -0,0 +1,55 @@ +# Generated by Django 2.2.13 on 2020-07-01 10:19 + +from django.db import migrations, models +import django.db.models.deletion +import modelcluster.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('determinations', '0010_add_determination_stream_field_forms'), + ('funds', '0076_multi_input_char_block'), + ] + + operations = [ + migrations.CreateModel( + name='RoundBaseDeterminationForm', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sort_order', models.IntegerField(blank=True, editable=False, null=True)), + ('form', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='determinations.DeterminationForm')), + ('round', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='determination_forms', to='funds.RoundBase')), + ], + options={ + 'ordering': ['sort_order'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='LabBaseDeterminationForm', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sort_order', models.IntegerField(blank=True, editable=False, null=True)), + ('form', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='determinations.DeterminationForm')), + ('lab', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='determination_forms', to='funds.LabBase')), + ], + options={ + 'ordering': ['sort_order'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ApplicationBaseDeterminationForm', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('sort_order', models.IntegerField(blank=True, editable=False, null=True)), + ('application', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='determination_forms', to='funds.ApplicationBase')), + ('form', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='determinations.DeterminationForm')), + ], + options={ + 'ordering': ['sort_order'], + 'abstract': False, + }, + ), + ] diff --git a/hypha/apply/funds/models/applications.py b/hypha/apply/funds/models/applications.py index 19d1a52289e538580122569cd1bbcda6f3dadc70..abd210a8bf6ca961df72d88bab969c4877df7059 100644 --- a/hypha/apply/funds/models/applications.py +++ b/hypha/apply/funds/models/applications.py @@ -187,6 +187,7 @@ class RoundBase(WorkflowStreamForm, SubmittableStreamForm): # type: ignore # Forms comes from parental key in models/forms.py ReadOnlyInlinePanel('forms', help_text="Copied from the fund."), ReadOnlyInlinePanel('review_forms', help_text="Copied from the fund."), + ReadOnlyInlinePanel('determination_forms', help_text="Copied from the fund."), ] edit_handler = TabbedInterface([ @@ -227,6 +228,7 @@ class RoundBase(WorkflowStreamForm, SubmittableStreamForm): # type: ignore # Would be nice to do this using model clusters as part of the __init__ self._copy_forms('forms') self._copy_forms('review_forms') + self._copy_forms('determination_forms') def _copy_forms(self, field): for form in getattr(self.get_parent().specific, field).all(): diff --git a/hypha/apply/funds/models/forms.py b/hypha/apply/funds/models/forms.py index e1868c0bae1b1f8cd5a145e64e7c627351728749..351110457b4aeb86cc31bd0f9f75c3345ca69571 100644 --- a/hypha/apply/funds/models/forms.py +++ b/hypha/apply/funds/models/forms.py @@ -65,6 +65,34 @@ class LabBaseForm(AbstractRelatedForm): lab = ParentalKey('LabBase', related_name='forms') +class AbstractRelatedDeterminationForm(Orderable): + class Meta(Orderable.Meta): + abstract = True + + form = models.ForeignKey( + 'determinations.DeterminationForm', on_delete=models.PROTECT + ) + + panels = [ + FilteredFieldPanel('form', filter_query={ + 'roundbasedeterminationform__isnull': True, + }) + ] + + @property + def fields(self): + return self.form.form_fields + + def __eq__(self, other): + try: + return self.fields == other.fields and self.sort_order == other.sort_order + except AttributeError: + return False + + def __str__(self): + return self.form.name + + class AbstractRelatedReviewForm(Orderable): class Meta(Orderable.Meta): abstract = True @@ -101,3 +129,15 @@ class RoundBaseReviewForm(AbstractRelatedReviewForm): class LabBaseReviewForm(AbstractRelatedReviewForm): lab = ParentalKey('LabBase', related_name='review_forms') + + +class ApplicationBaseDeterminationForm(AbstractRelatedDeterminationForm): + application = ParentalKey('ApplicationBase', related_name='determination_forms') + + +class RoundBaseDeterminationForm(AbstractRelatedDeterminationForm): + round = ParentalKey('RoundBase', related_name='determination_forms') + + +class LabBaseDeterminationForm(AbstractRelatedDeterminationForm): + lab = ParentalKey('LabBase', related_name='determination_forms') diff --git a/hypha/apply/funds/models/submissions.py b/hypha/apply/funds/models/submissions.py index f13ae29f76522ca729a8f6f55bbe325f366f61d1..b79a0d1fe1a4acb5aeb2f8e3d35e6727f31227db 100644 --- a/hypha/apply/funds/models/submissions.py +++ b/hypha/apply/funds/models/submissions.py @@ -536,6 +536,19 @@ class ApplicationSubmission( # We are a lab submission return getattr(self.page.specific, attribute) + @property + def is_determination_form_attached(self): + """ + We use old django determination forms but now as we are moving + to streamfield determination forms which can be created and attached + to funds in admin. + + This method checks if there are new determination forms attached to the + submission or we would still use the old determination forms for backward + compatibility. + """ + return self.get_from_parent('determination_forms').count() > 0 + def progress_application(self, **kwargs): target = None for phase in STAGE_CHANGE_ACTIONS: diff --git a/hypha/apply/funds/models/utils.py b/hypha/apply/funds/models/utils.py index 1c5de3edc8b781d920e6da62a79858a408000ccc..1a171e76e3b59440a48dcf09d8b96066629d2d49 100644 --- a/hypha/apply/funds/models/utils.py +++ b/hypha/apply/funds/models/utils.py @@ -115,7 +115,8 @@ class WorkflowStreamForm(WorkflowHelpers, AbstractStreamForm): # type: ignore content_panels = AbstractStreamForm.content_panels + [ FieldPanel('workflow_name'), InlinePanel('forms', label="Forms"), - InlinePanel('review_forms', label="Review Forms") + InlinePanel('review_forms', label="Review Forms"), + InlinePanel('determination_forms', label="Determination Forms") ] diff --git a/hypha/apply/funds/tests/test_admin_form.py b/hypha/apply/funds/tests/test_admin_form.py index 0975734e77d6b51ca3161d05ea394e5c26a1d2c2..689c665e0365db485e906a136538654b8678f5f9 100644 --- a/hypha/apply/funds/tests/test_admin_form.py +++ b/hypha/apply/funds/tests/test_admin_form.py @@ -1,6 +1,7 @@ import factory from django.test import TestCase +from hypha.apply.determinations.tests.factories import DeterminationFormFactory from hypha.apply.funds.models import FundType from hypha.apply.review.tests.factories import ReviewFormFactory @@ -37,12 +38,15 @@ def formset_base(field, total, delete, factory, same=False, form_stage_info=None return base_data -def form_data(num_appl_forms=0, num_review_forms=0, delete=0, stages=1, same_forms=False, form_stage_info=[1]): +def form_data(num_appl_forms=0, num_review_forms=0, num_determination_forms=0, delete=0, stages=1, same_forms=False, form_stage_info=[1]): form_data = formset_base( 'forms', num_appl_forms, delete, same=same_forms, factory=ApplicationFormFactory, form_stage_info=form_stage_info) review_form_data = formset_base('review_forms', num_review_forms, False, same=same_forms, factory=ReviewFormFactory) + determination_form_data = formset_base('determination_forms', num_determination_forms, False, same=same_forms, factory=DeterminationFormFactory) + form_data.update(review_form_data) + form_data.update(determination_form_data) fund_data = factory.build(dict, FACTORY_CLASS=FundTypeFactory) fund_data['workflow_name'] = workflow_for_stages(stages) @@ -64,15 +68,15 @@ class TestWorkflowFormAdminForm(TestCase): self.assertTrue(form.errors['__all__']) def test_validates_with_one_form_one_stage(self): - form = self.submit_data(form_data(1, 1)) + form = self.submit_data(form_data(1, 1, 1)) self.assertTrue(form.is_valid(), form.errors.as_text()) def test_validates_with_one_form_one_stage_with_deleted(self): - form = self.submit_data(form_data(1, 1, delete=1, form_stage_info=[2, 1])) + form = self.submit_data(form_data(1, 1, 1, delete=1, form_stage_info=[2, 1])) self.assertTrue(form.is_valid(), form.errors.as_text()) def test_doesnt_validates_with_two_forms_one_stage(self): - form = self.submit_data(form_data(2, 2, form_stage_info=[1, 2])) + form = self.submit_data(form_data(2, 2, 2, form_stage_info=[1, 2])) self.assertFalse(form.is_valid()) self.assertTrue(form.errors['__all__']) formset_errors = form.formsets['forms'].errors @@ -82,14 +86,14 @@ class TestWorkflowFormAdminForm(TestCase): self.assertTrue(formset_errors[1]['form']) def test_can_save_two_forms(self): - form = self.submit_data(form_data(2, 2, stages=2, form_stage_info=[1, 2])) + form = self.submit_data(form_data(2, 2, 2, stages=2, form_stage_info=[1, 2])) self.assertTrue(form.is_valid()) def test_can_save_multiple_forms_stage_two(self): - form = self.submit_data(form_data(3, 2, stages=2, form_stage_info=[1, 2, 2])) + form = self.submit_data(form_data(3, 2, 2, stages=2, form_stage_info=[1, 2, 2])) self.assertTrue(form.is_valid()) def test_doesnt_validates_with_two_first_stage_forms_in_two_stage(self): - form = self.submit_data(form_data(2, 2, stages=2, form_stage_info=[1, 1])) + form = self.submit_data(form_data(2, 2, 2, stages=2, form_stage_info=[1, 1])) self.assertFalse(form.is_valid()) self.assertTrue(form.errors['__all__']) diff --git a/hypha/apply/funds/tests/test_admin_views.py b/hypha/apply/funds/tests/test_admin_views.py index 0fc27921618046fa1beb1238b26a697fb6ef99f8..a9e2ab0ad9112886f348f8842c168e29a18950af 100644 --- a/hypha/apply/funds/tests/test_admin_views.py +++ b/hypha/apply/funds/tests/test_admin_views.py @@ -17,13 +17,14 @@ class TestFundCreationView(TestCase): cls.user = SuperUserFactory() cls.home = ApplyHomePageFactory() - def create_page(self, appl_forms=1, review_forms=1, stages=1, same_forms=False, form_stage_info=[1]): + def create_page(self, appl_forms=1, review_forms=1, determination_forms=1, stages=1, same_forms=False, form_stage_info=[1]): self.client.force_login(self.user) url = reverse('wagtailadmin_pages:add', args=('funds', 'fundtype', self.home.id)) data = form_data( appl_forms, review_forms, + determination_forms, same_forms=same_forms, stages=stages, form_stage_info=form_stage_info, @@ -47,21 +48,25 @@ class TestFundCreationView(TestCase): fund = self.create_page() self.assertEqual(fund.forms.count(), 1) self.assertEqual(fund.review_forms.count(), 1) + self.assertEqual(fund.determination_forms.count(), 1) def test_can_create_multi_phase_fund(self): - fund = self.create_page(2, 2, stages=2, form_stage_info=[1, 2]) + fund = self.create_page(2, 2, 2, stages=2, form_stage_info=[1, 2]) self.assertEqual(fund.forms.count(), 2) self.assertEqual(fund.review_forms.count(), 2) + self.assertEqual(fund.determination_forms.count(), 2) def test_can_create_multiple_forms_second_stage_in_fund(self): - fund = self.create_page(4, 2, stages=2, form_stage_info=[1, 2, 2, 2]) + fund = self.create_page(4, 2, 2, stages=2, form_stage_info=[1, 2, 2, 2]) self.assertEqual(fund.forms.count(), 4) self.assertEqual(fund.review_forms.count(), 2) + self.assertEqual(fund.determination_forms.count(), 2) def test_can_create_multi_phase_fund_reuse_forms(self): - fund = self.create_page(2, 2, same_forms=True, stages=2, form_stage_info=[1, 2]) + fund = self.create_page(2, 2, 2, same_forms=True, stages=2, form_stage_info=[1, 2]) self.assertEqual(fund.forms.count(), 2) self.assertEqual(fund.review_forms.count(), 2) + self.assertEqual(fund.determination_forms.count(), 2) class TestRoundIndexView(WagtailTestUtils, TestCase):