diff --git a/hypha/apply/funds/migrations/0100_alter_applicationbase_labbase_approval_form.py b/hypha/apply/funds/migrations/0100_alter_applicationbase_labbase_approval_form.py new file mode 100644 index 0000000000..dd958a461b --- /dev/null +++ b/hypha/apply/funds/migrations/0100_alter_applicationbase_labbase_approval_form.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.13 on 2022-07-08 11:28 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0053_projectapprovalform'), + ('funds', '0099_auto_20220629_1339'), + ] + + operations = [ + migrations.AddField( + model_name='applicationbase', + name='approval_form', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='funds', to='application_projects.projectapprovalform'), + ), + migrations.AddField( + model_name='labbase', + name='approval_form', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='labs', to='application_projects.projectapprovalform'), + ), + ] diff --git a/hypha/apply/funds/models/applications.py b/hypha/apply/funds/models/applications.py index ef24ed5599..13837638bc 100644 --- a/hypha/apply/funds/models/applications.py +++ b/hypha/apply/funds/models/applications.py @@ -71,6 +71,14 @@ class ApplicationBase(EmailForm, WorkflowStreamForm): # type: ignore blank=True, ) + approval_form = models.ForeignKey( + 'application_projects.ProjectApprovalForm', + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name='funds', + ) + guide_link = models.URLField(blank=True, max_length=255, help_text=_('Link to the apply guide.')) slack_channel = models.CharField(blank=True, max_length=128, help_text=_('The slack #channel for notifications. If left empty, notifications will go to the default channel.')) @@ -108,6 +116,7 @@ def serve(self, request): return self.open_round.serve(request) content_panels = WorkflowStreamForm.content_panels + [ + FieldPanel('approval_form'), FieldPanel('reviewers', widget=forms.SelectMultiple(attrs={'size': '16'})), FieldPanel('guide_link'), FieldPanel('slack_channel'), @@ -409,6 +418,14 @@ class LabBase(EmailForm, WorkflowStreamForm, SubmittableStreamForm): # type: ig blank=True, ) + approval_form = models.ForeignKey( + 'application_projects.ProjectApprovalForm', + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name='labs', + ) + guide_link = models.URLField(blank=True, max_length=255, help_text=_('Link to the apply guide.')) slack_channel = models.CharField(blank=True, max_length=128, help_text=_('The slack #channel for notifications.')) @@ -417,6 +434,7 @@ class LabBase(EmailForm, WorkflowStreamForm, SubmittableStreamForm): # type: ig subpage_types = [] # type: ignore content_panels = WorkflowStreamForm.content_panels + [ + FieldPanel('approval_form'), FieldPanel('lead'), FieldPanel('reviewers', widget=forms.SelectMultiple(attrs={'size': '16'})), FieldPanel('guide_link'), diff --git a/hypha/apply/funds/tests/factories/models.py b/hypha/apply/funds/tests/factories/models.py index 9da35a9b8f..59727aa612 100644 --- a/hypha/apply/funds/tests/factories/models.py +++ b/hypha/apply/funds/tests/factories/models.py @@ -82,6 +82,7 @@ class Params: # Will need to update how the stages are identified as Fund Page changes workflow_name = factory.LazyAttribute(lambda o: workflow_for_stages(o.workflow_stages)) + approval_form = factory.SubFactory('hypha.apply.projects.tests.factories.ProjectApprovalFormFactory') @factory.post_generation def forms(self, create, extracted, **kwargs): diff --git a/hypha/apply/funds/tests/test_admin_form.py b/hypha/apply/funds/tests/test_admin_form.py index 8011956340..d90622fe48 100644 --- a/hypha/apply/funds/tests/test_admin_form.py +++ b/hypha/apply/funds/tests/test_admin_form.py @@ -54,6 +54,7 @@ def form_data(num_appl_forms=0, num_review_forms=0, num_determination_forms=0, n fund_data['workflow_name'] = workflow_for_stages(stages) form_data.update(fund_data) + form_data.update(approval_form='') return form_data diff --git a/hypha/apply/projects/admin.py b/hypha/apply/projects/admin.py index 711563188a..2192a90502 100644 --- a/hypha/apply/projects/admin.py +++ b/hypha/apply/projects/admin.py @@ -1,6 +1,7 @@ from wagtail.contrib.modeladmin.options import ModelAdmin, ModelAdminGroup -from .models import DocumentCategory +from .admin_views import CreateProjectApprovalFormView, EditProjectApprovalFormView +from .models import DocumentCategory, ProjectApprovalForm class DocumentCategoryAdmin(ModelAdmin): @@ -9,9 +10,26 @@ class DocumentCategoryAdmin(ModelAdmin): list_display = ('name', 'recommended_minimum',) +class ProjectApprovalFormAdmin(ModelAdmin): + model = ProjectApprovalForm + menu_icon = 'form' + list_display = ('name', 'used_by',) + create_view_class = CreateProjectApprovalFormView + edit_view_class = EditProjectApprovalFormView + + def used_by(self, obj): + rows = list() + for field in ('funds', 'labs',): + related = ', '.join(getattr(obj, f'{field}').values_list('title', flat=True)) + if related: + rows.append(related) + return ', '.join(rows) + + class ManageAdminGoup(ModelAdminGroup): menu_label = 'Manage' menu_icon = 'folder-open-inverse' items = ( DocumentCategoryAdmin, + ProjectApprovalFormAdmin, ) diff --git a/hypha/apply/projects/admin_views.py b/hypha/apply/projects/admin_views.py new file mode 100644 index 0000000000..1356717a7d --- /dev/null +++ b/hypha/apply/projects/admin_views.py @@ -0,0 +1,37 @@ +from wagtail.contrib.modeladmin.views import CreateView, EditView + +from hypha.apply.utils.blocks import show_admin_form_error_messages + + +class CreateProjectApprovalFormView(CreateView): + + def get_form(self): + """ + Overriding this method to disable the single file block option from Project Approval Form. + Set 0 as max_number of single file can be added to make single file block option unavailable or disable. + """ + form = super(CreateProjectApprovalFormView, self).get_form() + form.fields['form_fields'].block.meta.block_counts = {'file': {'min_num': 0, 'max_num': 0}} + return form + + def form_invalid(self, form): + show_admin_form_error_messages(self.request, form) + return self.render_to_response(self.get_context_data(form=form)) + + +class EditProjectApprovalFormView(EditView): + + def get_form(self): + """ + Overriding this method to disable the single file block option from Project Approval Form. + Calculating the number of Single file blocks that exist in the instance already. + And set that count as max_number of single file block can be added to make single file option disable. + """ + form = super(EditProjectApprovalFormView, self).get_form() + single_file_count = sum(1 for block in self.get_instance().form_fields.raw_data if block['type'] == 'file') + form.fields['form_fields'].block.meta.block_counts = {'file': {'min_num': 0, 'max_num': single_file_count}} + return form + + def form_invalid(self, form): + show_admin_form_error_messages(self.request, form) + return self.render_to_response(self.get_context_data(form=form)) diff --git a/hypha/apply/projects/forms/project.py b/hypha/apply/projects/forms/project.py index 7e0da4777b..c7e3e2c732 100644 --- a/hypha/apply/projects/forms/project.py +++ b/hypha/apply/projects/forms/project.py @@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _ from hypha.apply.funds.models import ApplicationSubmission +from hypha.apply.stream_forms.forms import StreamBaseForm from hypha.apply.users.groups import STAFF_GROUP_NAME from ..models.project import COMMITTED, Approval, Contract, PacketFile, Project @@ -76,30 +77,38 @@ def clean_by(self): return by -class ProjectApprovalForm(forms.ModelForm): +class MixedMetaClass(type(StreamBaseForm), type(forms.ModelForm)): + pass + + +class ProjectApprovalForm(StreamBaseForm, forms.ModelForm, metaclass=MixedMetaClass): class Meta: fields = [ 'title', - 'value', - 'proposed_start', - 'proposed_end', ] model = Project widgets = { - 'title': forms.TextInput, - 'proposed_end': forms.DateInput, - 'proposed_start': forms.DateInput, + 'title': forms.HiddenInput() } def __init__(self, *args, extra_fields=None, **kwargs): super().__init__(*args, **kwargs) - if extra_fields: - self.fields = { - **self.fields, - **extra_fields, - } + + 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, *args, **kwargs): + self.instance.form_data = { + field: self.cleaned_data[field] + for field in self.instance.question_field_ids + if field in self.cleaned_data + } self.instance.user_has_updated_details = True return super().save(*args, **kwargs) diff --git a/hypha/apply/projects/migrations/0053_projectapprovalform.py b/hypha/apply/projects/migrations/0053_projectapprovalform.py new file mode 100644 index 0000000000..6b5d8c4887 --- /dev/null +++ b/hypha/apply/projects/migrations/0053_projectapprovalform.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.13 on 2022-07-08 11:28 + +from django.db import migrations, models +import hypha.apply.stream_forms.blocks +import hypha.apply.stream_forms.models +import wagtail.core.blocks +import wagtail.core.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0052_alter_project_form_fields'), + ] + + operations = [ + migrations.CreateModel( + name='ProjectApprovalForm', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('form_fields', wagtail.core.fields.StreamField([('text_markup', wagtail.core.blocks.RichTextBlock(group='Custom', label='Section text')), ('header_markup', wagtail.core.blocks.StructBlock([('heading_text', wagtail.core.blocks.CharBlock(form_classname='title', required=True)), ('size', wagtail.core.blocks.ChoiceBlock(choices=[('h2', 'H2'), ('h3', 'H3'), ('h4', 'H4')]))], group='Custom', label='Section header')), ('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')), ('multi_inputs_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)), ('number_of_inputs', wagtail.core.blocks.IntegerBlock(default=2, label='Max number of inputs')), ('add_button_text', wagtail.core.blocks.CharBlock(default='Add new item', 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')), ('number', 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.CharBlock(label='Default value', required=False))], group='Fields')), ('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')), ('radios', 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')), ('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')), ('checkboxes', 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)), ('checkboxes', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Checkbox')))], group='Fields')), ('date', 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.DateBlock(required=False))], group='Fields')), ('time', 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.TimeBlock(required=False))], group='Fields')), ('datetime', 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.DateTimeBlock(required=False))], group='Fields')), ('image', 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))], group='Fields')), ('file', 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))], group='Fields')), ('multi_file', 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))], group='Fields')), ('group_toggle', 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(default=True, label='Required', required=False)), ('choices', wagtail.core.blocks.ListBlock(wagtail.core.blocks.CharBlock(label='Choice'), help_text='Please create only two choices for toggle. First choice will revel the group and the second hide it. Additional choices will be ignored.'))], group='Custom')), ('group_toggle_end', hypha.apply.stream_forms.blocks.GroupToggleEndBlock(group='Custom'))])), + ], + bases=(hypha.apply.stream_forms.models.BaseStreamForm, models.Model), + ), + ] diff --git a/hypha/apply/projects/models/__init__.py b/hypha/apply/projects/models/__init__.py index 2aeab4cc6b..7794e9e2ce 100644 --- a/hypha/apply/projects/models/__init__.py +++ b/hypha/apply/projects/models/__init__.py @@ -6,6 +6,7 @@ DocumentCategory, PacketFile, Project, + ProjectApprovalForm, ProjectSettings, ) from .report import Report, ReportConfig, ReportPrivateFiles, ReportVersion @@ -13,6 +14,7 @@ __all__ = [ 'Project', + 'ProjectApprovalForm', 'ProjectSettings', 'Approval', 'Contract', diff --git a/hypha/apply/projects/models/project.py b/hypha/apply/projects/models/project.py index e3c470cf51..10e5159f20 100644 --- a/hypha/apply/projects/models/project.py +++ b/hypha/apply/projects/models/project.py @@ -16,6 +16,7 @@ from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from wagtail.admin.edit_handlers import FieldPanel, StreamFieldPanel from wagtail.contrib.settings.models import BaseSetting, register_setting from wagtail.core.fields import StreamField @@ -372,6 +373,19 @@ def program_project_id(self): # self.save(update_fields=['sent_to_compliance_at']) +class ProjectApprovalForm(BaseStreamForm, models.Model): + name = models.CharField(max_length=255) + form_fields = StreamField(FormFieldsBlock()) + + panels = [ + FieldPanel('name'), + StreamFieldPanel('form_fields'), + ] + + def __str__(self): + return self.name + + @register_setting class ProjectSettings(BaseSetting): compliance_email = models.TextField("Compliance Email") diff --git a/hypha/apply/projects/templates/application_projects/project_approval_form.html b/hypha/apply/projects/templates/application_projects/project_approval_form.html new file mode 100644 index 0000000000..8520b225d9 --- /dev/null +++ b/hypha/apply/projects/templates/application_projects/project_approval_form.html @@ -0,0 +1,55 @@ +{% extends "base-apply.html" %} +{% load i18n static %} +{% block title %}Editing: {{object.title }}{% endblock %} +{% block content %} +
+ +{% include "forms/includes/form_errors.html" with form=form %} + + + +{% endblock %} + +{% block extra_js %} + + + + + + {% if not show_all_group_fields %} + + {% endif %} +{% endblock %} diff --git a/hypha/apply/projects/tests/factories.py b/hypha/apply/projects/tests/factories.py index a1e710afb1..50c7bee415 100644 --- a/hypha/apply/projects/tests/factories.py +++ b/hypha/apply/projects/tests/factories.py @@ -5,6 +5,10 @@ from django.utils import timezone from hypha.apply.funds.tests.factories import ApplicationSubmissionFactory +from hypha.apply.stream_forms.testing.factories import ( + FormDataFactory, + FormFieldsBlockFactory, +) from hypha.apply.users.tests.factories import StaffFactory, UserFactory from ..models.payment import Invoice, InvoiceDeliverable, SupportingDocument @@ -16,6 +20,7 @@ DocumentCategory, PacketFile, Project, + ProjectApprovalForm, ) from ..models.report import Report, ReportConfig, ReportVersion @@ -53,6 +58,18 @@ class Meta: model = DocumentCategory +class ProjectApprovalFormFactory(factory.django.DjangoModelFactory): + class Meta: + model = ProjectApprovalForm + + name = factory.Faker('word') + form_fields = FormFieldsBlockFactory + + +class ProjectApprovalFormDataFactory(FormDataFactory): + field_factory = FormFieldsBlockFactory + + class ProjectFactory(factory.django.DjangoModelFactory): submission = factory.SubFactory(ApplicationSubmissionFactory) user = factory.SubFactory(UserFactory) @@ -65,6 +82,12 @@ class ProjectFactory(factory.django.DjangoModelFactory): is_locked = False + form_fields = FormFieldsBlockFactory + form_data = factory.SubFactory( + ProjectApprovalFormDataFactory, + form_fields=factory.SelfAttribute('..form_fields'), + ) + class Meta: model = Project diff --git a/hypha/apply/projects/views/project.py b/hypha/apply/projects/views/project.py index 66f2b283f5..b436eb3192 100644 --- a/hypha/apply/projects/views/project.py +++ b/hypha/apply/projects/views/project.py @@ -11,6 +11,7 @@ from django.urls import reverse, reverse_lazy from django.utils import timezone from django.utils.decorators import method_decorator +from django.utils.functional import cached_property from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ from django.views import View @@ -27,6 +28,7 @@ from hypha.apply.activity.messaging import MESSAGES, messenger from hypha.apply.activity.views import ActivityContextMixin, CommentFormView +from hypha.apply.stream_forms.models import BaseStreamForm from hypha.apply.users.decorators import ( approver_required, staff_or_finance_required, @@ -589,9 +591,14 @@ def get(self, request, *args, **kwargs): @method_decorator(staff_required, name='dispatch') -class ProjectApprovalEditView(UpdateView): - form_class = ProjectApprovalForm +class ProjectApprovalEditView(BaseStreamForm, UpdateView): + submission_form_class = ProjectApprovalForm model = Project + template_name = 'application_projects/project_approval_form.html' + + def buttons(self): + yield ('submit', 'primary', _('Submit')) + # yield ('save', 'white', _('Save draft')) def dispatch(self, request, *args, **kwargs): project = self.get_object() @@ -600,7 +607,48 @@ def dispatch(self, request, *args, **kwargs): return redirect(project) return super().dispatch(request, *args, **kwargs) + @cached_property + def approval_form(self): + if self.object.get_defined_fields(): + approval_form = self.object + else: + approval_form = self.object.submission.page.specific.approval_form + + return approval_form + + def get_context_data(self, **kwargs): + return super().get_context_data( + title=self.object.title, + buttons=self.buttons(), + **kwargs + ) + + def get_defined_fields(self): + approval_form = self.object.submission.get_from_parent('approval_form') + if approval_form: + return approval_form.form_fields + return self.object.get_defined_fields() + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + + if self.approval_form: + fields = self.approval_form.get_form_fields() + else: + fields = {} + + kwargs['extra_fields'] = fields + kwargs['initial'].update(self.object.raw_data) + return kwargs + def form_valid(self, form): + try: + form_fields = self.approval_form.form_fields + except AttributeError: + form_fields = [] + + form.instance.form_fields = form_fields + return super().form_valid(form)