diff --git a/hypha/apply/funds/models/applications.py b/hypha/apply/funds/models/applications.py index 19f7846b30e4a48417ea8eb580764d40cf6db01a..b3d5ca4290767d345c2988c18e14b856edf2c625 100644 --- a/hypha/apply/funds/models/applications.py +++ b/hypha/apply/funds/models/applications.py @@ -19,6 +19,7 @@ from django.db.models import ( from django.db.models.functions import Coalesce, Left, Length from django.http import Http404 from django.shortcuts import render +from django.template.response import TemplateResponse from django.utils.functional import cached_property from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ @@ -356,10 +357,11 @@ class RoundBase(WorkflowStreamForm, SubmittableStreamForm): # type: ignore # Overriding serve method to pass submission id to get_form method copy_open_submission = request.GET.get('open_call_submission') if request.method == 'POST': + draft = request.POST.get('draft', False) form = self.get_form(request.POST, request.FILES, page=self, user=request.user) if form.is_valid(): - form_submission = self.process_form_submission(form) + form_submission = self.process_form_submission(form, draft=draft) return self.render_landing_page(request, form_submission, *args, **kwargs) else: form = self.get_form(page=self, user=request.user, submission_id=copy_open_submission) @@ -441,6 +443,24 @@ class LabBase(EmailForm, WorkflowStreamForm, SubmittableStreamForm): # type: ig def open_round(self): return self.live + def serve(self, request, *args, **kwargs): + if request.method == 'POST': + form = self.get_form(request.POST, request.FILES, page=self, user=request.user) + draft = request.POST.get('draft', False) + if form.is_valid(): + form_submission = SubmittableStreamForm.process_form_submission(self, form, draft=draft) + return self.render_landing_page(request, form_submission, *args, **kwargs) + else: + form = self.get_form(page=self, user=request.user) + + context = self.get_context(request) + context['form'] = form + return TemplateResponse( + request, + self.get_template(request), + context + ) + class RoundsAndLabsQueryset(PageQuerySet): def new(self): diff --git a/hypha/apply/funds/models/submissions.py b/hypha/apply/funds/models/submissions.py index 741293d6e432a457824e9a862815536ff965da05..f13ae29f76522ca729a8f6f55bbe325f366f61d1 100644 --- a/hypha/apply/funds/models/submissions.py +++ b/hypha/apply/funds/models/submissions.py @@ -45,6 +45,7 @@ from ..blocks import NAMED_BLOCKS, ApplicationCustomFormFieldsBlock from ..workflow import ( COMMUNITY_REVIEW_PHASES, DETERMINATION_RESPONSE_PHASES, + DRAFT_STATE, INITIAL_STATE, PHASES, PHASES_MAPPING, @@ -160,6 +161,9 @@ class ApplicationSubmissionQueryset(JSONOrderable): Sum('value'), ) + def exclude_draft(self): + return self.exclude(status=DRAFT_STATE) + def with_latest_update(self): activities = self.model.activities.rel.model latest_activity = activities.objects.filter(submission=OuterRef('id')).select_related('user') @@ -786,13 +790,21 @@ def log_status_update(sender, **kwargs): notify = kwargs['method_kwargs'].get('notify', True) if request and notify: - messenger( - MESSAGES.TRANSITION, - user=by, - request=request, - source=instance, - related=old_phase, - ) + if kwargs['source'] == DRAFT_STATE: + messenger( + MESSAGES.NEW_SUBMISSION, + request=request, + user=by, + source=instance, + ) + else: + messenger( + MESSAGES.TRANSITION, + user=by, + request=request, + source=instance, + related=old_phase, + ) if instance.status in review_statuses: messenger( diff --git a/hypha/apply/funds/models/utils.py b/hypha/apply/funds/models/utils.py index caa8194a2d7205fd4d212abed754e20cdc19b389..1c5de3edc8b781d920e6da62a79858a408000ccc 100644 --- a/hypha/apply/funds/models/utils.py +++ b/hypha/apply/funds/models/utils.py @@ -18,7 +18,7 @@ from hypha.apply.users.groups import ( STAFF_GROUP_NAME, ) -from ..workflow import WORKFLOWS +from ..workflow import DRAFT_STATE, WORKFLOWS REVIEW_GROUPS = [ STAFF_GROUP_NAME, @@ -65,14 +65,22 @@ class SubmittableStreamForm(AbstractStreamForm): def get_submission_class(self): return self.submission_class - def process_form_submission(self, form): + def process_form_submission(self, form, draft=False): if not form.user.is_authenticated: form.user = None - return self.get_submission_class().objects.create( - form_data=form.cleaned_data, - form_fields=self.get_defined_fields(), - **self.get_submit_meta_data(user=form.user), - ) + if draft: + return self.get_submission_class().objects.create( + form_data=form.cleaned_data, + form_fields=self.get_defined_fields(), + **self.get_submit_meta_data(user=form.user), + status=DRAFT_STATE, + ) + else: + return self.get_submission_class().objects.create( + form_data=form.cleaned_data, + form_fields=self.get_defined_fields(), + **self.get_submit_meta_data(user=form.user), + ) def get_submit_meta_data(self, **kwargs): return kwargs @@ -95,13 +103,14 @@ class WorkflowStreamForm(WorkflowHelpers, AbstractStreamForm): # type: ignore def render_landing_page(self, request, form_submission=None, *args, **kwargs): # We only reach this page after creation of a new submission # Hook in to notify about new applications - messenger( - MESSAGES.NEW_SUBMISSION, - request=request, - user=form_submission.user, - source=form_submission, - ) - return super().render_landing_page(request, form_submission=None, *args, **kwargs) + if not form_submission.status == DRAFT_STATE: + messenger( + MESSAGES.NEW_SUBMISSION, + request=request, + user=form_submission.user, + source=form_submission, + ) + return super().render_landing_page(request, form_submission, *args, **kwargs) content_panels = AbstractStreamForm.content_panels + [ FieldPanel('workflow_name'), diff --git a/hypha/apply/funds/templates/funds/application_base.html b/hypha/apply/funds/templates/funds/application_base.html index 86739048690ee4565fe789532bde602d51c921ac..32b43e12a1c729138a5c19ae1004092111e6fbff 100644 --- a/hypha/apply/funds/templates/funds/application_base.html +++ b/hypha/apply/funds/templates/funds/application_base.html @@ -54,6 +54,7 @@ {% endif %} {% endfor %} <button class="link link--button-secondary" type="submit" disabled>Submit for review</button> + <button class="link link--button-tertiary" type="submit" name="draft" value="Save Draft" formnovalidate>Save Draft</button> </form> <p class="wrapper--error message-no-js js-hidden">You must have Javascript enabled to use this form.</p> {% endif %} diff --git a/hypha/apply/funds/templates/funds/application_base_landing.html b/hypha/apply/funds/templates/funds/application_base_landing.html index 2d8ac2c8dd483023a3dc0baf63cb30a2100f14f9..b1d5080fabaabec350895f4264a896515c1b85d7 100644 --- a/hypha/apply/funds/templates/funds/application_base_landing.html +++ b/hypha/apply/funds/templates/funds/application_base_landing.html @@ -2,9 +2,17 @@ {% block header_modifier %}header--light-bg{% endblock %} {% block content %} <div class="wrapper wrapper--small"> - <h3>Thank you for your submission to the {{ ORG_LONG_NAME }}.</h3> + {% if form_submission.status == 'draft' %} + <h3>Your application is saved as a draft.</h3> + {% else %} + <h3>Thank you for your submission to the {{ ORG_LONG_NAME }}.</h3> + {% endif %} <div class="rich-text"> - <p>An e-mail with more information has been sent to the address you entered.</p> + {% if form_submission.status == 'draft' %} + <p>Please note that it is not submitted for review. You can complete your application by following the log-in details emailed to you.</p> + {% else %} + <p>An e-mail with more information has been sent to the address you entered.</p> + {% endif %} <p>If you do not receive an e-mail within 15 minutes please check your spam folder and contact {{ ORG_EMAIL|urlize }} for further assistance.</p> {% with email_context=page.specific %}<p>{{ email_context.confirmation_text_extra|urlize }}</p>{% endwith %} diff --git a/hypha/apply/funds/tests/test_models.py b/hypha/apply/funds/tests/test_models.py index a0486ff7f0bb8e331d7cc544d8599c3af0e212ab..fd04152a81638d4866062d2aee506d35867dad33 100644 --- a/hypha/apply/funds/tests/test_models.py +++ b/hypha/apply/funds/tests/test_models.py @@ -202,7 +202,7 @@ class TestFormSubmission(TestCase): self.round_page = RoundFactory(parent=fund, now=True) self.lab_page = LabFactory(lead=self.round_page.lead) - def submit_form(self, page=None, email=None, name=None, user=AnonymousUser(), ignore_errors=False): + def submit_form(self, page=None, email=None, name=None, draft=None, user=AnonymousUser(), ignore_errors=False): page = page or self.round_page fields = page.forms.first().fields @@ -214,6 +214,8 @@ class TestFormSubmission(TestCase): data[field.id] = self.email if email is None else email if isinstance(field.block, FullNameBlock): data[field.id] = self.name if name is None else name + if draft: + data['draft'] = 'Save Draft' request = make_request(user, data, method='post', site=self.site) @@ -228,17 +230,31 @@ class TestFormSubmission(TestCase): self.assertNotContains(response, 'errors') return response + def test_workflow_and_draft(self): + self.submit_form(draft=True) + submission = ApplicationSubmission.objects.first() + first_phase = list(self.round_page.workflow.keys())[0] + self.assertEqual(submission.workflow, self.round_page.workflow) + self.assertEqual(submission.status, first_phase) + + def test_workflow_and_draft_lab(self): + self.submit_form(page=self.lab_page, draft=True) + submission = ApplicationSubmission.objects.first() + first_phase = list(self.lab_page.workflow.keys())[0] + self.assertEqual(submission.workflow, self.lab_page.workflow) + self.assertEqual(submission.status, first_phase) + def test_workflow_and_status_assigned(self): self.submit_form() submission = ApplicationSubmission.objects.first() - first_phase = list(self.round_page.workflow.keys())[0] + first_phase = list(self.round_page.workflow.keys())[1] self.assertEqual(submission.workflow, self.round_page.workflow) self.assertEqual(submission.status, first_phase) def test_workflow_and_status_assigned_lab(self): self.submit_form(page=self.lab_page) submission = ApplicationSubmission.objects.first() - first_phase = list(self.lab_page.workflow.keys())[0] + first_phase = list(self.lab_page.workflow.keys())[1] self.assertEqual(submission.workflow, self.lab_page.workflow) self.assertEqual(submission.status, first_phase) diff --git a/hypha/apply/funds/tests/test_views.py b/hypha/apply/funds/tests/test_views.py index 19a51b30aabc8a4cc2aed0869b55f77731755d6e..3c7256f049be6afb349cf50801a8ea6bc693b822 100644 --- a/hypha/apply/funds/tests/test_views.py +++ b/hypha/apply/funds/tests/test_views.py @@ -40,7 +40,7 @@ from hypha.apply.utils.testing import make_request from hypha.apply.utils.testing.tests import BaseViewTestCase from ..models import ApplicationRevision, ApplicationSubmission -from ..views import SubmissionDetailSimplifiedView +from ..views import SubmissionDetailSimplifiedView, SubmissionDetailView from .factories import CustomFormFieldsFactory @@ -432,6 +432,29 @@ class TestStaffSubmissionView(BaseSubmissionViewTestCase): DeterminationFactory(submission=submission, author=self.user, accepted=True, submitted=False) assert_view_determination_not_displayed(submission) + def test_cant_see_application_draft_status(self): + factory = RequestFactory() + submission = ApplicationSubmissionFactory(status='draft') + ProjectFactory(submission=submission) + + request = factory.get(f'/submission/{submission.pk}') + request.user = StaffFactory() + + with self.assertRaises(Http404): + SubmissionDetailView.as_view()(request, pk=submission.pk) + + def test_applicant_can_see_application_draft_status(self): + factory = RequestFactory() + user = ApplicantFactory() + submission = ApplicationSubmissionFactory(status='draft', user=user) + ProjectFactory(submission=submission) + + request = factory.get(f'/submission/{submission.pk}') + request.user = user + + response = SubmissionDetailView.as_view()(request, pk=submission.pk) + self.assertEqual(response.status_code, 200) + class TestReviewersUpdateView(BaseSubmissionViewTestCase): user_factory = StaffFactory diff --git a/hypha/apply/funds/views.py b/hypha/apply/funds/views.py index 637dc91d84d573a42a7efac298eedff05bfb6fcd..a7fec42b8d8222f887ebb4f8d7c2752c83427748 100644 --- a/hypha/apply/funds/views.py +++ b/hypha/apply/funds/views.py @@ -94,6 +94,7 @@ from .tables import ( SummarySubmissionsTable, ) from .workflow import ( + DRAFT_STATE, INITIAL_STATE, PHASES_MAPPING, STAGE_CHANGE_ACTIONS, @@ -143,7 +144,7 @@ class UpdateReviewersMixin: action = None if submission.status == INITIAL_STATE: # Automatically transition the application to "Internal review". - action = submission.workflow.stepped_phases[1][0].name + action = submission.workflow.stepped_phases[2][0].name elif submission.status == 'proposal_discussion': # Automatically transition the proposal to "Internal review". action = 'proposal_internal_review' @@ -186,7 +187,7 @@ class BaseAdminSubmissionsTable(SingleTableMixin, FilterView): return new_kwargs def get_queryset(self): - return self.filterset_class._meta.model.objects.current().for_table(self.request.user) + return self.filterset_class._meta.model.objects.exclude_draft().current().for_table(self.request.user) def get_context_data(self, **kwargs): search_term = self.request.GET.get('query') @@ -703,6 +704,8 @@ class AdminSubmissionDetailView(ReviewContextMixin, ActivityContextMixin, Delega def dispatch(self, request, *args, **kwargs): submission = self.get_object() + if submission.status == DRAFT_STATE and not request.user == submission.user: + raise Http404 redirect = SubmissionSealedView.should_redirect(request, submission) return redirect or super().dispatch(request, *args, **kwargs) @@ -731,6 +734,8 @@ class ReviewerSubmissionDetailView(ReviewContextMixin, ActivityContextMixin, Del # Reviewers may sometimes be applicants as well. if submission.user == request.user: return ApplicantSubmissionDetailView.as_view()(request, *args, **kwargs) + if submission.status == DRAFT_STATE: + raise Http404 return super().dispatch(request, *args, **kwargs) @@ -751,6 +756,8 @@ class PartnerSubmissionDetailView(ActivityContextMixin, DelegateableView, Detail partner_has_access = submission.partners.filter(pk=request.user.pk).exists() if not partner_has_access: raise PermissionDenied + if submission.status == DRAFT_STATE: + raise Http404 return super().dispatch(request, *args, **kwargs) @@ -768,6 +775,8 @@ class CommunitySubmissionDetailView(ReviewContextMixin, ActivityContextMixin, De # Only allow community reviewers in submission with a community review state. if not submission.community_review: raise PermissionDenied + if submission.status == DRAFT_STATE: + raise Http404 return super().dispatch(request, *args, **kwargs) @@ -938,7 +947,7 @@ class ApplicantSubmissionEditView(BaseSubmissionEditView): user=self.request.user, source=self.object, ) - elif revision: + elif revision and not self.object.status == DRAFT_STATE: messenger( MESSAGES.APPLICANT_EDIT, request=self.request, @@ -957,7 +966,7 @@ class ApplicantSubmissionEditView(BaseSubmissionEditView): transition.target, self.request.user, request=self.request, - notify=not (revision or submitting_proposal), # Use the other notification + notify=not (revision or submitting_proposal) or self.object.status == DRAFT_STATE, # Use the other notification ) return HttpResponseRedirect(self.get_success_url()) diff --git a/hypha/apply/funds/workflow.py b/hypha/apply/funds/workflow.py index d9888b11af49210096b49662c33b40ad30229f25..849485f0bd33767da09ef49e584fa94073dc2290 100644 --- a/hypha/apply/funds/workflow.py +++ b/hypha/apply/funds/workflow.py @@ -190,10 +190,25 @@ Concept = Stage('Concept', False) Proposal = Stage('Proposal', True) +DRAFT_STATE = 'draft' INITIAL_STATE = 'in_discussion' SingleStageDefinition = [ + { + DRAFT_STATE: { + 'transitions': { + INITIAL_STATE: { + 'display': 'Submit', + 'permissions': {UserPermissions.APPLICANT}, + 'method': 'create_revision', + }, + }, + 'display': 'Draft', + 'stage': Request, + 'permissions': applicant_edit_permissions, + } + }, { INITIAL_STATE: { 'transitions': { @@ -293,6 +308,20 @@ SingleStageDefinition = [ ] SingleStageExternalDefinition = [ + { + DRAFT_STATE: { + 'transitions': { + INITIAL_STATE: { + 'display': 'Submit', + 'permissions': {UserPermissions.APPLICANT}, + 'method': 'create_revision', + }, + }, + 'display': 'Draft', + 'stage': RequestExt, + 'permissions': applicant_edit_permissions, + } + }, { INITIAL_STATE: { 'transitions': { @@ -423,6 +452,20 @@ SingleStageExternalDefinition = [ SingleStageCommunityDefinition = [ + { + DRAFT_STATE: { + 'transitions': { + INITIAL_STATE: { + 'display': 'Submit', + 'permissions': {UserPermissions.APPLICANT}, + 'method': 'create_revision', + }, + }, + 'display': 'Draft', + 'stage': RequestCom, + 'permissions': applicant_edit_permissions, + } + }, { INITIAL_STATE: { 'transitions': { @@ -577,6 +620,20 @@ SingleStageCommunityDefinition = [ DoubleStageDefinition = [ + { + DRAFT_STATE: { + 'transitions': { + INITIAL_STATE: { + 'display': 'Submit', + 'permissions': {UserPermissions.APPLICANT}, + 'method': 'create_revision', + }, + }, + 'display': 'Draft', + 'stage': Concept, + 'permissions': applicant_edit_permissions, + } + }, { INITIAL_STATE: { 'transitions': { diff --git a/hypha/apply/review/views.py b/hypha/apply/review/views.py index d4ed8a4dbc0d158b6a5559b87ff76482f5fffc0e..06526cf6863c8dcbd92d7ac6399aa517cb7f33f1 100644 --- a/hypha/apply/review/views.py +++ b/hypha/apply/review/views.py @@ -187,13 +187,13 @@ def review_workflow_actions(request, submission): action = None if submission.status == INITIAL_STATE: # Automatically transition the application to "Internal review". - action = submission_stepped_phases[1][0].name + action = submission_stepped_phases[2][0].name elif submission.status == 'proposal_discussion': # Automatically transition the proposal to "Internal review". action = 'proposal_internal_review' - elif submission.status == submission_stepped_phases[1][0].name and submission.reviews.count() > 1: + elif submission.status == submission_stepped_phases[2][0].name and submission.reviews.count() > 1: # Automatically transition the application to "Ready for discussion". - action = submission_stepped_phases[2][0].name + action = submission_stepped_phases[3][0].name elif submission.status == 'ext_external_review' and submission.reviews.by_reviewers().count() > 1: # Automatically transition the application to "Ready for discussion". action = 'ext_post_external_review_discussion' diff --git a/hypha/static_src/src/javascript/apply/submission-form-copy.js b/hypha/static_src/src/javascript/apply/submission-form-copy.js index c18fce9c441e9f56463ef7fe0c297ecbcf9865be..a824fa4a48f8fe74c462f2148e131bf218e5e0a7 100644 --- a/hypha/static_src/src/javascript/apply/submission-form-copy.js +++ b/hypha/static_src/src/javascript/apply/submission-form-copy.js @@ -81,7 +81,7 @@ .attr('title', 'Copies all the questions and user input to the clipboard in plain text.'); var $application_form = $('.application-form'); $button.clone().css({'display': 'block', 'margin-left': 'auto'}).insertBefore($application_form); - $button.insertAfter($application_form.find('.link--button-secondary').last()); + $button.css({'margin-left': '20px'}).insertAfter($application_form.find('button').last()); $('.js-clipboard-button').on('click', function (e) { e.preventDefault();