From f3c014d7be815ba09c8fe341cbc0c48f957d7755 Mon Sep 17 00:00:00 2001 From: Todd Dembrey <todd.dembrey@torchbox.com> Date: Fri, 11 Oct 2019 14:09:15 +0100 Subject: [PATCH] Feature/tidy up applicant views (#1595) * Ensure the applicant dashboard loads when they have projects * Limit applicant visibility for entities on project page * Track the progress of a project for the applicant's viewpoint * Improve messaging to applicant around contracts * Ensure the payment request works for the applicant * Fixup the tests for the applicant changes --- opentech/apply/activity/messaging.py | 6 +- .../migrations/0048_add_project_transition.py | 18 +++++ opentech/apply/activity/options.py | 1 + .../messages/email/contract_uploaded.html | 7 ++ .../activity/templatetags/activity_tags.py | 3 +- opentech/apply/dashboard/views.py | 4 +- opentech/apply/projects/forms.py | 30 ++++---- ...3_ensure_contracts_uses_private_storage.py | 20 ++++++ .../0024_allow_no_comments_on_pr.py | 18 +++++ opentech/apply/projects/models.py | 32 ++++++--- .../includes/payment_requests.html | 17 ++--- .../includes/supporting_documents.html | 11 +-- .../paymentrequest_confirm_delete.html | 37 ++++++++++ .../paymentrequest_detail.html | 6 ++ .../paymentrequest_form.html | 6 +- .../application_projects/project_detail.html | 11 +-- .../projects/templatetags/approval_tools.py | 5 ++ opentech/apply/projects/tests/test_forms.py | 10 +-- opentech/apply/projects/tests/test_models.py | 4 +- .../apply/projects/tests/test_templatetags.py | 4 +- opentech/apply/projects/tests/test_views.py | 5 +- opentech/apply/projects/urls.py | 4 ++ opentech/apply/projects/views/payment.py | 59 ++++++++-------- opentech/apply/projects/views/project.py | 68 ++++++++++++++++--- .../sass/apply/components/_payment-block.scss | 12 ++-- 25 files changed, 286 insertions(+), 112 deletions(-) create mode 100644 opentech/apply/activity/migrations/0048_add_project_transition.py create mode 100644 opentech/apply/projects/migrations/0023_ensure_contracts_uses_private_storage.py create mode 100644 opentech/apply/projects/migrations/0024_allow_no_comments_on_pr.py create mode 100644 opentech/apply/projects/templates/application_projects/paymentrequest_confirm_delete.html diff --git a/opentech/apply/activity/messaging.py b/opentech/apply/activity/messaging.py index 74fb6c01a..77a92b8a1 100644 --- a/opentech/apply/activity/messaging.py +++ b/opentech/apply/activity/messaging.py @@ -55,8 +55,11 @@ neat_related = { MESSAGES.DELETE_REVIEW: 'review', MESSAGES.EDIT_REVIEW: 'review', MESSAGES.CREATED_PROJECT: 'submission', + MESSAGES.PROJECT_TRANSITION: 'old_stage', MESSAGES.UPDATE_PROJECT_LEAD: 'old_lead', MESSAGES.APPROVE_CONTRACT: 'contract', + MESSAGES.UPLOAD_CONTRACT: 'contract', + MESSAGES.REQUEST_PAYMENT: 'payment_request', MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: 'payment_request', MESSAGES.DELETE_PAYMENT_REQUEST: 'payment_request', MESSAGES.UPDATE_PAYMENT_REQUEST: 'payment_request', @@ -219,11 +222,12 @@ class ActivityAdapter(AdapterBase): MESSAGES.SCREENING: 'Screening status from {old_status} to {source.screening_status}', MESSAGES.REVIEW_OPINION: '{user} {opinion.opinion_display}s with {opinion.review.author}''s review of {source}', MESSAGES.CREATED_PROJECT: 'Created', + MESSAGES.PROJECT_TRANSITION: 'Progressed from {old_stage} to {source.status_display}', MESSAGES.UPDATE_PROJECT_LEAD: 'Lead changed from {old_lead} to {source.lead}', MESSAGES.SEND_FOR_APPROVAL: 'Requested approval', MESSAGES.APPROVE_PROJECT: 'Approved', MESSAGES.REQUEST_PROJECT_CHANGE: 'Requested changes for acceptance: "{comment}"', - MESSAGES.UPLOAD_CONTRACT: 'Uploaded a contract', + MESSAGES.UPLOAD_CONTRACT: 'Uploaded a {contract.state} contract', MESSAGES.APPROVE_CONTRACT: 'Approved contract', MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: 'Updated Payment Request status to: {payment_request.status_display}', MESSAGES.REQUEST_PAYMENT: 'Payment Request submitted', diff --git a/opentech/apply/activity/migrations/0048_add_project_transition.py b/opentech/apply/activity/migrations/0048_add_project_transition.py new file mode 100644 index 000000000..b576b1ed8 --- /dev/null +++ b/opentech/apply/activity/migrations/0048_add_project_transition.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.11 on 2019-09-05 05:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0047_add_update_payment_request'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('PROJECT_TRANSITION', 'Project was Transitioned'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project'), ('UPLOAD_CONTRACT', 'Contract was Uploaded to Project'), ('APPROVE_CONTRACT', 'Contract was Approved'), ('REQUEST_PAYMENT', 'Payment was requested for Project'), ('UPDATE_PAYMENT_REQUEST_STATUS', 'Updated Payment Request Status'), ('DELETE_PAYMENT_REQUEST', 'Delete Payment Request'), ('SENT_TO_COMPLIANCE', 'Project was sent to Compliance'), ('UPDATE_PAYMENT_REQUEST', 'Updated Payment Request')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/options.py b/opentech/apply/activity/options.py index e04ddcf90..7d2f9bb58 100644 --- a/opentech/apply/activity/options.py +++ b/opentech/apply/activity/options.py @@ -31,6 +31,7 @@ class MESSAGES(Enum): EDIT_REVIEW = 'Edit Review' SEND_FOR_APPROVAL = 'Send for Approval' APPROVE_PROJECT = 'Project was Approved' + PROJECT_TRANSITION = 'Project was Transitioned' REQUEST_PROJECT_CHANGE = 'Project change requested' UPLOAD_DOCUMENT = 'Document was Uploaded to Project' REMOVE_DOCUMENT = 'Document was Removed from Project' diff --git a/opentech/apply/activity/templates/messages/email/contract_uploaded.html b/opentech/apply/activity/templates/messages/email/contract_uploaded.html index fdda12cc6..5a5aeed5b 100644 --- a/opentech/apply/activity/templates/messages/email/contract_uploaded.html +++ b/opentech/apply/activity/templates/messages/email/contract_uploaded.html @@ -5,4 +5,11 @@ A new contract has been added to your Project: Title: {{ source.title }} Link: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }} + +{% if contract.is_signed %} +This contract has already been signed and there is no action for you to take. +{% else %} +Please review the contract and sign it before reuploading it to your Project page for the {{ ORG_SHORT_NAME }} Team to approve. +{% endif %} + {% endblock %} diff --git a/opentech/apply/activity/templatetags/activity_tags.py b/opentech/apply/activity/templatetags/activity_tags.py index 9a1aa5e4e..649e48387 100644 --- a/opentech/apply/activity/templatetags/activity_tags.py +++ b/opentech/apply/activity/templatetags/activity_tags.py @@ -3,6 +3,7 @@ import json from django import template from opentech.apply.determinations.models import Determination +from opentech.apply.projects.models import Contract from opentech.apply.review.models import Review from ..models import TEAM, ALL, REVIEWER @@ -25,7 +26,7 @@ def user_can_see_related(activity, user): if user.is_apply_staff: return True - if isinstance(activity.related_object, Determination): + if isinstance(activity.related_object, (Determination, Contract)): return True return False diff --git a/opentech/apply/dashboard/views.py b/opentech/apply/dashboard/views.py index 646141271..148bddc9b 100644 --- a/opentech/apply/dashboard/views.py +++ b/opentech/apply/dashboard/views.py @@ -328,7 +328,7 @@ class ApplicantDashboardView(MultiTableMixin, TemplateView): return context def get_active_project_data(self, user): - return Project.objects.filter(user=user).in_progress() + return Project.objects.filter(user=user).in_progress().for_table() def get_active_submissions(self, user): active_subs = ApplicationSubmission.objects.filter( @@ -339,7 +339,7 @@ class ApplicantDashboardView(MultiTableMixin, TemplateView): yield submission.from_draft() def get_historical_project_data(self, user): - return Project.objects.filter(user=user).complete() + return Project.objects.filter(user=user).complete().for_table() def get_historical_submission_data(self, user): return ApplicationSubmission.objects.filter( diff --git a/opentech/apply/projects/forms.py b/opentech/apply/projects/forms.py index 693588845..5dc566f88 100644 --- a/opentech/apply/projects/forms.py +++ b/opentech/apply/projects/forms.py @@ -199,19 +199,21 @@ class RemoveDocumentForm(forms.ModelForm): super().__init__(*args, **kwargs) -class RequestPaymentForm(forms.ModelForm): - receipts = MultiFileField() - +class PaymentRequestBaseForm(forms.ModelForm): class Meta: - fields = ['requested_value', 'invoice', 'date_from', 'date_to', 'receipts', 'comment'] + fields = ['requested_value', 'invoice', 'date_from', 'date_to'] model = PaymentRequest widgets = { 'date_from': forms.DateInput, 'date_to': forms.DateInput, } + labels = { + 'requested_value': 'Requested Value ($)' + } - def __init__(self, user=None, instance=None, *args, **kwargs): + def __init__(self, user=None, *args, **kwargs): super().__init__(*args, **kwargs) + self.fields['requested_value'].widget.attrs['min'] = 0 def clean(self): cleaned_data = super().clean() @@ -223,6 +225,10 @@ class RequestPaymentForm(forms.ModelForm): return cleaned_data + +class CreatePaymentRequestForm(PaymentRequestBaseForm): + receipts = MultiFileField() + def save(self, commit=True): request = super().save(commit=commit) @@ -234,7 +240,7 @@ class RequestPaymentForm(forms.ModelForm): return request -class EditPaymentRequestForm(forms.ModelForm): +class EditPaymentRequestForm(PaymentRequestBaseForm): receipt_list = forms.ModelMultipleChoiceField( widget=forms.CheckboxSelectMultiple(attrs={'class': 'delete'}), queryset=PaymentReceipt.objects.all(), @@ -243,14 +249,6 @@ class EditPaymentRequestForm(forms.ModelForm): ) receipts = MultiFileField(label='', required=False) - class Meta: - fields = ['invoice', 'requested_value', 'date_from', 'date_to', 'receipt_list', 'receipts', 'comment'] - model = PaymentRequest - widgets = { - 'date_from': forms.DateInput, - 'date_to': forms.DateInput, - } - def __init__(self, user=None, instance=None, *args, **kwargs): super().__init__(*args, instance=instance, **kwargs) @@ -259,8 +257,8 @@ class EditPaymentRequestForm(forms.ModelForm): self.fields['requested_value'].label = 'Value' @transaction.atomic - def save(self, *args, **kwargs): - request = super().save(*args, **kwargs) + def save(self, commit=True): + request = super().save(commit=commit) removed_receipts = self.cleaned_data['receipt_list'] diff --git a/opentech/apply/projects/migrations/0023_ensure_contracts_uses_private_storage.py b/opentech/apply/projects/migrations/0023_ensure_contracts_uses_private_storage.py new file mode 100644 index 000000000..b7cbcf235 --- /dev/null +++ b/opentech/apply/projects/migrations/0023_ensure_contracts_uses_private_storage.py @@ -0,0 +1,20 @@ +# Generated by Django 2.1.11 on 2019-09-05 08:48 + +import django.core.files.storage +from django.db import migrations, models +import opentech.apply.projects.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0022_update_field_definitions_for_forms'), + ] + + operations = [ + migrations.AlterField( + model_name='contract', + name='file', + field=models.FileField(storage=django.core.files.storage.FileSystemStorage(), upload_to=opentech.apply.projects.models.contract_path), + ), + ] diff --git a/opentech/apply/projects/migrations/0024_allow_no_comments_on_pr.py b/opentech/apply/projects/migrations/0024_allow_no_comments_on_pr.py new file mode 100644 index 000000000..a694b4d96 --- /dev/null +++ b/opentech/apply/projects/migrations/0024_allow_no_comments_on_pr.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.11 on 2019-09-05 08:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0023_ensure_contracts_uses_private_storage'), + ] + + operations = [ + migrations.AlterField( + model_name='paymentrequest', + name='comment', + field=models.TextField(blank=True), + ), + ] diff --git a/opentech/apply/projects/models.py b/opentech/apply/projects/models.py index 9aba62c2d..7e2739bd1 100644 --- a/opentech/apply/projects/models.py +++ b/opentech/apply/projects/models.py @@ -75,16 +75,22 @@ class Contract(models.Model): approver = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name='contracts') project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="contracts") - file = models.FileField(upload_to=contract_path) + file = models.FileField(upload_to=contract_path, storage=PrivateStorage()) is_signed = models.BooleanField("Signed?", default=False) created_at = models.DateTimeField(auto_now_add=True) objects = ContractQuerySet.as_manager() + @property + def state(self): + return 'Signed' if self.is_signed else 'Unsigned' + def __str__(self): - state = 'Signed' if self.is_signed else 'Unsigned' - return f'Contract for {self.project} ({state})' + return f'Contract for {self.project} ({self.state})' + + def get_absolute_url(self): + return reverse('apply:projects:contract', args=[self.project.pk, self.pk]) class PacketFile(models.Model): @@ -190,7 +196,7 @@ class PaymentRequest(models.Model): requested_at = models.DateTimeField(auto_now_add=True) date_from = models.DateTimeField() date_to = models.DateTimeField() - comment = models.TextField() + comment = models.TextField(blank=True) status = models.TextField(choices=REQUEST_STATUS_CHOICES, default=SUBMITTED) objects = PaymentRequestQueryset.as_manager() @@ -211,6 +217,10 @@ class PaymentRequest(models.Model): if self.status in (SUBMITTED, CHANGES_REQUESTED): return True + if user.is_apply_staff: + if self.status in {SUBMITTED}: + return True + return False def can_user_edit(self, user): @@ -224,13 +234,6 @@ class PaymentRequest(models.Model): return False - def user_can_delete(self, user): - if user.is_apply_staff: - if self.status in {SUBMITTED}: - return True - - return False - def can_user_change_status(self, user): if not user.is_apply_staff: return False # Users can't change status @@ -341,6 +344,10 @@ class Project(BaseStreamForm, AccessFormData, models.Model): def __str__(self): return self.title + @property + def status_display(self): + return self.get_status_display() + def get_address_display(self): address = json.loads(self.contact_address) return ', '.join( @@ -415,6 +422,9 @@ class Project(BaseStreamForm, AccessFormData, models.Model): @property def editable(self): + if self.status not in (CONTRACTING, COMMITTED): + return True + # Someone has approved the project - consider it locked while with contracting if self.approvals.exists(): return False diff --git a/opentech/apply/projects/templates/application_projects/includes/payment_requests.html b/opentech/apply/projects/templates/application_projects/includes/payment_requests.html index 103e91908..940cd498a 100644 --- a/opentech/apply/projects/templates/application_projects/includes/payment_requests.html +++ b/opentech/apply/projects/templates/application_projects/includes/payment_requests.html @@ -3,10 +3,8 @@ <div id="payment-requests" class="payment-block"> <div class="payment-block__header"> <p class="payment-block__title">Payment Requests</p> - <a data-fancybox - data-src="#request-payment" - class="payment-block__button button button--primary" - href="#"> + <a class="payment-block__button button button--primary" + href="{% url "apply:projects:request" pk=object.pk %}"> Add Request </a> </div> @@ -32,19 +30,16 @@ <a href="{{ payment_request.get_absolute_url }}">View</a> {% can_edit payment_request user as user_can_edit_request %} {% if user_can_edit_request %} - <a - class="payment-block__edit-link" - href="{% url "apply:projects:payments:edit" pk=payment_request.pk %}"> + <a href="{% url "apply:projects:payments:edit" pk=payment_request.pk %}"> Edit </a> {% endif %} {% can_delete payment_request user as user_can_delete_request %} {% if user_can_delete_request %} - <form method="POST" action="{% url 'apply:projects:delete-payment-request' pk=object.pk payment_request_id=payment_request.pk %}"> - {% csrf_token %} - <button class="button button--primary" type="submit">Delete</button> - </form> + <a href="{% url 'apply:projects:payments:delete' pk=payment_request.pk %}"> + Delete + </a> {% endif %} </td> </tr> diff --git a/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html b/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html index 80d56ac2e..597258a5b 100644 --- a/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html +++ b/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html @@ -14,7 +14,9 @@ </div> <div class="docs-block__row-inner"> <a class="docs-block__link" href="{% url 'apply:submissions:simplified' pk=project.submission.pk %}">View</a> - <a class="docs-block__link" href="#">Download</a> + {% if not user.is_applicant %} + <a class="docs-block__link" href="#">Download</a> + {% endif %} </div> </li> @@ -35,7 +37,7 @@ {% endif %} </a> {% endif %} - {% if object.user_has_updated_details %} + {% if object.user_has_updated_details and not user.is_applicant %} <a class="docs-block__link" href="{% url 'apply:projects:simplified' pk=project.pk %}"> View </a> @@ -110,7 +112,8 @@ </li> </ul> <div class="docs-block__buttons"> - {% if object.can_send_for_approval %} + {% can_send_for_approval object user as can_approve %} + {% if can_approve %} <a data-fancybox data-src="#send-for-approval" class="button button--primary" @@ -118,7 +121,6 @@ Submit for Approval </a> {% endif %} - <!-- <button class="button button--primary" href="#">Ready for contracting</button> --> </div> </div> @@ -130,5 +132,6 @@ <div class="modal" id="upload-supporting-doc"> <h4 class="modal__header-bar">Upload a new document</h4> + <p></p> {% include 'funds/includes/delegated_form_base.html' with form=document_form value='Upload'%} </div> diff --git a/opentech/apply/projects/templates/application_projects/paymentrequest_confirm_delete.html b/opentech/apply/projects/templates/application_projects/paymentrequest_confirm_delete.html new file mode 100644 index 000000000..d3907d95c --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/paymentrequest_confirm_delete.html @@ -0,0 +1,37 @@ + +{% extends "base-apply.html" %} +{% load humanize payment_request_tools %} + +{% block title %}Payment Request: {{ object.project.title }}{% endblock %} +{% block content %} +<div class="admin-bar"> + <div class="admin-bar__inner"> + <a class="simplified__projects-link" href="{{ object.project.get_absolute_url }}"> + Project + </a> + <h2 class="heading heading--no-margin">Delete Payment Request</h2> + <h5 class="heading heading--no-margin">For: {{ object.project.title }}</h5> + </div> +</div> + +<div class="wrapper wrapper--sidebar wrapper--outer-space-medium"> + <div class="wrapper--sidebar--inner"> + + <div class="card card--solid"> + <p class="card__text"><b>Status:</b> {{ object.get_status_display }}</p> + <p class="card__text"><b>Name of Vendor:</b> {{ object.project.contact_legal_name }}</p> + <p class="card__text"><b>Invoice Number:</b> {{ object.pk }}</p> + <p class="card__text"><b>Period of Performance:</b> {{ object.date_from.date }} | {{ object.date_to.date }}</p> + <p class="card__text"><b>Total:</b> ${{ object.value|intcomma }}</p> + + </div> + <div class="card card--solid"> + <form method="post">{% csrf_token %} + <p>Are you sure you want to delete this payment request for {{ object.project.title }}?</p> + <input class="button button--primary" type="submit" value="Confirm"> + </form> + + </div> + </div> +</div> +{% endblock %} diff --git a/opentech/apply/projects/templates/application_projects/paymentrequest_detail.html b/opentech/apply/projects/templates/application_projects/paymentrequest_detail.html index 63d8987fa..eb034ad84 100644 --- a/opentech/apply/projects/templates/application_projects/paymentrequest_detail.html +++ b/opentech/apply/projects/templates/application_projects/paymentrequest_detail.html @@ -53,6 +53,12 @@ > Edit </a> + {% can_delete object user as user_can_delete_request %} + {% if user_can_delete_request %} + <a + class="button button--bottom-space button--primary button--full-width" + href="{% url 'apply:projects:payments:delete' pk=object.pk %}">Delete</a> + {% endif %} {% endblock %} </div> </aside> diff --git a/opentech/apply/projects/templates/application_projects/paymentrequest_form.html b/opentech/apply/projects/templates/application_projects/paymentrequest_form.html index b282e7ca1..c0b49835b 100644 --- a/opentech/apply/projects/templates/application_projects/paymentrequest_form.html +++ b/opentech/apply/projects/templates/application_projects/paymentrequest_form.html @@ -1,12 +1,12 @@ {% extends "base-apply.html" %} {% load static %} -{% block title %}Edit Payment Request: {{ object.project.title }}{% endblock %} +{% block title %}{% if object %}Edit{% else %}Create{% endif %} Payment Request: {% if object %}{{ object.project.title }}{% else %}{{ project.title }}{% endif %}{% endblock %} {% block content %} <div class="admin-bar"> <div class="admin-bar__inner"> - <h2 class="heading heading--no-margin">Editing Payment Request</h2> - <h5 class="heading heading--no-margin">{{ object.project.title }}</h5> + <h2 class="heading heading--no-margin">{% if object %}Editing{% else %}Create{% endif %} Payment Request</h2> + <h5 class="heading heading--no-margin">{% if object %}{{ object.project.title }}{% else %}{{ project.title }}{% endif %}</h5> </div> </div> diff --git a/opentech/apply/projects/templates/application_projects/project_detail.html b/opentech/apply/projects/templates/application_projects/project_detail.html index e671b2cde..de8cabd35 100644 --- a/opentech/apply/projects/templates/application_projects/project_detail.html +++ b/opentech/apply/projects/templates/application_projects/project_detail.html @@ -169,15 +169,8 @@ {% endif %} {% if object.can_request_funding %} - <div class="modal" id="request-payment"> - <h4 class="modal__header-bar">Request Payment</h4> - {% include 'funds/includes/delegated_form_base.html' with form=request_payment_form value='Request'%} - </div> - - <a data-fancybox - data-src="#request-payment" - class="button button--primary button--bottom-space button--full-width" - href="#"> + <a class="button button--primary button--bottom-space button--full-width" + href="{% url "apply:projects:request" pk=object.pk %}"> Add payment request </a> {% endif %} diff --git a/opentech/apply/projects/templatetags/approval_tools.py b/opentech/apply/projects/templatetags/approval_tools.py index 2c51d1a59..17de68f47 100644 --- a/opentech/apply/projects/templatetags/approval_tools.py +++ b/opentech/apply/projects/templatetags/approval_tools.py @@ -8,6 +8,11 @@ def user_has_approved(project, user): return project.approvals.filter(by=user).exists() +@register.simple_tag +def can_send_for_approval(project, user): + return user.is_staff and project.can_send_for_approval + + @register.simple_tag def user_can_approve_project(project, user): return user.is_approver and not user_has_approved(project, user) diff --git a/opentech/apply/projects/tests/test_forms.py b/opentech/apply/projects/tests/test_forms.py index 7685bbdb4..1e24303d6 100644 --- a/opentech/apply/projects/tests/test_forms.py +++ b/opentech/apply/projects/tests/test_forms.py @@ -11,8 +11,8 @@ from opentech.apply.users.tests.factories import ( from ..files import get_files from ..forms import ( ChangePaymentRequestStatusForm, + CreatePaymentRequestForm, ProjectApprovalForm, - RequestPaymentForm, SelectDocumentForm, StaffUploadContractForm, UploadContractForm, @@ -109,7 +109,7 @@ class TestProjectApprovalForm(TestCase): self.assertTrue(project.user_has_updated_details) -class TestRequestPaymentForm(TestCase): +class TestCreatePaymentRequestForm(TestCase): def test_adding_payment_request(self): data = { 'requested_value': '10', @@ -125,7 +125,7 @@ class TestRequestPaymentForm(TestCase): 'receipts': receipts, } - form = RequestPaymentForm(data=data, files=files) + form = CreatePaymentRequestForm(data=data, files=files) self.assertTrue(form.is_valid(), msg=form.errors) form.instance.by = UserFactory() @@ -142,7 +142,7 @@ class TestRequestPaymentForm(TestCase): 'receipts': receipts, } - form = RequestPaymentForm( + form = CreatePaymentRequestForm( files=files, data={ 'requested_value': '10', @@ -153,7 +153,7 @@ class TestRequestPaymentForm(TestCase): ) self.assertTrue(form.is_valid(), msg=form.errors) - form = RequestPaymentForm( + form = CreatePaymentRequestForm( files=files, data={ 'requested_value': '10', diff --git a/opentech/apply/projects/tests/test_models.py b/opentech/apply/projects/tests/test_models.py index 5225dba1f..a467f9f1d 100644 --- a/opentech/apply/projects/tests/test_models.py +++ b/opentech/apply/projects/tests/test_models.py @@ -77,11 +77,11 @@ class TestProjectModel(TestCase): class TestPaymentRequestModel(TestCase): - def test_staff_cant_delete_from_submitted(self): + def test_staff_can_delete_from_submitted(self): payment_request = PaymentRequestFactory(status=SUBMITTED) staff = StaffFactory() - self.assertFalse(payment_request.can_user_delete(staff)) + self.assertTrue(payment_request.can_user_delete(staff)) def test_staff_cant_delete_from_under_review(self): payment_request = PaymentRequestFactory(status=UNDER_REVIEW) diff --git a/opentech/apply/projects/tests/test_templatetags.py b/opentech/apply/projects/tests/test_templatetags.py index a6ce390f9..72bccc8e3 100644 --- a/opentech/apply/projects/tests/test_templatetags.py +++ b/opentech/apply/projects/tests/test_templatetags.py @@ -152,11 +152,11 @@ class TestPaymentRequestTools(TestCase): self.assertFalse(can_change_status(payment_request, user)) - def test_staff_cant_delete_from_submitted(self): + def test_staff_can_delete_from_submitted(self): payment_request = PaymentRequestFactory(status=SUBMITTED) staff = StaffFactory() - self.assertFalse(can_delete(payment_request, staff)) + self.assertTrue(can_delete(payment_request, staff)) def test_staff_cant_delete_from_under_review(self): payment_request = PaymentRequestFactory(status=UNDER_REVIEW) diff --git a/opentech/apply/projects/tests/test_views.py b/opentech/apply/projects/tests/test_views.py index a2311e728..8d84549be 100644 --- a/opentech/apply/projects/tests/test_views.py +++ b/opentech/apply/projects/tests/test_views.py @@ -820,7 +820,7 @@ class TestAnonPacketView(BasePacketFileViewTestCase): class TestRequestPaymentViewAsApplicant(BaseViewTestCase): - base_view_name = 'detail' + base_view_name = 'request' url_name = 'funds:projects:{}' user_factory = ApplicantFactory @@ -842,7 +842,6 @@ class TestRequestPaymentViewAsApplicant(BaseViewTestCase): 'requested_value': '10', 'date_from': '2018-08-15', 'date_to': '2019-08-15', - 'comment': 'test comment', 'invoice': invoice, 'receipts': receipts, }) @@ -854,7 +853,7 @@ class TestRequestPaymentViewAsApplicant(BaseViewTestCase): class TestRequestPaymentViewAsStaff(BaseViewTestCase): - base_view_name = 'detail' + base_view_name = 'request' url_name = 'funds:projects:{}' user_factory = StaffFactory diff --git a/opentech/apply/projects/urls.py b/opentech/apply/projects/urls.py index 1ee9eec77..81c86378d 100644 --- a/opentech/apply/projects/urls.py +++ b/opentech/apply/projects/urls.py @@ -2,6 +2,8 @@ from django.conf import settings from django.urls import include, path from .views import ( + ContractPrivateMediaView, + CreatePaymentRequestView, DeletePaymentRequestView, EditPaymentRequestView, PaymentRequestListView, @@ -27,7 +29,9 @@ if settings.PROJECTS_ENABLED: path('', ProjectDetailView.as_view(), name='detail'), path('edit/', ProjectEditView.as_view(), name="edit"), path('documents/<int:file_pk>/', ProjectPrivateMediaView.as_view(), name="document"), + path('contract/<int:file_pk>/', ContractPrivateMediaView.as_view(), name="contract"), path('simplified/', ProjectDetailSimplifiedView.as_view(), name='simplified'), + path('request/', CreatePaymentRequestView.as_view(), name='request'), ])), path('payment-requests/', include(([ path('', PaymentRequestListView.as_view(), name='all'), diff --git a/opentech/apply/projects/views/payment.py b/opentech/apply/projects/views/payment.py index 768573140..b9306f7bc 100644 --- a/opentech/apply/projects/views/payment.py +++ b/opentech/apply/projects/views/payment.py @@ -24,8 +24,8 @@ from opentech.apply.utils.views import ( from ..forms import ( ChangePaymentRequestStatusForm, + CreatePaymentRequestForm, EditPaymentRequestForm, - RequestPaymentForm, ) from ..filters import PaymentRequestListFilter from ..models import ( @@ -74,13 +74,10 @@ class ChangePaymentRequestStatusView(DelegatedViewMixin, PaymentRequestAccessMix class DeletePaymentRequestView(DeleteView): model = PaymentRequest - pk_url_kwarg = 'payment_request_id' def dispatch(self, request, *args, **kwargs): - self.project = get_object_or_404(Project, pk=self.kwargs['pk']) - self.object = self.get_object() - if not self.object.user_can_delete(request.user): + if not self.object.can_user_delete(request.user): raise PermissionDenied return super().dispatch(request, *args, **kwargs) @@ -119,6 +116,36 @@ class PaymentRequestView(ViewDispatcher): applicant_view = PaymentRequestApplicantView +class CreatePaymentRequestView(CreateView): + model = PaymentRequest + form_class = CreatePaymentRequestForm + + def dispatch(self, request, *args, **kwargs): + self.project = Project.objects.get(pk=kwargs['pk']) + if not request.user.is_apply_staff and not self.project.user == request.user: + return redirect(self.project) + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + return super().get_context_data(project=self.project, **kwargs) + + def form_valid(self, form): + form.instance.project = self.project + form.instance.by = self.request.user + + response = super().form_valid(form) + + messenger( + MESSAGES.REQUEST_PAYMENT, + request=self.request, + user=self.request.user, + source=self.project, + related=self.object, + ) + + return response + + class EditPaymentRequestView(PaymentRequestAccessMixin, UpdateView): form_class = EditPaymentRequestForm @@ -170,28 +197,6 @@ class PaymentRequestPrivateMedia(UserPassesTestMixin, PrivateMediaView): return False -class RequestPaymentView(DelegatedViewMixin, CreateView): - context_name = 'request_payment_form' - form_class = RequestPaymentForm - model = PaymentRequest - - def form_valid(self, form): - project = self.kwargs['object'] - - form.instance.by = self.request.user - form.instance.project = project - response = super().form_valid(form) - - messenger( - MESSAGES.REQUEST_PAYMENT, - request=self.request, - user=self.request.user, - source=project, - ) - - return response - - @method_decorator(staff_required, name='dispatch') class PaymentRequestListView(SingleTableMixin, FilterView): filterset_class = PaymentRequestListFilter diff --git a/opentech/apply/projects/views/project.py b/opentech/apply/projects/views/project.py index 7bd9ee75e..88cf33d6b 100644 --- a/opentech/apply/projects/views/project.py +++ b/opentech/apply/projects/views/project.py @@ -67,8 +67,6 @@ from ..tables import ( ProjectsListTable ) -from .payment import RequestPaymentView - # APPROVAL VIEWS @@ -107,6 +105,7 @@ class CreateApprovalView(DelegatedViewMixin, CreateView): @transaction.atomic() def form_valid(self, form): project = self.kwargs['object'] + old_stage = project.get_status_display() form.instance.project = project @@ -125,6 +124,14 @@ class CreateApprovalView(DelegatedViewMixin, CreateView): project.status = CONTRACTING project.save(update_fields=['is_locked', 'status']) + messenger( + MESSAGES.PROJECT_TRANSITION, + request=self.request, + user=self.request.user, + source=project, + related=old_stage, + ) + return response @@ -315,6 +322,8 @@ class ApproveContractView(DelegatedViewMixin, UpdateView): form.instance.project = self.project response = super().form_valid(form) + old_stage = self.project.get_status_display() + messenger( MESSAGES.APPROVE_CONTRACT, request=self.request, @@ -326,6 +335,14 @@ class ApproveContractView(DelegatedViewMixin, UpdateView): self.project.status = IN_PROGRESS self.project.save(update_fields=['status']) + messenger( + MESSAGES.PROJECT_TRANSITION, + request=self.request, + user=self.request.user, + source=self.project, + related=old_stage, + ) + return response def get_success_url(self): @@ -373,18 +390,26 @@ class UploadContractView(DelegatedViewMixin, CreateView): request=self.request, user=self.request.user, source=project, + related=form.instance, ) return response # PROJECT VIEW +class BaseProjectDetailView(DetailView): + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['statuses'] = PROJECT_STATUS_CHOICES + context['current_status_index'] = [status for status, _ in PROJECT_STATUS_CHOICES].index(self.object.status) + return context + class AdminProjectDetailView( ActivityContextMixin, DelegateableView, ContractsMixin, - DetailView, + BaseProjectDetailView, ): form_views = [ ApproveContractView, @@ -392,7 +417,6 @@ class AdminProjectDetailView( CreateApprovalView, RejectionView, RemoveDocumentView, - RequestPaymentView, SelectDocumentView, SendForApprovalView, UpdateLeadView, @@ -404,19 +428,22 @@ class AdminProjectDetailView( def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context['statuses'] = PROJECT_STATUS_CHOICES - context['current_status_index'] = [status for status, _ in PROJECT_STATUS_CHOICES].index(self.object.status) context['approvals'] = self.object.approvals.distinct('by') context['remaining_document_categories'] = list(self.object.get_missing_document_categories()) return context -class ApplicantProjectDetailView(ActivityContextMixin, DelegateableView, ContractsMixin, DetailView): +class ApplicantProjectDetailView( + ActivityContextMixin, + DelegateableView, + ContractsMixin, + BaseProjectDetailView, +): form_views = [ CommentFormView, - RequestPaymentView, SelectDocumentView, UploadContractView, + UploadDocumentView, ] model = Project @@ -459,6 +486,31 @@ class ProjectPrivateMediaView(UserPassesTestMixin, PrivateMediaView): return False +@method_decorator(login_required, name='dispatch') +class ContractPrivateMediaView(UserPassesTestMixin, PrivateMediaView): + raise_exception = True + + def dispatch(self, *args, **kwargs): + project_pk = self.kwargs['pk'] + self.project = get_object_or_404(Project, pk=project_pk) + return super().dispatch(*args, **kwargs) + + def get_media(self, *args, **kwargs): + document = Contract.objects.get(pk=kwargs['file_pk']) + if document.project != self.project: + raise Http404 + return document.file + + def test_func(self): + if self.request.user.is_apply_staff: + return True + + if self.request.user == self.project.user: + return True + + return False + + # PROJECT EDIT @method_decorator(staff_required, name='dispatch') diff --git a/opentech/static_src/src/sass/apply/components/_payment-block.scss b/opentech/static_src/src/sass/apply/components/_payment-block.scss index 5b1c248b9..3e2a5da4f 100644 --- a/opentech/static_src/src/sass/apply/components/_payment-block.scss +++ b/opentech/static_src/src/sass/apply/components/_payment-block.scss @@ -43,10 +43,6 @@ } } - &__edit-link { - margin-left: 1rem; - } - &__rejected { text-align: center; } @@ -118,9 +114,11 @@ &:last-child { padding: 0 0 1rem; - - @include media-query(tablet-landscape) { - padding: 1rem; + display: flex; + flex-wrap: wrap; + & > * { + flex: 1 1 55px; + max-width: 55px; } } -- GitLab