From d7ba82adc1b08f896442845fe9aaca84de8a367a Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Tue, 9 Jun 2026 09:48:33 +0200 Subject: [PATCH 1/6] First stab at alloing a submission to have more than one project. --- hypha/apply/activity/services.py | 11 +- .../messages/email/assign_paf_approvers.html | 2 +- .../messages/email/paf_for_approval.html | 2 +- .../email/project_final_approval.html | 2 +- .../messages/email/sent_to_compliance.html | 2 +- .../apply/determinations/tests/test_views.py | 32 ++--- hypha/apply/determinations/views.py | 6 +- hypha/apply/funds/forms.py | 4 +- .../funds/applicationsubmission_detail.html | 23 ++-- .../apply/funds/templates/funds/comments.html | 25 ++-- .../funds/includes/admin_primary_actions.html | 8 +- .../funds/includes/co-applicant-block.html | 2 +- .../funds/includes/submission-list-row.html | 4 +- .../funds/includes/submission-table-row.html | 4 +- hypha/apply/funds/tests/test_views.py | 13 +- hypha/apply/funds/urls.py | 13 +- hypha/apply/projects/forms/project.py | 12 +- ...roject_options_alter_project_submission.py | 31 +++++ hypha/apply/projects/models/project.py | 30 +++-- hypha/apply/projects/reports/tables.py | 2 +- .../templates/reports/includes/reports.html | 4 +- hypha/apply/projects/reports/views.py | 2 +- hypha/apply/projects/tables.py | 7 +- .../includes/contracting_documents.html | 18 +-- .../includes/invoices.html | 2 +- .../includes/project_documents.html | 30 ++--- .../includes/project_header.html | 4 +- .../application_projects/invoice_detail.html | 4 +- .../modals/approve_contract.html | 2 +- .../contracting_category_documents.html | 14 +-- .../partials/invoice_detail_actions.html | 4 +- .../partials/invoice_status_table.html | 4 +- .../partials/project_information.html | 8 +- .../partials/project_lead.html | 4 +- .../partials/project_title.html | 4 +- .../partials/supporting_documents.html | 10 +- .../project_admin_detail.html | 4 +- .../project_approval_detail.html | 18 +-- .../application_projects/project_detail.html | 4 +- .../project_sow_detail.html | 6 +- .../submission_projects.html | 46 +++++++ hypha/apply/projects/tests/test_forms.py | 28 +++++ hypha/apply/projects/tests/test_models.py | 16 +++ hypha/apply/projects/tests/test_views.py | 115 ++++++++++++------ hypha/apply/projects/urls.py | 7 +- hypha/apply/projects/views/__init__.py | 2 + hypha/apply/projects/views/payment.py | 8 +- hypha/apply/projects/views/project.py | 101 ++++++++++----- .../apply/projects/views/project_partials.py | 10 +- 49 files changed, 472 insertions(+), 242 deletions(-) create mode 100644 hypha/apply/projects/migrations/0104_alter_project_options_alter_project_submission.py create mode 100644 hypha/apply/projects/templates/application_projects/submission_projects.html diff --git a/hypha/apply/activity/services.py b/hypha/apply/activity/services.py index 1eff7ae1c9..1babce10ee 100644 --- a/hypha/apply/activity/services.py +++ b/hypha/apply/activity/services.py @@ -75,14 +75,13 @@ def get_related_activities_for_user(obj, user): Returns: [`Activity`][hypha.apply.activity.models.Activity] queryset """ - if hasattr(obj, "project") and obj.project: - if ( - obj.co_applicants.filter(user=user).exists() - and not obj.co_applicants.filter(user=user).first().project_permission - ): + if hasattr(obj, "projects"): + # obj is an ApplicationSubmission, which may have several projects. + co_applicant = obj.co_applicants.filter(user=user).first() + if co_applicant and not co_applicant.project_permission: source_filter = Q(submission=obj) else: - source_filter = Q(submission=obj) | Q(project=obj.project) + source_filter = Q(submission=obj) | Q(project__in=obj.projects.all()) elif hasattr(obj, "submission") and obj.submission: source_filter = Q(submission=obj.submission) | Q(project=obj) else: diff --git a/hypha/apply/activity/templates/messages/email/assign_paf_approvers.html b/hypha/apply/activity/templates/messages/email/assign_paf_approvers.html index 0275bee9b4..22df57998f 100644 --- a/hypha/apply/activity/templates/messages/email/assign_paf_approvers.html +++ b/hypha/apply/activity/templates/messages/email/assign_paf_approvers.html @@ -7,7 +7,7 @@ {% trans "Project documents are ready to be assigned for approval." %} {% trans "Title" %}: {{ source.title_text_display }} -{% trans "Link" %}: {{ request.scheme }}://{{ request.get_host }}{% url 'apply:projects:approval' pk=source.submission.pk %} +{% trans "Link" %}: {{ request.scheme }}://{{ request.get_host }}{% url 'apply:projects:approval' pk=source.pk %} {% blocktrans with lead=source.lead email=source.lead.email %}Please contact {{ lead }} - {{ email }} if you have any questions.{% endblocktrans %} {% endblock %}{# fmt:on #} diff --git a/hypha/apply/activity/templates/messages/email/paf_for_approval.html b/hypha/apply/activity/templates/messages/email/paf_for_approval.html index bbf533da29..c15f334ad0 100644 --- a/hypha/apply/activity/templates/messages/email/paf_for_approval.html +++ b/hypha/apply/activity/templates/messages/email/paf_for_approval.html @@ -6,7 +6,7 @@ {% block content %}{# fmt:off #} {% blocktrans with title=source.title_text_display %}The {{ title }} project is awaiting your review.{% endblocktrans %} -{% trans "View the project here" %}: {{ request.scheme }}://{{ request.get_host }}{% url 'apply:projects:approval' pk=source.submission.pk %} +{% trans "View the project here" %}: {{ request.scheme }}://{{ request.get_host }}{% url 'apply:projects:approval' pk=source.pk %} {% blocktrans with lead=source.lead email=source.lead.email %}Please contact {{ lead }} - {{ email }} if you have any questions.{% endblocktrans %} {% endblock %}{# fmt:on #} diff --git a/hypha/apply/activity/templates/messages/email/project_final_approval.html b/hypha/apply/activity/templates/messages/email/project_final_approval.html index f1ba5720e6..253048f0a0 100644 --- a/hypha/apply/activity/templates/messages/email/project_final_approval.html +++ b/hypha/apply/activity/templates/messages/email/project_final_approval.html @@ -6,7 +6,7 @@ {% block content %}{# fmt:off #} {% blocktrans with title=source.title_text_display %}The project "{{title}}" is awaiting final approval.{% endblocktrans %} -{% trans "Approve the project here" %}: {{ request.scheme }}://{{ request.get_host }}{% url 'apply:projects:approval' pk=source.submission.pk %} +{% trans "Approve the project here" %}: {{ request.scheme }}://{{ request.get_host }}{% url 'apply:projects:approval' pk=source.pk %} {% blocktrans with lead=source.lead email=source.lead.email %}Please contact {{ lead }} - {{ email }} if you have any questions.{% endblocktrans %} {% endblock %}{# fmt:on #} diff --git a/hypha/apply/activity/templates/messages/email/sent_to_compliance.html b/hypha/apply/activity/templates/messages/email/sent_to_compliance.html index 5a0efb6f49..51e3a425f1 100644 --- a/hypha/apply/activity/templates/messages/email/sent_to_compliance.html +++ b/hypha/apply/activity/templates/messages/email/sent_to_compliance.html @@ -6,7 +6,7 @@ {% block content %}{# fmt:off #} {% blocktrans with title=source.title_text_display %}The project "{{ title }}" is awaiting your review.{% endblocktrans %} -{% trans "View the project here" %}: {{ request.scheme }}://{{ request.get_host }}{% url 'apply:projects:approval' pk=source.submission.pk %} +{% trans "View the project here" %}: {{ request.scheme }}://{{ request.get_host }}{% url 'apply:projects:approval' pk=source.pk %} {% blocktrans with lead=source.lead email=source.lead.email %}Please contact {{ lead }} - {{ email }} if you have any questions.{% endblocktrans %} {% endblock %}{# fmt:on #} diff --git a/hypha/apply/determinations/tests/test_views.py b/hypha/apply/determinations/tests/test_views.py index 17e60607e7..598628e123 100644 --- a/hypha/apply/determinations/tests/test_views.py +++ b/hypha/apply/determinations/tests/test_views.py @@ -202,8 +202,10 @@ def test_first_stage_accepted_determination_does_not_create_project(self): # Confirm a Project was not created for either submission since it's # not in the final stage of its workflow. - self.assertFalse(hasattr(submission_original, "project")) - self.assertFalse(hasattr(submission_next, "project")) + self.assertFalse(submission_original.projects.exists()) + self.assertFalse( + submission_next.projects.exists() if submission_next else False + ) def test_first_stage_rejected_determination_does_not_create_project(self): submission = ApplicationSubmissionFactory( @@ -232,7 +234,7 @@ def test_first_stage_rejected_determination_does_not_create_project(self): # Confirm a Project was not created for the original # ApplicationSubmission. - self.assertFalse(hasattr(submission_original, "project")) + self.assertFalse(submission_original.projects.exists()) def test_second_stage_accepted_determination_creates_project(self): submission = ApplicationSubmissionFactory( @@ -260,8 +262,10 @@ def test_second_stage_accepted_determination_creates_project(self): # applications flow. self.assertIsNone(submission_next) - self.assertTrue(hasattr(submission_original, "project")) - self.assertFalse(hasattr(submission_next, "project")) + self.assertTrue(submission_original.projects.exists()) + self.assertFalse( + submission_next.projects.exists() if submission_next else False + ) def test_second_stage_rejected_determination_does_not_create_project(self): submission = ApplicationSubmissionFactory( @@ -288,7 +292,7 @@ def test_second_stage_rejected_determination_does_not_create_project(self): # applications flow. self.assertIsNone(submission_next) - self.assertFalse(hasattr(submission_original, "project")) + self.assertFalse(submission_original.projects.exists()) def test_single_stage_accepted_determination_creates_project(self): submission = ApplicationSubmissionFactory( @@ -312,7 +316,7 @@ def test_single_stage_accepted_determination_creates_project(self): submission_next = submission_original.next self.assertIsNone(submission_next) - self.assertTrue(hasattr(submission_original, "project")) + self.assertTrue(submission_original.projects.exists()) def test_single_stage_rejected_determination_does_not_create_project(self): submission = ApplicationSubmissionFactory( @@ -336,7 +340,7 @@ def test_single_stage_rejected_determination_does_not_create_project(self): submission_next = submission_original.next self.assertIsNone(submission_next) - self.assertFalse(hasattr(submission_original, "project")) + self.assertFalse(submission_original.projects.exists()) @override_settings(PROJECTS_DEFAULT_STATUS="contracting") def test_auto_creation_uses_status_settings(self): @@ -357,8 +361,8 @@ def test_auto_creation_uses_status_settings(self): ) submission_original = self.refresh(submission) - self.assertTrue(hasattr(submission_original, "project")) - self.assertEqual(submission.project.status, CONTRACTING) + self.assertTrue(submission_original.projects.exists()) + self.assertEqual(submission.projects.first().status, CONTRACTING) @override_settings(PROJECTS_DEFAULT_STATUS="garbage") def test_auto_creation_uses_draft_when_invalid_status_settings(self): @@ -379,8 +383,8 @@ def test_auto_creation_uses_draft_when_invalid_status_settings(self): ) submission_original = self.refresh(submission) - self.assertTrue(hasattr(submission_original, "project")) - self.assertEqual(submission.project.status, DRAFT) + self.assertTrue(submission_original.projects.exists()) + self.assertEqual(submission.projects.first().status, DRAFT) @override_settings(PROJECTS_AUTO_CREATE=False) def test_disabling_project_auto_creation_stops_projects_being_created(self): @@ -405,7 +409,7 @@ def test_disabling_project_auto_creation_stops_projects_being_created(self): submission_next = submission_original.next self.assertIsNone(submission_next) - self.assertFalse(hasattr(submission_original, "project")) + self.assertFalse(submission_original.projects.exists()) @override_settings(PROJECTS_ENABLED=False, PROJECTS_AUTO_CREATE=True) def test_disabling_projects_ignores_auto_creation_setting(self): @@ -430,7 +434,7 @@ def test_disabling_projects_ignores_auto_creation_setting(self): submission_next = submission_original.next self.assertIsNone(submission_next) - self.assertFalse(hasattr(submission_original, "project")) + self.assertFalse(submission_original.projects.exists()) class BatchDeterminationTestCase(BaseViewTestCase): diff --git a/hypha/apply/determinations/views.py b/hypha/apply/determinations/views.py index b1e0a486f4..f686db873c 100644 --- a/hypha/apply/determinations/views.py +++ b/hypha/apply/determinations/views.py @@ -459,7 +459,11 @@ def form_valid(self, form): proposal_form=proposal_form, ) - if self.submission.accepted_for_funding and settings.PROJECTS_AUTO_CREATE: + if ( + self.submission.accepted_for_funding + and settings.PROJECTS_AUTO_CREATE + and not self.submission.projects.exists() + ): Project.create_from_submission(self.submission) messages.success( self.request, _("A project was automatically created.") diff --git a/hypha/apply/funds/forms.py b/hypha/apply/funds/forms.py index 82cb806f10..4f94a85e6e 100644 --- a/hypha/apply/funds/forms.py +++ b/hypha/apply/funds/forms.py @@ -472,7 +472,7 @@ def __init__(self, *args, submission, user=None, **kwargs): if submission: self.fields["submission"].initial = submission.id - if not hasattr(submission, "project"): + if not submission.projects.exists(): self.fields.pop("project_permission", None) class Meta: @@ -497,7 +497,7 @@ class EditCoApplicantForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) instance = kwargs.get("instance", None) - if not hasattr(instance.submission, "project"): + if not instance.submission.projects.exists(): self.fields.pop("project_permission", None) class Meta: diff --git a/hypha/apply/funds/templates/funds/applicationsubmission_detail.html b/hypha/apply/funds/templates/funds/applicationsubmission_detail.html index 59e4f680a7..6c3042edd6 100644 --- a/hypha/apply/funds/templates/funds/applicationsubmission_detail.html +++ b/hypha/apply/funds/templates/funds/applicationsubmission_detail.html @@ -8,11 +8,7 @@ {% block hero %} - {% if object.project %} - {% include "application_projects/includes/project_header.html" with object=object.project %} - {% else %} - {% include "funds/includes/application_header.html" %} - {% endif %} + {% include "funds/includes/application_header.html" %}