diff --git a/hypha/apply/funds/files.py b/hypha/apply/funds/files.py index 27b8fcba75be89f1a6302345c5736347eb69e052..0c4a263c32f94ac4b86a23f5b03ecd111efb7561 100644 --- a/hypha/apply/funds/files.py +++ b/hypha/apply/funds/files.py @@ -5,36 +5,57 @@ from django.urls import reverse from hypha.apply.stream_forms.files import StreamFieldFile -def generate_submission_file_path(submission_id, field_id, file_name): - path = os.path.join("submission", str(submission_id), str(field_id)) +def generate_private_file_path(entity_id, field_id, file_name, path_start="submission"): + path = os.path.join(path_start, str(entity_id), str(field_id)) if file_name.startswith(path): return file_name return os.path.join(path, file_name) -class SubmissionStreamFieldFile(StreamFieldFile): - def get_submission_id(self): +class PrivateStreamFieldFile(StreamFieldFile): + """ + Represents a file from a Wagtail/Hypha Stream Form block. + Aside: with imports in their usual place, there could be circular imports. Deferring or delaying import to methods + resolves the issue. + """ + + def get_entity_id(self): from hypha.apply.funds.models import ApplicationRevision + from hypha.apply.projects.models import ReportVersion - submission_id = self.instance.pk + entity_id = self.instance.pk if isinstance(self.instance, ApplicationRevision): - submission_id = self.instance.submission.pk - return submission_id + entity_id = self.instance.submission.pk + elif isinstance(self.instance, ReportVersion): + # Reports are project documents. + entity_id = self.instance.report.project.pk + return entity_id def generate_filename(self): - return generate_submission_file_path( - self.get_submission_id(), self.field.id, self.name + from hypha.apply.projects.models import ReportVersion + + path_start = "submission" + if isinstance(self.instance, ReportVersion): + path_start = "project" + return generate_private_file_path( + self.get_entity_id(), + self.field.id, + self.name, + path_start=path_start, ) @property def url(self): - return reverse( - "apply:submissions:serve_private_media", - kwargs={ - "pk": self.get_submission_id(), - "field_id": self.field.id, - "file_name": self.basename, - }, - ) + from hypha.apply.projects.models import ReportVersion + + view_name = "apply:submissions:serve_private_media" + kwargs = { + "pk": self.get_entity_id(), + "field_id": self.field.id, + "file_name": self.basename, + } + if isinstance(self.instance, ReportVersion): + view_name = "apply:projects:document" + return reverse(viewname=view_name, kwargs=kwargs) diff --git a/hypha/apply/funds/migrations/0118_labbaseprojectreportform_and_more.py b/hypha/apply/funds/migrations/0118_labbaseprojectreportform_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..802db4a8876865487e3bdd46077e98474402f4b0 --- /dev/null +++ b/hypha/apply/funds/migrations/0118_labbaseprojectreportform_and_more.py @@ -0,0 +1,89 @@ +# Generated by Django 4.2.11 on 2024-04-17 20:38 + +from django.db import migrations, models +import django.db.models.deletion +import modelcluster.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("application_projects", "0082_projectreportform_and_more"), + ("funds", "0117_applicationrevision_is_draft"), + ] + + operations = [ + migrations.CreateModel( + name="LabBaseProjectReportForm", + 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="application_projects.projectreportform", + ), + ), + ( + "lab", + modelcluster.fields.ParentalKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="report_forms", + to="funds.labbase", + ), + ), + ], + options={ + "ordering": ["sort_order"], + "abstract": False, + }, + ), + migrations.CreateModel( + name="ApplicationBaseProjectReportForm", + 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="report_forms", + to="funds.applicationbase", + ), + ), + ( + "form", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="application_projects.projectreportform", + ), + ), + ], + options={ + "ordering": ["sort_order"], + "abstract": False, + }, + ), + ] diff --git a/hypha/apply/funds/models/forms.py b/hypha/apply/funds/models/forms.py index 39ad01c0d69bd4dcb30c1649900eac426326e676..92a5f9e7f35f8fd6f3b19373033fd94afa390423 100644 --- a/hypha/apply/funds/models/forms.py +++ b/hypha/apply/funds/models/forms.py @@ -4,6 +4,7 @@ from wagtail.admin.panels import FieldPanel from wagtail.fields import StreamField from wagtail.models import Orderable +from ...projects.models.project import ProjectReportForm from ..blocks import ApplicationCustomFormFieldsBlock from ..edit_handlers import FilteredFieldPanel @@ -268,3 +269,40 @@ class LabBaseProjectApprovalForm(AbstractRelatedProjectApprovalForm): class LabBaseProjectSOWForm(AbstractRelatedProjectSOWForm): lab = ParentalKey("LabBase", related_name="sow_forms") + + +class AbstractRelatedProjectReportForm(Orderable): + class Meta(Orderable.Meta): + abstract = True + + form = models.ForeignKey(to=ProjectReportForm, on_delete=models.PROTECT) + + @property + def fields(self): + return self.form.form_fields + + def __eq__(self, other): + try: + if self.fields == other.fields and self.sort_order == other.sort_order: + # If the objects are saved to db. pk should also be compared + if hasattr(other, "pk") and hasattr(self, "pk"): + return self.pk == other.pk + return True + return False + except AttributeError: + return False + + def __hash__(self): + fields = [field.id for field in self.fields] + return hash((tuple(fields), self.sort_order, self.pk)) + + def __str__(self): + return self.form.name + + +class ApplicationBaseProjectReportForm(AbstractRelatedProjectReportForm): + application = ParentalKey("ApplicationBase", related_name="report_forms") + + +class LabBaseProjectReportForm(AbstractRelatedProjectReportForm): + lab = ParentalKey("LabBase", related_name="report_forms") diff --git a/hypha/apply/funds/models/mixins.py b/hypha/apply/funds/models/mixins.py index 1d7aae6feeaba73b79252a18a71d8df39ae4a900..b6479d3fdc0ae35b60ffee03f9e83b265f664beb 100644 --- a/hypha/apply/funds/models/mixins.py +++ b/hypha/apply/funds/models/mixins.py @@ -14,7 +14,7 @@ from hypha.apply.stream_forms.blocks import ( from hypha.apply.utils.blocks import SingleIncludeMixin from hypha.apply.utils.storage import PrivateStorage -from ..files import SubmissionStreamFieldFile +from ..files import PrivateStreamFieldFile __all__ = ["AccessFormData"] @@ -31,7 +31,7 @@ class AccessFormData: - form_fields > streamfield containing the original form fields """ - stream_file_class = SubmissionStreamFieldFile + stream_file_class = PrivateStreamFieldFile storage_class = PrivateStorage @property diff --git a/hypha/apply/funds/models/utils.py b/hypha/apply/funds/models/utils.py index dd52fbe334097e54ea86cd1a07eedee8f6693994..b8ead66526f2d296615dfae1d9a8d01a863ceeb9 100644 --- a/hypha/apply/funds/models/utils.py +++ b/hypha/apply/funds/models/utils.py @@ -146,6 +146,8 @@ class WorkflowStreamForm(WorkflowHelpers, AbstractStreamForm): # type: ignore InlinePanel("determination_forms", label=_("Determination Forms")), InlinePanel("approval_forms", label=_("Project Approval Form"), max_num=1), InlinePanel("sow_forms", label=_("Project SOW Form"), max_num=1), + # The models technically allow for multiple Report forms but to start we permit only one in the UIs. + InlinePanel("report_forms", label=_("Project Report Form"), max_num=1), ] diff --git a/hypha/apply/funds/tests/test_admin_form.py b/hypha/apply/funds/tests/test_admin_form.py index f2f147a9d1371afe82ea2f3ffae43526489fdba9..f484492c777dd9ea9052ab8cd00af550d8913fce 100644 --- a/hypha/apply/funds/tests/test_admin_form.py +++ b/hypha/apply/funds/tests/test_admin_form.py @@ -5,6 +5,7 @@ from hypha.apply.determinations.tests.factories import DeterminationFormFactory from hypha.apply.funds.models import FundType from hypha.apply.projects.tests.factories import ( ProjectApprovalFormFactory, + ProjectReportFormFactory, ProjectSOWFormFactory, ) from hypha.apply.review.tests.factories import ReviewFormFactory @@ -55,6 +56,7 @@ def form_data( stages=1, same_forms=False, form_stage_info=None, + num_project_report_forms=0, ): if form_stage_info is None: form_stage_info = [1] @@ -101,12 +103,19 @@ def form_data( same=same_forms, factory=ProjectSOWFormFactory, ) - + project_report_form_data = formset_base( + "report_forms", + num_project_report_forms, + False, + same=same_forms, + factory=ProjectReportFormFactory, + ) form_data.update(review_form_data) form_data.update(external_review_form_data) form_data.update(determination_form_data) form_data.update(project_approval_form_data) form_data.update(project_sow_form_data) + form_data.update(project_report_form_data) fund_data = factory.build(dict, FACTORY_CLASS=FundTypeFactory) fund_data["workflow_name"] = workflow_for_stages(stages) @@ -232,3 +241,7 @@ class TestWorkflowFormAdminForm(TestCase): form_data(1, 1, 1, 0, num_project_approval_form=2, stages=2) ) self.assertFalse(form.is_valid(), form.errors.as_text()) + + def test_validates_with_project_report_form(self): + form = self.submit_data(form_data(1, 1, 1, 0, 1, 0, 0, 1, False, None, 1)) + self.assertTrue(form.is_valid(), form.errors.as_text()) diff --git a/hypha/apply/funds/views.py b/hypha/apply/funds/views.py index 0b60e99d2215cff882e1b5de057d6e0c91b32f74..51eff45830c8e1b0cae7c317b1d13f5141d31e0f 100644 --- a/hypha/apply/funds/views.py +++ b/hypha/apply/funds/views.py @@ -76,7 +76,7 @@ from hypha.apply.utils.views import ( from . import services from .differ import compare -from .files import generate_submission_file_path +from .files import generate_private_file_path from .forms import ( ArchiveSubmissionForm, BatchArchiveSubmissionForm, @@ -1701,7 +1701,7 @@ class SubmissionPrivateMediaView(UserPassesTestMixin, PrivateMediaView): def get_media(self, *args, **kwargs): field_id = kwargs["field_id"] file_name = kwargs["file_name"] - path_to_file = generate_submission_file_path( + path_to_file = generate_private_file_path( self.submission.pk, field_id, file_name ) return self.storage.open(path_to_file) diff --git a/hypha/apply/projects/admin.py b/hypha/apply/projects/admin.py index 6a3b58fed405eaeefdfaf45374f9b6e9cfdcb4c1..0a46204ae3ba456ff28ca12d1ab5847eb5ee3935 100644 --- a/hypha/apply/projects/admin.py +++ b/hypha/apply/projects/admin.py @@ -5,14 +5,17 @@ from hypha.core.wagtail.admin import SettingModelAdmin from .admin_views import ( CreateProjectApprovalFormView, + CreateProjectReportFormView, CreateProjectSOWFormView, EditProjectApprovalFormView, + EditProjectReportFormView, EditProjectSOWFormView, ) from .models import ( ContractDocumentCategory, DocumentCategory, ProjectApprovalForm, + ProjectReportForm, ProjectSettings, ProjectSOWForm, VendorFormSettings, @@ -71,6 +74,23 @@ class ProjectSOWFormAdmin(ListRelatedMixin, ModelAdmin): ] +class ProjectReportFormAdmin(ListRelatedMixin, ModelAdmin): + model = ProjectReportForm + menu_label = "Report Forms" + menu_icon = "form" + list_display = ( + "name", + "used_by", + ) + create_view_class = CreateProjectReportFormView + edit_view_class = EditProjectReportFormView + + related_models = [ + ("applicationbaseprojectreportform", "application"), + ("labbaseprojectreportform", "lab"), + ] + + class ProjectSettingsAdmin(SettingModelAdmin): model = ProjectSettings @@ -86,6 +106,7 @@ class ProjectAdminGroup(ModelAdminGroup): ContractDocumentCategoryAdmin, DocumentCategoryAdmin, ProjectApprovalFormAdmin, + ProjectReportFormAdmin, ProjectSOWFormAdmin, VendorFormSettingsAdmin, ProjectSettingsAdmin, diff --git a/hypha/apply/projects/admin_views.py b/hypha/apply/projects/admin_views.py index 5dcc4c04d8cb7766a3c45004810f18e4019bf948..4bdaa7a802f664c4778440cd2181ae886eb4f80a 100644 --- a/hypha/apply/projects/admin_views.py +++ b/hypha/apply/projects/admin_views.py @@ -21,3 +21,11 @@ class CreateProjectSOWFormView(CreateProjectApprovalFormView): class EditProjectSOWFormView(EditProjectApprovalFormView): pass + + +class CreateProjectReportFormView(CreateProjectApprovalFormView): + pass + + +class EditProjectReportFormView(EditProjectApprovalFormView): + pass diff --git a/hypha/apply/projects/blocks.py b/hypha/apply/projects/blocks.py index c5378ff36e91f7836f93d04337aae2ab3b862508..a8207a333aa93bccf8114618dd870969cf8bbe9c 100644 --- a/hypha/apply/projects/blocks.py +++ b/hypha/apply/projects/blocks.py @@ -2,5 +2,7 @@ from hypha.apply.stream_forms.blocks import FormFieldsBlock from hypha.apply.utils.blocks import CustomFormFieldsBlock -class ProjectApprovalFormCustomFormFieldsBlock(CustomFormFieldsBlock, FormFieldsBlock): +class ProjectFormCustomFormFieldsBlock(CustomFormFieldsBlock, FormFieldsBlock): + """A block that can be used for customizable Project-related forms: PAF, SOW, and Report.""" + pass diff --git a/hypha/apply/projects/forms/report.py b/hypha/apply/projects/forms/report.py index 917ae5824cc29174cdf5f3eb3854626d598bdf39..f63ad8b910a9376b9ca89f26a0ad82b2243b1ea6 100644 --- a/hypha/apply/projects/forms/report.py +++ b/hypha/apply/projects/forms/report.py @@ -1,76 +1,73 @@ from django import forms from django.db import transaction from django.utils import timezone -from django.utils.translation import gettext_lazy as _ -from django_file_form.forms import FileFormMixin -from hypha.apply.stream_forms.fields import MultiFileField -from hypha.apply.utils.fields import RichTextField +from ...review.forms import MixedMetaClass +from ...stream_forms.forms import StreamBaseForm +from ..models.report import Report, ReportConfig, ReportVersion -from ..models.report import Report, ReportConfig, ReportPrivateFiles, ReportVersion - - -class ReportEditForm(FileFormMixin, forms.ModelForm): - public_content = RichTextField( - help_text=_( - "This section of the report will be shared with the broader community." - ) - ) - private_content = RichTextField( - help_text=_("This section of the report will be shared with staff only.") - ) - file_list = forms.ModelMultipleChoiceField( - widget=forms.CheckboxSelectMultiple(attrs={"class": "delete"}), - queryset=ReportPrivateFiles.objects.all(), - required=False, - label=_("Files"), - ) - files = MultiFileField(required=False, label="") +class ReportEditForm(StreamBaseForm, forms.ModelForm, metaclass=MixedMetaClass): class Meta: model = Report - fields = ( - "public_content", - "private_content", - "file_list", - "files", - ) + fields: list = [] def __init__(self, *args, user=None, initial=None, **kwargs): if initial is None: initial = {} - self.report_files = initial.pop( - "file_list", - ReportPrivateFiles.objects.none(), - ) + # Need to populate form_fields, right? + # No: The form_fields got populated from the view which instantiated this Form. + # Yes: they don't seem to be here. + # No: this is not where the magic happens. + # self.form_fields = kwargs.pop("form_fields", {}) + # Need to populate form_data, right? Yes. No. IDK. Appears no. + # self.form_data = kwargs.pop("form_data", {}) + # OK, both yes and no. If there is an existing value it will come via "initial", so if present there, use them. + # if initial["form_fields"] is not None: + # self.form_fields = initial["form_fields"] + # if initial["form_data"] is not None: + # self.form_data = initial["form_data"] + # But this should not be needed because super().__init__ will already take these initial values and use them. super().__init__(*args, initial=initial, **kwargs) - self.fields["file_list"].queryset = self.report_files self.user = user def clean(self): cleaned_data = super().clean() - public = cleaned_data["public_content"] - private = cleaned_data["private_content"] - if not private and not public: - missing_content = _( - "Must include either public or private content when submitting a report." - ) - self.add_error("public_content", missing_content) - self.add_error("private_content", missing_content) + cleaned_data["form_data"] = { + key: value + for key, value in cleaned_data.items() + if key not in self._meta.fields + } return cleaned_data @transaction.atomic - def save(self, commit=True): + def save(self, commit=True, form_fields=dict): is_draft = "save" in self.data - version = ReportVersion.objects.create( report=self.instance, - public_content=self.cleaned_data["public_content"], - private_content=self.cleaned_data["private_content"], + form_fields=form_fields, + # Save a ReportVersion first then edit/update the form_data below. + form_data={}, submitted=timezone.now(), draft=is_draft, author=self.user, ) + # We need to save the fields first, not attempt to save form_data on first save, then update the form_data next. + # Otherwise, we don't get access to the generator method "question_field_ids" which we use to prevent temp file + # fields from getting into the saved form_data. + # Inspired by ProjectApprovalForm.save and ProjectSOWForm.save but enhanced to support multi-answer fields. + version.form_data = { + field: self.cleaned_data["form_data"][field] + for field in self.cleaned_data["form_data"] + # Where do we get question_field_ids? On the version, but only when it exists, thus the create-then-update. + # The split-on-underscore supports the use of multi-answer fields such as MultiInputCharFieldBlock. + if field.split("_")[0] in version.question_field_ids + } + + # In case there are stream form file fields, process those here. + version.process_file_data(self.cleaned_data["form_data"]) + # Because ReportVersion is a separate entity from Project, super().save will not save ReportVersion: save here. + version.save() if is_draft: self.instance.draft = version @@ -83,21 +80,6 @@ class ReportEditForm(FileFormMixin, forms.ModelForm): self.instance.draft = None instance = super().save(commit) - - removed_files = self.cleaned_data["file_list"] - ReportPrivateFiles.objects.bulk_create( - ReportPrivateFiles(report=version, document=file.document) - for file in self.report_files - if file not in removed_files - ) - - added_files = self.cleaned_data["files"] - if added_files: - ReportPrivateFiles.objects.bulk_create( - ReportPrivateFiles(report=version, document=file) - for file in added_files - ) - return instance diff --git a/hypha/apply/projects/migrations/0082_projectreportform_and_more.py b/hypha/apply/projects/migrations/0082_projectreportform_and_more.py new file mode 100644 index 0000000000000000000000000000000000000000..37535ccaf7d46061017b39177f244fcf5a2537bb --- /dev/null +++ b/hypha/apply/projects/migrations/0082_projectreportform_and_more.py @@ -0,0 +1,1423 @@ +# Generated by Django 4.2.11 on 2024-04-05 16:30 + +from django.db import migrations, models +import hypha.apply.stream_forms.blocks +import hypha.apply.stream_forms.files +import hypha.apply.stream_forms.models +import wagtail.blocks +import wagtail.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("application_projects", "0081_alter_project_value"), + ] + + operations = [ + migrations.CreateModel( + name="ProjectReportForm", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ( + "form_fields", + wagtail.fields.StreamField( + [ + ( + "text_markup", + wagtail.blocks.RichTextBlock( + group="Custom", label="Paragraph" + ), + ), + ( + "header_markup", + wagtail.blocks.StructBlock( + [ + ( + "heading_text", + wagtail.blocks.CharBlock( + form_classname="title", required=True + ), + ), + ( + "size", + wagtail.blocks.ChoiceBlock( + choices=[ + ("h2", "H2"), + ("h3", "H3"), + ("h4", "H4"), + ] + ), + ), + ], + group="Custom", + label="Section header", + ), + ), + ( + "char", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "format", + wagtail.blocks.ChoiceBlock( + choices=[ + ("email", "Email"), + ("url", "URL"), + ], + label="Format", + required=False, + ), + ), + ( + "default_value", + wagtail.blocks.CharBlock( + label="Default value", required=False + ), + ), + ], + group="Fields", + ), + ), + ( + "multi_inputs_char", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "format", + wagtail.blocks.ChoiceBlock( + choices=[ + ("email", "Email"), + ("url", "URL"), + ], + label="Format", + required=False, + ), + ), + ( + "default_value", + wagtail.blocks.CharBlock( + label="Default value", required=False + ), + ), + ( + "number_of_inputs", + wagtail.blocks.IntegerBlock( + default=2, label="Max number of inputs" + ), + ), + ( + "add_button_text", + wagtail.blocks.CharBlock( + default="Add new item", required=False + ), + ), + ], + group="Fields", + ), + ), + ( + "text", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "default_value", + wagtail.blocks.TextBlock( + label="Default value", required=False + ), + ), + ( + "word_limit", + wagtail.blocks.IntegerBlock( + default=1000, label="Word limit" + ), + ), + ], + group="Fields", + ), + ), + ( + "number", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "default_value", + wagtail.blocks.CharBlock( + label="Default value", required=False + ), + ), + ], + group="Fields", + ), + ), + ( + "checkbox", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "default_value", + wagtail.blocks.BooleanBlock(required=False), + ), + ], + group="Fields", + ), + ), + ( + "radios", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "choices", + wagtail.blocks.ListBlock( + wagtail.blocks.CharBlock(label="Choice") + ), + ), + ], + group="Fields", + ), + ), + ( + "dropdown", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "choices", + wagtail.blocks.ListBlock( + wagtail.blocks.CharBlock(label="Choice") + ), + ), + ], + group="Fields", + ), + ), + ( + "checkboxes", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "checkboxes", + wagtail.blocks.ListBlock( + wagtail.blocks.CharBlock( + label="Checkbox" + ) + ), + ), + ], + group="Fields", + ), + ), + ( + "date", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "default_value", + wagtail.blocks.DateBlock(required=False), + ), + ], + group="Fields", + ), + ), + ( + "time", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "default_value", + wagtail.blocks.TimeBlock(required=False), + ), + ], + group="Fields", + ), + ), + ( + "datetime", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "default_value", + wagtail.blocks.DateTimeBlock( + required=False + ), + ), + ], + group="Fields", + ), + ), + ( + "image", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ], + group="Fields", + ), + ), + ( + "file", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ], + group="Fields", + ), + ), + ( + "multi_file", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ], + group="Fields", + ), + ), + ( + "group_toggle", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + default=True, + label="Required", + required=False, + ), + ), + ( + "choices", + wagtail.blocks.ListBlock( + wagtail.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" + ), + ), + ( + "rich_text", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "default_value", + wagtail.blocks.TextBlock( + label="Default value", required=False + ), + ), + ( + "word_limit", + wagtail.blocks.IntegerBlock( + default=1000, label="Word limit" + ), + ), + ], + group="Fields", + ), + ), + ( + "markdown_text", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "default_value", + wagtail.blocks.TextBlock( + label="Default value", required=False + ), + ), + ( + "word_limit", + wagtail.blocks.IntegerBlock( + default=1000, label="Word limit" + ), + ), + ], + group="Fields", + ), + ), + ], + use_json_field=True, + ), + ), + ], + options={ + "abstract": False, + }, + bases=(hypha.apply.stream_forms.models.BaseStreamForm, models.Model), + ), + migrations.RemoveField( + model_name="reportversion", + name="private_content", + ), + migrations.RemoveField( + model_name="reportversion", + name="public_content", + ), + migrations.AddField( + model_name="reportversion", + name="form_data", + field=models.JSONField( + default=dict, + encoder=hypha.apply.stream_forms.files.StreamFieldDataEncoder, + ), + ), + migrations.AddField( + model_name="reportversion", + name="form_fields", + field=wagtail.fields.StreamField( + [ + ( + "text_markup", + wagtail.blocks.RichTextBlock(group="Custom", label="Paragraph"), + ), + ( + "header_markup", + wagtail.blocks.StructBlock( + [ + ( + "heading_text", + wagtail.blocks.CharBlock( + form_classname="title", required=True + ), + ), + ( + "size", + wagtail.blocks.ChoiceBlock( + choices=[ + ("h2", "H2"), + ("h3", "H3"), + ("h4", "H4"), + ] + ), + ), + ], + group="Custom", + label="Section header", + ), + ), + ( + "char", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "format", + wagtail.blocks.ChoiceBlock( + choices=[("email", "Email"), ("url", "URL")], + label="Format", + required=False, + ), + ), + ( + "default_value", + wagtail.blocks.CharBlock( + label="Default value", required=False + ), + ), + ], + group="Fields", + ), + ), + ( + "multi_inputs_char", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "format", + wagtail.blocks.ChoiceBlock( + choices=[("email", "Email"), ("url", "URL")], + label="Format", + required=False, + ), + ), + ( + "default_value", + wagtail.blocks.CharBlock( + label="Default value", required=False + ), + ), + ( + "number_of_inputs", + wagtail.blocks.IntegerBlock( + default=2, label="Max number of inputs" + ), + ), + ( + "add_button_text", + wagtail.blocks.CharBlock( + default="Add new item", required=False + ), + ), + ], + group="Fields", + ), + ), + ( + "text", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "default_value", + wagtail.blocks.TextBlock( + label="Default value", required=False + ), + ), + ( + "word_limit", + wagtail.blocks.IntegerBlock( + default=1000, label="Word limit" + ), + ), + ], + group="Fields", + ), + ), + ( + "number", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "default_value", + wagtail.blocks.CharBlock( + label="Default value", required=False + ), + ), + ], + group="Fields", + ), + ), + ( + "checkbox", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "default_value", + wagtail.blocks.BooleanBlock(required=False), + ), + ], + group="Fields", + ), + ), + ( + "radios", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "choices", + wagtail.blocks.ListBlock( + wagtail.blocks.CharBlock(label="Choice") + ), + ), + ], + group="Fields", + ), + ), + ( + "dropdown", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "choices", + wagtail.blocks.ListBlock( + wagtail.blocks.CharBlock(label="Choice") + ), + ), + ], + group="Fields", + ), + ), + ( + "checkboxes", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "checkboxes", + wagtail.blocks.ListBlock( + wagtail.blocks.CharBlock(label="Checkbox") + ), + ), + ], + group="Fields", + ), + ), + ( + "date", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "default_value", + wagtail.blocks.DateBlock(required=False), + ), + ], + group="Fields", + ), + ), + ( + "time", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "default_value", + wagtail.blocks.TimeBlock(required=False), + ), + ], + group="Fields", + ), + ), + ( + "datetime", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "default_value", + wagtail.blocks.DateTimeBlock(required=False), + ), + ], + group="Fields", + ), + ), + ( + "image", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ], + group="Fields", + ), + ), + ( + "file", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ], + group="Fields", + ), + ), + ( + "multi_file", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ], + group="Fields", + ), + ), + ( + "group_toggle", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + default=True, label="Required", required=False + ), + ), + ( + "choices", + wagtail.blocks.ListBlock( + wagtail.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" + ), + ), + ( + "rich_text", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "default_value", + wagtail.blocks.TextBlock( + label="Default value", required=False + ), + ), + ( + "word_limit", + wagtail.blocks.IntegerBlock( + default=1000, label="Word limit" + ), + ), + ], + group="Fields", + ), + ), + ( + "markdown_text", + wagtail.blocks.StructBlock( + [ + ( + "field_label", + wagtail.blocks.CharBlock(label="Label"), + ), + ( + "help_text", + wagtail.blocks.TextBlock( + label="Help text", required=False + ), + ), + ( + "help_link", + wagtail.blocks.URLBlock( + label="Help link", required=False + ), + ), + ( + "required", + wagtail.blocks.BooleanBlock( + label="Required", required=False + ), + ), + ( + "default_value", + wagtail.blocks.TextBlock( + label="Default value", required=False + ), + ), + ( + "word_limit", + wagtail.blocks.IntegerBlock( + default=1000, label="Word limit" + ), + ), + ], + group="Fields", + ), + ), + ], + default={}, + use_json_field=True, + ), + preserve_default=False, + ), + ] diff --git a/hypha/apply/projects/models/__init__.py b/hypha/apply/projects/models/__init__.py index 5e289c2031b104c9a347b36be36a1e3520d10d88..871444e464ef76c88903fc22334302ced6cba99f 100644 --- a/hypha/apply/projects/models/__init__.py +++ b/hypha/apply/projects/models/__init__.py @@ -9,6 +9,7 @@ from .project import ( PAFApprovals, Project, ProjectApprovalForm, + ProjectReportForm, ProjectSettings, ProjectSOWForm, ) @@ -18,6 +19,7 @@ from .vendor import BankInformation, DueDiligenceDocument, Vendor, VendorFormSet __all__ = [ "Project", "ProjectApprovalForm", + "ProjectReportForm", "ProjectSOWForm", "ProjectSettings", "PAFApprovals", diff --git a/hypha/apply/projects/models/project.py b/hypha/apply/projects/models/project.py index 14aae7c1773049e6901f760983942c3085deba90..e7a162b297cebce7b1074b6725eba82136b8443c 100644 --- a/hypha/apply/projects/models/project.py +++ b/hypha/apply/projects/models/project.py @@ -30,7 +30,7 @@ from hypha.apply.stream_forms.files import StreamFieldDataEncoder from hypha.apply.stream_forms.models import BaseStreamForm from hypha.apply.utils.storage import PrivateStorage -from ..blocks import ProjectApprovalFormCustomFormFieldsBlock +from ..blocks import ProjectFormCustomFormFieldsBlock from .vendor import Vendor logger = logging.getLogger(__name__) @@ -219,7 +219,7 @@ class Project(BaseStreamForm, AccessFormData, models.Model): form_data = models.JSONField(encoder=StreamFieldDataEncoder, default=dict) form_fields = StreamField( - ProjectApprovalFormCustomFormFieldsBlock(), null=True, use_json_field=True + ProjectFormCustomFormFieldsBlock(), null=True, use_json_field=True ) # tracks read/write state of the Project @@ -321,7 +321,6 @@ class Project(BaseStreamForm, AccessFormData, models.Model): ) if not first_approved_contract: return None - return first_approved_contract.approved_at.date() @property @@ -455,15 +454,13 @@ class ProjectSOW(BaseStreamForm, AccessFormData, models.Model): ) form_data = models.JSONField(encoder=StreamFieldDataEncoder, default=dict) form_fields = StreamField( - ProjectApprovalFormCustomFormFieldsBlock(), null=True, use_json_field=True + ProjectFormCustomFormFieldsBlock(), null=True, use_json_field=True ) class ProjectBaseStreamForm(BaseStreamForm, models.Model): name = models.CharField(max_length=255) - form_fields = StreamField( - ProjectApprovalFormCustomFormFieldsBlock(), use_json_field=True - ) + form_fields = StreamField(ProjectFormCustomFormFieldsBlock(), use_json_field=True) panels = [ FieldPanel("name"), @@ -485,6 +482,17 @@ class ProjectSOWForm(ProjectBaseStreamForm): pass +class ProjectReportForm(ProjectBaseStreamForm): + """ + An Applicant Report Form can be attached to a Fund to collect reports from Applicants aka Grantees during the + Project. It is only relevant for accepted or granted Submissions which is why it is attached to Project. It is + similar to the other Forms (PAF, SOW) in that it uses StreamForm to allow maximum flexibility in form creation. + See Also ReportVersion where the fields from the form get copied and the response data gets filled in. + """ + + pass + + class PAFReviewersRole(Orderable, ClusterableModel): label = models.CharField(max_length=200) user_roles = ParentalManyToManyField( diff --git a/hypha/apply/projects/models/report.py b/hypha/apply/projects/models/report.py index 9467b2f03340857c98add2afc71eaa7253eed4c4..ee6a2d12c26efda2b5b301806a6f55d5059ccab4 100644 --- a/hypha/apply/projects/models/report.py +++ b/hypha/apply/projects/models/report.py @@ -12,7 +12,12 @@ from django.urls import reverse from django.utils import timezone from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ +from wagtail.fields import StreamField +from hypha.apply.funds.models.mixins import AccessFormData +from hypha.apply.projects.blocks import ProjectFormCustomFormFieldsBlock +from hypha.apply.stream_forms.files import StreamFieldDataEncoder +from hypha.apply.stream_forms.models import BaseStreamForm from hypha.apply.utils.storage import PrivateStorage @@ -166,13 +171,17 @@ class Report(models.Model): return self.project.start_date -class ReportVersion(models.Model): +class ReportVersion(BaseStreamForm, AccessFormData, models.Model): report = models.ForeignKey( "Report", on_delete=models.CASCADE, related_name="versions" ) submitted = models.DateTimeField() - public_content = models.TextField() - private_content = models.TextField() + form_fields = StreamField( + # Re-use the Project Custom Form class. The original fields (used at the time of response) should be required. + ProjectFormCustomFormFieldsBlock(), + use_json_field=True, + ) + form_data = models.JSONField(encoder=StreamFieldDataEncoder, default=dict) draft = models.BooleanField() author = models.ForeignKey( settings.AUTH_USER_MODEL, diff --git a/hypha/apply/projects/templates/application_projects/report_detail.html b/hypha/apply/projects/templates/application_projects/report_detail.html index b0209e7be5fb6dc6b8cd5d92b1e631037eb025a0..10ecebe2cfe87e23e153d407c36566d126d67818 100644 --- a/hypha/apply/projects/templates/application_projects/report_detail.html +++ b/hypha/apply/projects/templates/application_projects/report_detail.html @@ -27,14 +27,14 @@ <h2>{% trans "Report Skipped" %}</h2> {% else %} <h4>{% trans "Public Report" %}</h4> - <div class="rich-text"> - {{ object.current.public_content|nh3|safe }} + <div class="card card--solid"> + {% if object.current %} + <div class="simplified__paf_answers"> + {{ object.current.output_answers }} + </div> + {% endif %} </div> - <h4>{% trans "Private Report" %}</h4> - <div class="rich-text"> - {{ object.current.private_content|nh3|safe }} - </div> {% for file in object.current.files.all %} {% if forloop.first %} <h4>{% trans "Attachements" %}</h4> diff --git a/hypha/apply/projects/templates/application_projects/report_form.html b/hypha/apply/projects/templates/application_projects/report_form.html index da0de3f82f1d3ad6ce99a888d6edb7211c67b308..d7669e1024a0edce7c17ab2e650e60f691548f55 100644 --- a/hypha/apply/projects/templates/application_projects/report_form.html +++ b/hypha/apply/projects/templates/application_projects/report_form.html @@ -35,16 +35,23 @@ {% for field in form %} {% if field.field %} - {% include "forms/includes/field.html" %} + {% if field.field.multi_input_field %} + {% include "forms/includes/multi_input_field.html" %} + {% else %} + {% include "forms/includes/field.html" %} + {% endif %} {% else %} - {{ field }} + {{ field.block }} {% endif %} {% endfor %} + {% for hidden_field in form.hidden_fields %} + {{ hidden_field }} + {% endfor %} <input type="submit" id="submit-report-form-submit" name="submit" class="is-hidden" /> <input type="submit" id="submit-report-form-save" name="save" class="is-hidden" /> - <button data-fancybox data-src="#save-report" class="button button--submit button--top-space button--white" type="button">{% trans "Save" %}</button> - <button data-fancybox data-src="#submit-report" class="button button--primary" type="button">{% trans "Submit" %}</button> + <button data-fancybox data-src="#save-report" class="button button--submit button--top-space button--white" type="button">{% trans "Save draft" %}</button> + <button data-fancybox data-src="#submit-report" class="button button--submit button--top-space button--primary" type="button">{% trans "Submit" %}</button> <!-- Save report modal --> <div class="modal" id="save-report"> @@ -75,4 +82,5 @@ {% block extra_js %} <script src="{% static 'js/jquery.fancybox.min.js' %}"></script> <script src="{% static 'js/fancybox-global.js' %}"></script> + <script src="{% static 'js/multi-input-fields.js' %}"></script> {% endblock %} diff --git a/hypha/apply/projects/tests/factories.py b/hypha/apply/projects/tests/factories.py index f65f243f94aba9e193627f6c9c5bb4da42a1f969..8c40c41bfbeccc953cbcf8ca3c76d45472973c49 100644 --- a/hypha/apply/projects/tests/factories.py +++ b/hypha/apply/projects/tests/factories.py @@ -8,6 +8,7 @@ from hypha.apply.funds.tests.factories import ApplicationSubmissionFactory from hypha.apply.stream_forms.testing.factories import ( FormDataFactory, FormFieldsBlockFactory, + NonFileFormFieldsBlockFactory, ) from hypha.apply.users.groups import APPROVER_GROUP_NAME, STAFF_GROUP_NAME from hypha.apply.users.tests.factories import GroupFactory, StaffFactory, UserFactory @@ -24,6 +25,7 @@ from ..models.project import ( PAFReviewersRole, Project, ProjectApprovalForm, + ProjectReportForm, ProjectSOWForm, ) from ..models.report import Report, ReportConfig, ReportVersion @@ -84,6 +86,14 @@ class ProjectApprovalFormDataFactory(FormDataFactory): field_factory = FormFieldsBlockFactory +class ProjectReportFormFactory(factory.django.DjangoModelFactory): + class Meta: + model = ProjectReportForm + + name = factory.Faker("word") + form_fields = FormFieldsBlockFactory + + class ProjectFactory(factory.django.DjangoModelFactory): submission = factory.SubFactory(ApplicationSubmissionFactory) user = factory.SubFactory(UserFactory) @@ -200,11 +210,19 @@ class ReportConfigFactory(factory.django.DjangoModelFactory): ) +class ReportVersionDataFactory(FormDataFactory): + field_factory = NonFileFormFieldsBlockFactory + + class ReportVersionFactory(factory.django.DjangoModelFactory): report = factory.SubFactory("hypha.apply.projects.tests.factories.ReportFactory") submitted = factory.LazyFunction(timezone.now) - public_content = factory.Faker("paragraph") - private_content = factory.Faker("paragraph") + form_fields = NonFileFormFieldsBlockFactory + # TODO: is it better to keep the following link between form_data and form_fields or to remove it? + form_data = factory.SubFactory( + ReportVersionDataFactory, + form_fields=factory.SelfAttribute("..form_fields"), + ) draft = True class Meta: diff --git a/hypha/apply/projects/tests/test_views.py b/hypha/apply/projects/tests/test_views.py index 7e2b462c7fe54c51d6ae50687a215d82edcaf2f0..429e71e19ef1e6a09fce14ca9cf836bb14e420ea 100644 --- a/hypha/apply/projects/tests/test_views.py +++ b/hypha/apply/projects/tests/test_views.py @@ -1,6 +1,7 @@ import json from io import BytesIO +import factory from dateutil.relativedelta import relativedelta from django.conf import settings from django.contrib.auth.models import AnonymousUser @@ -23,6 +24,7 @@ from hypha.apply.users.tests.factories import ( from hypha.apply.utils.testing.tests import BaseViewTestCase from hypha.home.factories import ApplySiteFactory +from ...funds.models.forms import ApplicationBaseProjectReportForm from ..files import get_files from ..forms import SetPendingForm from ..models.payment import CHANGES_REQUESTED_BY_STAFF, SUBMITTED @@ -35,6 +37,7 @@ from ..models.project import ( INTERNAL_APPROVAL, INVOICING_AND_REPORTING, REQUEST_CHANGE, + ProjectReportForm, ProjectSettings, ) from ..views.project import ContractsMixin, ProjectDetailApprovalView @@ -51,6 +54,20 @@ from .factories import ( SupportingDocumentFactory, ) +# A boilerplate stream form for Project Report tests below. +FORM_FIELDS = [ + { + "id": "012a4f29-0882-4b1c-b567-aede1b601d4a", + "type": "number", + "value": { + "required": True, + "help_text": "", + "field_label": "How many folks did you reach?", + "default_value": "", + }, + } +] + class TestUpdateLeadView(BaseViewTestCase): base_view_name = "detail" @@ -1189,6 +1206,15 @@ class TestStaffSubmitReport(BaseViewTestCase): base_view_name = "edit" url_name = "funds:projects:reports:{}" user_factory = StaffFactory + report_form_id = None + + def setUp(self): + super().setUp() + report_form, _ = ProjectReportForm.objects.get_or_create( + name=factory.Faker("word"), + form_fields=FORM_FIELDS, + ) + self.report_form_id = report_form.id def get_kwargs(self, instance): return { @@ -1197,11 +1223,19 @@ class TestStaffSubmitReport(BaseViewTestCase): def test_get_page_for_inprogress_project(self): report = ReportFactory(project__status=INVOICING_AND_REPORTING) + ApplicationBaseProjectReportForm.objects.get_or_create( + application_id=report.project.submission.page.specific.id, + form_id=self.report_form_id, + ) response = self.get_page(report) self.assertContains(response, report.project.title) def test_cant_get_page_for_closing_and_complete_project(self): report = ReportFactory(project__status=CLOSING) + ApplicationBaseProjectReportForm.objects.get_or_create( + application_id=report.project.submission.page.specific.id, + form_id=self.report_form_id, + ) response = self.get_page(report) self.assertEqual(response.status_code, 403) @@ -1211,39 +1245,72 @@ class TestStaffSubmitReport(BaseViewTestCase): def test_submit_report(self): report = ReportFactory(project__status=INVOICING_AND_REPORTING) - response = self.post_page(report, {"public_content": "Some text"}) + ApplicationBaseProjectReportForm.objects.get_or_create( + application_id=report.project.submission.page.specific.id, + form_id=self.report_form_id, + ) + response = self.post_page( + report, {"012a4f29-0882-4b1c-b567-aede1b601d4a": "11"} + ) report.refresh_from_db() self.assertRedirects( response, self.absolute_url(report.project.get_absolute_url()) ) - self.assertEqual(report.versions.first().public_content, "Some text") + self.assertEqual( + report.versions.first().form_data, + {"012a4f29-0882-4b1c-b567-aede1b601d4a": "11"}, + ) self.assertEqual(report.versions.first(), report.current) self.assertEqual(report.current.author, self.user) self.assertIsNone(report.draft) def test_cant_submit_report_for_closing_and_complete_project(self): report = ReportFactory(project__status=CLOSING) - response = self.post_page(report, {"public_content": "Some text"}) + ApplicationBaseProjectReportForm.objects.get_or_create( + application_id=report.project.submission.page.specific.id, + form_id=self.report_form_id, + ) + response = self.post_page( + report, {"012a4f29-0882-4b1c-b567-aede1b601d4a": "13"} + ) self.assertEqual(response.status_code, 403) report = ReportFactory(project__status=COMPLETE) - response = self.post_page(report, {"public_content": "Some text"}) + ApplicationBaseProjectReportForm.objects.get_or_create( + application_id=report.project.submission.page.specific.id, + form_id=self.report_form_id, + ) + response = self.post_page( + report, {"012a4f29-0882-4b1c-b567-aede1b601d4a": "13"} + ) self.assertEqual(response.status_code, 403) def test_submit_private_report(self): report = ReportFactory(project__status=INVOICING_AND_REPORTING) - response = self.post_page(report, {"private_content": "Some text"}) + # Link the single-field report_form to the Fund associated with this Submission. + ApplicationBaseProjectReportForm.objects.get_or_create( + application_id=report.project.submission.page.specific.id, + form_id=self.report_form_id, + ) + response = self.post_page( + report, {"012a4f29-0882-4b1c-b567-aede1b601d4a": "17"} + ) report.refresh_from_db() - self.assertRedirects( - response, self.absolute_url(report.project.get_absolute_url()) + self.assertEquals(response.status_code, 200) + self.assertEqual( + report.versions.first().form_data, + {"012a4f29-0882-4b1c-b567-aede1b601d4a": "17"}, ) - self.assertEqual(report.versions.first().private_content, "Some text") self.assertEqual(report.versions.first(), report.current) self.assertEqual(report.current.author, self.user) self.assertIsNone(report.draft) def test_cant_submit_blank_report(self): report = ReportFactory(project__status=INVOICING_AND_REPORTING) + ApplicationBaseProjectReportForm.objects.get_or_create( + application_id=report.project.submission.page.specific.id, + form_id=self.report_form_id, + ) response = self.post_page(report, {}) report.refresh_from_db() self.assertEqual(response.status_code, 200) @@ -1251,62 +1318,94 @@ class TestStaffSubmitReport(BaseViewTestCase): def test_save_report_draft(self): report = ReportFactory(project__status=INVOICING_AND_REPORTING) + ApplicationBaseProjectReportForm.objects.get_or_create( + application_id=report.project.submission.page.specific.id, + form_id=self.report_form_id, + ) response = self.post_page( - report, {"public_content": "Some text", "save": "Save"} + report, {"012a4f29-0882-4b1c-b567-aede1b601d4a": "19", "save": "Save"} ) report.refresh_from_db() - self.assertRedirects( - response, self.absolute_url(report.project.get_absolute_url()) + self.assertEquals(response.status_code, 200) + self.assertEqual( + report.versions.first().form_data, + {"012a4f29-0882-4b1c-b567-aede1b601d4a": "19"}, ) - self.assertEqual(report.versions.first().public_content, "Some text") self.assertEqual(report.versions.first(), report.draft) self.assertIsNone(report.current) def test_save_report_with_draft(self): report = ReportFactory(is_draft=True, project__status=INVOICING_AND_REPORTING) + ApplicationBaseProjectReportForm.objects.get_or_create( + application_id=report.project.submission.page.specific.id, + form_id=self.report_form_id, + ) self.assertEqual(report.versions.first(), report.draft) - response = self.post_page(report, {"public_content": "Some text"}) + response = self.post_page( + report, {"012a4f29-0882-4b1c-b567-aede1b601d4a": "23"} + ) report.refresh_from_db() - self.assertRedirects( - response, self.absolute_url(report.project.get_absolute_url()) + self.assertEquals(response.status_code, 200) + self.assertEqual( + report.versions.last().form_data, + {"012a4f29-0882-4b1c-b567-aede1b601d4a": "23"}, ) - self.assertEqual(report.versions.last().public_content, "Some text") self.assertEqual(report.versions.last(), report.current) self.assertIsNone(report.draft) def test_edit_submitted_report(self): report = ReportFactory( - is_submitted=True, project__status=INVOICING_AND_REPORTING + is_submitted=True, + project__status=INVOICING_AND_REPORTING, + version__form_fields=json.dumps(FORM_FIELDS), + ) + ApplicationBaseProjectReportForm.objects.get_or_create( + application_id=report.project.submission.page.specific.id, + form_id=self.report_form_id, ) self.assertEqual(report.versions.first(), report.current) response = self.post_page( - report, {"public_content": "Some text", "save": " Save"} + report, {"012a4f29-0882-4b1c-b567-aede1b601d4a": "29", "save": " Save"} ) report.refresh_from_db() self.assertRedirects( response, self.absolute_url(report.project.get_absolute_url()) ) - self.assertEqual(report.versions.last().public_content, "Some text") + self.assertEqual( + report.versions.last().form_data["012a4f29-0882-4b1c-b567-aede1b601d4a"], + "29", + ) self.assertEqual(report.versions.last(), report.draft) self.assertEqual(report.versions.first(), report.current) def test_resubmit_submitted_report(self): yesterday = timezone.now() - relativedelta(days=1) version = ReportVersionFactory( - report__project__status=INVOICING_AND_REPORTING, submitted=yesterday + report__project__status=INVOICING_AND_REPORTING, + submitted=yesterday, + form_fields=json.dumps(FORM_FIELDS), ) report = version.report + ApplicationBaseProjectReportForm.objects.get_or_create( + application_id=report.project.submission.page.specific.id, + form_id=self.report_form_id, + ) report.current = version report.submitted = version.submitted report.save() self.assertEqual(report.submitted, yesterday) self.assertEqual(report.versions.first(), report.current) - response = self.post_page(report, {"public_content": "Some text"}) + response = self.post_page( + report, {"012a4f29-0882-4b1c-b567-aede1b601d4a": "31"} + ) report.refresh_from_db() self.assertRedirects( response, self.absolute_url(report.project.get_absolute_url()) ) - self.assertEqual(report.versions.last().public_content, "Some text") + self.assertEqual( + report.versions.last().form_data, + {"012a4f29-0882-4b1c-b567-aede1b601d4a": "31"}, + ) self.assertEqual(report.versions.last(), report.current) self.assertIsNone(report.draft) self.assertEqual(report.submitted.date(), yesterday.date()) @@ -1318,11 +1417,17 @@ class TestStaffSubmitReport(BaseViewTestCase): is_submitted=True, project__status=INVOICING_AND_REPORTING, ) + ApplicationBaseProjectReportForm.objects.get_or_create( + application_id=submitted_report.project.submission.page.specific.id, + form_id=self.report_form_id, + ) future_report = ReportFactory( end_date=timezone.now() + relativedelta(days=3), project=submitted_report.project, ) - response = self.post_page(future_report, {"public_content": "Some text"}) + response = self.post_page( + future_report, {"012a4f29-0882-4b1c-b567-aede1b601d4a": "37"} + ) self.assertEqual(response.status_code, 403) @@ -1330,6 +1435,15 @@ class TestApplicantSubmitReport(BaseViewTestCase): base_view_name = "edit" url_name = "funds:projects:reports:{}" user_factory = ApplicantFactory + report_form_id = None + + def setUp(self): + super().setUp() + report_form, _ = ProjectReportForm.objects.get_or_create( + name=factory.Faker("word"), + form_fields=FORM_FIELDS, + ) + self.report_form_id = report_form.id def get_kwargs(self, instance): return { @@ -1340,20 +1454,36 @@ class TestApplicantSubmitReport(BaseViewTestCase): report = ReportFactory( project__status=INVOICING_AND_REPORTING, project__user=self.user ) + ApplicationBaseProjectReportForm.objects.get_or_create( + application_id=report.project.submission.page.specific.id, + form_id=self.report_form_id, + ) response = self.get_page(report) self.assertContains(response, report.project.title) def test_cant_get_own_report_for_closing_and_complete_project(self): report = ReportFactory(project__status=CLOSING, project__user=self.user) + ApplicationBaseProjectReportForm.objects.get_or_create( + application_id=report.project.submission.page.specific.id, + form_id=self.report_form_id, + ) response = self.get_page(report) self.assertEqual(response.status_code, 403) report = ReportFactory(project__status=COMPLETE, project__user=self.user) + ApplicationBaseProjectReportForm.objects.get_or_create( + application_id=report.project.submission.page.specific.id, + form_id=self.report_form_id, + ) response = self.get_page(report) self.assertEqual(response.status_code, 403) def test_cant_get_other_report(self): report = ReportFactory(project__status=INVOICING_AND_REPORTING) + ApplicationBaseProjectReportForm.objects.get_or_create( + application_id=report.project.submission.page.specific.id, + form_id=self.report_form_id, + ) response = self.get_page(report) self.assertEqual(response.status_code, 403) @@ -1361,12 +1491,21 @@ class TestApplicantSubmitReport(BaseViewTestCase): report = ReportFactory( project__status=INVOICING_AND_REPORTING, project__user=self.user ) - response = self.post_page(report, {"public_content": "Some text"}) + ApplicationBaseProjectReportForm.objects.get_or_create( + application_id=report.project.submission.page.specific.id, + form_id=self.report_form_id, + ) + response = self.post_page( + report, {"012a4f29-0882-4b1c-b567-aede1b601d4a": "37"} + ) report.refresh_from_db() self.assertRedirects( response, self.absolute_url(report.project.get_absolute_url()) ) - self.assertEqual(report.versions.first().public_content, "Some text") + self.assertEqual( + report.versions.first().form_data, + {"012a4f29-0882-4b1c-b567-aede1b601d4a": "37"}, + ) self.assertEqual(report.versions.first(), report.current) self.assertEqual(report.current.author, self.user) @@ -1374,12 +1513,19 @@ class TestApplicantSubmitReport(BaseViewTestCase): report = ReportFactory( project__status=INVOICING_AND_REPORTING, project__user=self.user ) - response = self.post_page(report, {"private_content": "Some text"}) + ApplicationBaseProjectReportForm.objects.get_or_create( + application_id=report.project.submission.page.specific.id, + form_id=self.report_form_id, + ) + response = self.post_page( + report, {"012a4f29-0882-4b1c-b567-aede1b601d4a": "41"} + ) report.refresh_from_db() - self.assertRedirects( - response, self.absolute_url(report.project.get_absolute_url()) + self.assertEquals(response.status_code, 200) + self.assertEqual( + report.versions.first().form_data, + {"012a4f29-0882-4b1c-b567-aede1b601d4a": "41"}, ) - self.assertEqual(report.versions.first().private_content, "Some text") self.assertEqual(report.versions.first(), report.current) self.assertEqual(report.current.author, self.user) self.assertIsNone(report.draft) @@ -1388,6 +1534,10 @@ class TestApplicantSubmitReport(BaseViewTestCase): report = ReportFactory( project__status=INVOICING_AND_REPORTING, project__user=self.user ) + ApplicationBaseProjectReportForm.objects.get_or_create( + application_id=report.project.submission.page.specific.id, + form_id=self.report_form_id, + ) response = self.post_page(report, {}) report.refresh_from_db() self.assertEqual(response.status_code, 200) @@ -1397,14 +1547,21 @@ class TestApplicantSubmitReport(BaseViewTestCase): report = ReportFactory( project__status=INVOICING_AND_REPORTING, project__user=self.user ) + ApplicationBaseProjectReportForm.objects.get_or_create( + application_id=report.project.submission.page.specific.id, + form_id=self.report_form_id, + ) response = self.post_page( - report, {"public_content": "Some text", "save": "Save"} + report, {"012a4f29-0882-4b1c-b567-aede1b601d4a": "43", "save": "Save"} ) report.refresh_from_db() self.assertRedirects( response, self.absolute_url(report.project.get_absolute_url()) ) - self.assertEqual(report.versions.first().public_content, "Some text") + self.assertEqual( + report.versions.first().form_data, + {"012a4f29-0882-4b1c-b567-aede1b601d4a": "43"}, + ) self.assertEqual(report.versions.first(), report.draft) self.assertIsNone(report.current) @@ -1414,13 +1571,22 @@ class TestApplicantSubmitReport(BaseViewTestCase): is_draft=True, project__user=self.user, ) + ApplicationBaseProjectReportForm.objects.get_or_create( + application_id=report.project.submission.page.specific.id, + form_id=self.report_form_id, + ) self.assertEqual(report.versions.first(), report.draft) - response = self.post_page(report, {"public_content": "Some text"}) + response = self.post_page( + report, {"012a4f29-0882-4b1c-b567-aede1b601d4a": "47"} + ) report.refresh_from_db() self.assertRedirects( response, self.absolute_url(report.project.get_absolute_url()) ) - self.assertEqual(report.versions.last().public_content, "Some text") + self.assertEqual( + report.versions.last().form_data, + {"012a4f29-0882-4b1c-b567-aede1b601d4a": "47"}, + ) self.assertEqual(report.versions.last(), report.current) self.assertIsNone(report.draft) @@ -1431,6 +1597,10 @@ class TestApplicantSubmitReport(BaseViewTestCase): is_submitted=True, project__user=self.user, ) + ApplicationBaseProjectReportForm.objects.get_or_create( + application_id=report.project.submission.page.specific.id, + form_id=self.report_form_id, + ) self.assertEqual(report.versions.first(), report.current) response = self.post_page( report, {"public_content": "Some text", "save": " Save"} @@ -1439,7 +1609,13 @@ class TestApplicantSubmitReport(BaseViewTestCase): def test_cant_submit_other_report(self): report = ReportFactory(project__status=INVOICING_AND_REPORTING) - response = self.post_page(report, {"public_content": "Some text"}) + ApplicationBaseProjectReportForm.objects.get_or_create( + application_id=report.project.submission.page.specific.id, + form_id=self.report_form_id, + ) + response = self.post_page( + report, {"012a4f29-0882-4b1c-b567-aede1b601d4a": "53"} + ) self.assertEqual(response.status_code, 403) diff --git a/hypha/apply/projects/urls.py b/hypha/apply/projects/urls.py index a3d80e397e6137ab6028bd1732598673ba497851..88601a80cd21138aa2849c0cc01ab060e393d6e3 100644 --- a/hypha/apply/projects/urls.py +++ b/hypha/apply/projects/urls.py @@ -54,7 +54,12 @@ urlpatterns = [ ), path("edit/", ProjectApprovalFormEditView.as_view(), name="edit"), path( - "documents/<int:file_pk>/", + "documents/<int:file_pk>", + ProjectPrivateMediaView.as_view(), + name="document", + ), + path( + "documents/<uuid:field_id>/<str:file_name>", ProjectPrivateMediaView.as_view(), name="document", ), diff --git a/hypha/apply/projects/utils.py b/hypha/apply/projects/utils.py index 5a07ecb82e516215b13f8b156d9bea8f6ab9f11d..ea06cc42766f55fca2c49548428b43295b960aac 100644 --- a/hypha/apply/projects/utils.py +++ b/hypha/apply/projects/utils.py @@ -1,5 +1,6 @@ from django.conf import settings from django.utils.translation import gettext_lazy as _ +from django_file_form.uploaded_file import PlaceholderUploadedFile from .constants import ( INT_DECLINED, @@ -190,3 +191,14 @@ def get_invoice_table_status(invoice_status, is_applicant=False): return INT_DECLINED if invoice_status == PAYMENT_FAILED: return INT_PAYMENT_FAILED + + +def get_placeholder_file(initial_file): + if not isinstance(initial_file, list): + return PlaceholderUploadedFile( + initial_file.filename, size=initial_file.size, file_id=initial_file.name + ) + return [ + PlaceholderUploadedFile(f.filename, size=f.size, file_id=f.name) + for f in initial_file + ] diff --git a/hypha/apply/projects/views/project.py b/hypha/apply/projects/views/project.py index 2ab814bae1afe34cd2d56781eb3a3960848947a4..f65848aa10690d5587c49a7e5bd6c1cb7b3e6c37 100644 --- a/hypha/apply/projects/views/project.py +++ b/hypha/apply/projects/views/project.py @@ -1,6 +1,6 @@ +import copy import datetime import io -from copy import copy from django.conf import settings from django.contrib import messages @@ -29,7 +29,6 @@ from django.views.generic import ( UpdateView, ) from django.views.generic.detail import SingleObjectMixin -from django_file_form.models import PlaceholderUploadedFile from django_filters.views import FilterView from django_tables2 import SingleTableMixin from docx import Document @@ -67,6 +66,7 @@ from hypha.apply.utils.models import PDFPageSettings from hypha.apply.utils.storage import PrivateMediaView from hypha.apply.utils.views import DelegateableView, DelegatedViewMixin, ViewDispatcher +from ...funds.files import generate_private_file_path from ..files import get_files from ..filters import InvoiceListFilter, ProjectListFilter, ReportListFilter from ..forms import ( @@ -110,7 +110,7 @@ from ..models.project import ( from ..models.report import Report from ..permissions import has_permission from ..tables import InvoiceListTable, ProjectsListTable, ReportListTable -from ..utils import get_paf_status_display +from ..utils import get_paf_status_display, get_placeholder_file from ..views.payment import ChangeInvoiceStatusView from .report import ReportFrequencyUpdate, ReportingMixin @@ -371,7 +371,7 @@ class UpdateLeadView(DelegatedViewMixin, UpdateView): def form_valid(self, form): # Fetch the old lead from the database - old_lead = copy(self.get_object().lead) + old_lead = copy.copy(self.get_object().lead) response = super().form_valid(form) project = form.instance @@ -1315,6 +1315,10 @@ class ProjectDetailView(ViewDispatcher): @method_decorator(login_required, name="dispatch") class ProjectPrivateMediaView(UserPassesTestMixin, PrivateMediaView): + """ + See also hypha/apply/funds/files.py + """ + raise_exception = True def dispatch(self, *args, **kwargs): @@ -1323,10 +1327,18 @@ class ProjectPrivateMediaView(UserPassesTestMixin, PrivateMediaView): return super().dispatch(*args, **kwargs) def get_media(self, *args, **kwargs): - document = PacketFile.objects.get(pk=kwargs["file_pk"]) - if document.project != self.project: - raise Http404 - return document.document + if "file_pk" in kwargs: + document = PacketFile.objects.get(pk=kwargs["file_pk"]) + if document.project != self.project: + raise Http404 + return document.document + else: + field_id = kwargs["field_id"] + file_name = kwargs["file_name"] + path_to_file = generate_private_file_path( + self.project.pk, field_id, file_name, path_start="project" + ) + return self.storage.open(path_to_file) def test_func(self): if self.request.user.is_apply_staff: @@ -1765,22 +1777,10 @@ class ProjectApprovalFormEditView(BaseStreamForm, UpdateView): initial = self.object.raw_data for field_id in self.object.file_field_ids: initial.pop(field_id + "-uploads", False) - initial[field_id] = self.get_placeholder_file( - self.object.raw_data.get(field_id) - ) + initial[field_id] = get_placeholder_file(self.object.raw_data.get(field_id)) kwargs["initial"].update(initial) return kwargs - def get_placeholder_file(self, initial_file): - if not isinstance(initial_file, list): - return PlaceholderUploadedFile( - initial_file.filename, size=initial_file.size, file_id=initial_file.name - ) - return [ - PlaceholderUploadedFile(f.filename, size=f.size, file_id=f.name) - for f in initial_file - ] - def get_sow_form_kwargs(self): kwargs = super().get_form_kwargs() if self.approval_sow_form: @@ -1792,7 +1792,7 @@ class ProjectApprovalFormEditView(BaseStreamForm, UpdateView): initial = sow_instance.raw_data for field_id in sow_instance.file_field_ids: initial.pop(field_id + "-uploads", False) - initial[field_id] = self.get_placeholder_file( + initial[field_id] = get_placeholder_file( sow_instance.raw_data.get(field_id) ) initial["project"] = self.object diff --git a/hypha/apply/projects/views/report.py b/hypha/apply/projects/views/report.py index f34341b1eedb577bdfba3b4d2f71727f2f341f3b..71d0f475763cac5436c0c9d5db529ccd86728e26 100644 --- a/hypha/apply/projects/views/report.py +++ b/hypha/apply/projects/views/report.py @@ -1,5 +1,6 @@ from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import UserPassesTestMixin +from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect from django.utils.decorators import method_decorator from django.views import View @@ -13,11 +14,13 @@ from hypha.apply.users.decorators import staff_or_finance_required, staff_requir from hypha.apply.utils.storage import PrivateMediaView from hypha.apply.utils.views import DelegatedViewMixin +from ...stream_forms.models import BaseStreamForm from ..filters import ReportListFilter from ..forms import ReportEditForm, ReportFrequencyForm from ..models import Report, ReportConfig, ReportPrivateFiles from ..permissions import has_permission from ..tables import ReportListTable +from ..utils import get_placeholder_file class ReportingMixin: @@ -60,40 +63,109 @@ class ReportDetailView(DetailView): @method_decorator(login_required, name="dispatch") -class ReportUpdateView(UpdateView): - form_class = ReportEditForm +class ReportUpdateView(BaseStreamForm, UpdateView): model = Report + # Values for `object`, `form_class`, and `form_fields` are set during `dispatch` and functions it calls. + object = None + form_class = None + form_fields = None + + def get_form_class(self, draft=False, form_data=None, user=None): + """ + Expects self.form_fields to have already been set. + """ + if not self.form_fields: + raise RuntimeError("Expected self.form_fields to be set") + # This is where the magic happens. + fields = self.get_form_fields(draft, form_data, user) + the_class = type( + "WagtailStreamForm", + (ReportEditForm,), + fields, + ) + return the_class - def dispatch(self, *args, **kwargs): + def dispatch(self, request, *args, **kwargs): report = self.get_object() permission, _ = has_permission( "report_update", self.request.user, object=report, raise_exception=True ) - return super().dispatch(*args, **kwargs) + self.object = report + # super().dispatch calls get_context_data() which calls the rest to get the form fully ready for use. + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, *args, **kwargs): + """ + Django note: super().dispatch calls get_context_data. + """ + # Is this where we need to get the associated form fields? Not in the form itself but up here? Yes. But in a + # roundabout way: get_form (here) gets fields and calls get_form_class (here) which calls get_form_fields + # (super) which sets up the fields in the returned form. + form = self.get_form() + context_data = { + "form": form, + "object": self.object, + **kwargs, + } + return context_data + + def get_form(self, form_class=None): + if self.object.current is None or self.object.current.form_fields is None: + # Here is where we get the form_fields, the ProjectReportForm associated with the Fund: + report_form = ( + self.object.project.submission.page.specific.report_forms.first() + ) + if report_form: + self.form_fields = report_form.form.form_fields + else: + self.form_fields = {} + else: + self.form_fields = self.object.current.form_fields + + if form_class is None: + form_class = self.get_form_class() + report_instance = form_class(**self.get_form_kwargs()) + return report_instance def get_initial(self): + initial = {} if self.object.draft: current = self.object.draft else: current = self.object.current + # current here is a ReportVersion which should already have the data associated. if current: - return { - "public_content": current.public_content, - "private_content": current.private_content, - "file_list": current.files.all(), - } + # The following allows existing data to populate the form. This code was inspired by (aka copied from) + # ProjectApprovalFormEditView.get_paf_form_kwargs(). + initial = current.raw_data + # Is the following needed to see the file in a friendly URL? Does not appear so. But needed to not blow up. + for field_id in current.file_field_ids: + initial.pop(field_id + "-uploads", False) + initial[field_id] = get_placeholder_file(current.raw_data.get(field_id)) - return {} + return initial def get_form_kwargs(self): - return { + form_kwargs = { "user": self.request.user, **super().get_form_kwargs(), } + return form_kwargs + + def post(self, request, *args, **kwargs): + form = self.get_form() + if form.is_valid(): + form.save(form_fields=self.form_fields) + form.delete_temporary_files() + response = HttpResponseRedirect(self.get_success_url()) + else: + response = self.form_invalid(form) + return response def get_success_url(self): - return self.object.project.get_absolute_url() + success_url = self.object.project.get_absolute_url() + return success_url def form_valid(self, form): response = super().form_valid(form) diff --git a/hypha/apply/stream_forms/testing/factories.py b/hypha/apply/stream_forms/testing/factories.py index 3c23d0bcc671db664ceae8c80ad9a2e608047e3b..9b30e4f1fa145be6ccab6629d2a4c9ddbf343f73 100644 --- a/hypha/apply/stream_forms/testing/factories.py +++ b/hypha/apply/stream_forms/testing/factories.py @@ -318,7 +318,7 @@ class StreamFieldUUIDFactory(wagtail_factories.StreamFieldFactory): return flatten_for_form(data) -BLOCK_FACTORY_DEFINITION = { +NON_FILE_BLOCK_FACTORY_DEFINITION = { "text_markup": ParagraphBlockFactory, "char": CharFieldBlockFactory, "text": TextFieldBlockFactory, @@ -330,11 +330,20 @@ BLOCK_FACTORY_DEFINITION = { "date": DateFieldBlockFactory, "time": TimeFieldBlockFactory, "datetime": DateTimeFieldBlockFactory, +} + +BLOCK_FACTORY_DEFINITION = { + **NON_FILE_BLOCK_FACTORY_DEFINITION, "image": ImageFieldBlockFactory, "file": FileFieldBlockFactory, "multi_file": MultiFileFieldBlockFactory, } +# There are two here, because some tests will fail due to JSON serialization errors +# if SimpleUploadedFile is included in the factory (most notably Project ReportVersion tests) +NonFileFormFieldsBlockFactory = StreamFieldUUIDFactory( + NON_FILE_BLOCK_FACTORY_DEFINITION +) FormFieldsBlockFactory = StreamFieldUUIDFactory(BLOCK_FACTORY_DEFINITION)