diff --git a/opentech/apply/activity/messaging.py b/opentech/apply/activity/messaging.py index e3a6a233bffc467ede9a0d40bb30eb534622d123..07bcc73d47add7599cfed87548581aa79b852779 100644 --- a/opentech/apply/activity/messaging.py +++ b/opentech/apply/activity/messaging.py @@ -18,7 +18,7 @@ User = get_user_model() def link_to(target, request): - if target: + if target and hasattr(target, 'get_absolute_url'): return request.scheme + '://' + request.get_host() + target.get_absolute_url() @@ -218,13 +218,15 @@ class ActivityAdapter(AdapterBase): MESSAGES.OPENED_SEALED: 'Opened the submission while still sealed', 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: '{user} has created Project', - MESSAGES.UPDATE_PROJECT_LEAD: 'Lead changed from {old_lead} to {source.lead} by {user}', - MESSAGES.SEND_FOR_APPROVAL: '{user} has requested approval', - MESSAGES.APPROVE_PROJECT: '{user} has approved', - MESSAGES.REQUEST_PROJECT_CHANGE: '{user} has requested changes to for acceptance: "{comment}"', - MESSAGES.UPLOAD_CONTRACT: '{user} has uploaded a contract for {source.title}', - MESSAGES.APPROVE_CONTRACT: '{user} has approved contract for {source.title}' + MESSAGES.CREATED_PROJECT: 'Created', + 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.APPROVE_CONTRACT: 'Approved contract', + MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: 'Updated Payment Request status to: {payment_request.status_display}', + MESSAGES.REQUEST_PAYMENT: 'Payment Request submitted', } def recipients(self, message_type, **kwargs): @@ -342,9 +344,10 @@ class ActivityAdapter(AdapterBase): except KeyError: pass - # TODO resolve how related objects work with submission/project - has_correct_fields = all(hasattr(related, attr) for attr in ['author', 'submission', 'get_absolute_url']) - if has_correct_fields and isinstance(related, models.Model): + has_correct_fields = all(hasattr(related, attr) for attr in ['get_absolute_url']) + isnt_source = source != related + is_model = isinstance(related, models.Model) + if has_correct_fields and isnt_source and is_model: related_object = related else: related_object = None @@ -394,7 +397,7 @@ class SlackAdapter(AdapterBase): MESSAGES.UPLOAD_CONTRACT: '{user} has uploaded a contract for <{link}|{source.title}>.', MESSAGES.APPROVE_CONTRACT: '{user} has approved contract for <{link}|{source.title}>.', MESSAGES.REQUEST_PAYMENT: '{user} has requested payment for <{link}|{source.title}>.', - MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: '{user} has updated status for payment request <{link}|{source.title}>.', + MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: '{user} has changed the status of <{link_related}|payment request> on <{link}|{source.title}> to {payment_request.status_display}.', MESSAGES.DELETE_PAYMENT_REQUEST: '{user} has deleted payment request from <{link}|{source.title}>.', MESSAGES.UPDATE_PAYMENT_REQUEST: '{user} has updated payment request for <{link}|{source.title}>.', } @@ -414,13 +417,16 @@ class SlackAdapter(AdapterBase): source = kwargs['source'] sources = kwargs['sources'] request = kwargs['request'] + related = kwargs['related'] link = link_to(source, request) + link_related = link_to(related, request) links = { source.id: link_to(source, request) for source in sources } return { 'link': link, + 'link_related': link_related, 'links': links, } diff --git a/opentech/apply/activity/templates/activity/include/listing_base.html b/opentech/apply/activity/templates/activity/include/listing_base.html index bdc55edf7ae7fa60bed5332b66916ae77fc3995e..5dce52086caa022b6ce8326272a9cac600468630 100644 --- a/opentech/apply/activity/templates/activity/include/listing_base.html +++ b/opentech/apply/activity/templates/activity/include/listing_base.html @@ -1,4 +1,4 @@ -{% load activity_tags bleach_tags markdown_tags submission_tags %} +{% load activity_tags bleach_tags markdown_tags submission_tags apply_tags %} <div class="feed__item feed__item--{{ activity.type }}"> <div class="feed__pre-content"> <p class="feed__label feed__label--{{ activity.type }}">{{ activity.type|capfirst }}</p> @@ -49,7 +49,7 @@ {% with url=activity.related_object.get_absolute_url %} {% if url %} <a href="{{ url }}" class="feed__related-item"> - {{ activity.related_object }} <svg><use xlink:href="#arrow-head-pixels--solid"></use></svg> + {{ activity.related_object|model_verbose_name }} <svg><use xlink:href="#arrow-head-pixels--solid"></use></svg> </a> {% endif %} {% endwith %} diff --git a/opentech/apply/activity/tests/test_messaging.py b/opentech/apply/activity/tests/test_messaging.py index 4341efeb1d8159f493bf14b74e4a7e8fc09a4087..08cb17eebf476543c8960b23ff24331df5a48dff 100644 --- a/opentech/apply/activity/tests/test_messaging.py +++ b/opentech/apply/activity/tests/test_messaging.py @@ -607,7 +607,7 @@ class TestAdaptersForProject(AdapterMixin, TestCase): ) self.assertEqual(Activity.objects.count(), 1) activity = Activity.objects.first() - self.assertEqual(None, activity.related_object) + self.assertEqual(project.submission, activity.related_object) @override_settings( SLACK_DESTINATION_URL=target_url, diff --git a/opentech/apply/activity/views.py b/opentech/apply/activity/views.py index c0edaf6b7abe782220d0d241b14e1e2eeddfb64d..9b7c4bb433a89fabc4f2680b5f6e4dd15b871c6f 100644 --- a/opentech/apply/activity/views.py +++ b/opentech/apply/activity/views.py @@ -43,12 +43,12 @@ class ActivityContextMixin: 'actions': Activity.actions.filter(**query).select_related( 'user', ).prefetch_related( - 'related_object__submission', + 'related_object', ).visible_to(self.request.user), 'comments': Activity.comments.filter(**query).select_related( 'user', ).prefetch_related( - 'related_object__submission', + 'related_object', ).visible_to(self.request.user), } return super().get_context_data(**extra, **kwargs) @@ -77,7 +77,8 @@ class CommentFormView(DelegatedViewMixin, CreateView): def get_success_url(self): return self.object.source.get_absolute_url() + '#communications' - @classmethod - def contribute_form(cls, instance, user): + def get_form_kwargs(self): # We dont want to pass the submission as the instance - return super().contribute_form(instance=None, user=user) + kwargs = super().get_form_kwargs() + kwargs.pop('instance') + return kwargs diff --git a/opentech/apply/funds/forms.py b/opentech/apply/funds/forms.py index 91911a9858bf01e30272c27b1c3e7cb1791dfcc2..74ac4a435c0207617a0214a27da3bffce3f9532c 100644 --- a/opentech/apply/funds/forms.py +++ b/opentech/apply/funds/forms.py @@ -105,10 +105,6 @@ class BatchUpdateSubmissionLeadForm(forms.Form): def save(self): new_lead = self.cleaned_data['lead'] - import logging - logger = logging.getLogger('opentech') - logger.debug(new_lead) - logger.debug(new_lead.id) submissions = self.cleaned_data['submissions'] for submission in submissions: diff --git a/opentech/apply/funds/templates/funds/includes/delegated_form_base.html b/opentech/apply/funds/templates/funds/includes/delegated_form_base.html index f33bc3d89f2f6bf9d59b68bd00febca8a745ea79..18292b3d89d3c371b58917a9063f0baf8a63eae0 100644 --- a/opentech/apply/funds/templates/funds/includes/delegated_form_base.html +++ b/opentech/apply/funds/templates/funds/includes/delegated_form_base.html @@ -9,7 +9,10 @@ {{ form.media }} - {% for field in form %} + {% for hidden in form.hidden_fields %} + {{ hidden }} + {% endfor %} + {% for field in form.visible_fields %} {% if field.field %} {% include "forms/includes/field.html" %} {% else %} diff --git a/opentech/apply/projects/files.py b/opentech/apply/projects/files.py index 57c4a5eb171e024e046441dab682598e4aa394a3..2195d989e212cfc57b3eb0d79a03a7966558a443 100644 --- a/opentech/apply/projects/files.py +++ b/opentech/apply/projects/files.py @@ -25,4 +25,4 @@ def get_files(project): file_field_names = project.submission.file_field_ids file_fields = (project.submission.data(field) for field in file_field_names) - yield from flatten(file_fields) + return list(flatten(file_fields)) diff --git a/opentech/apply/projects/forms.py b/opentech/apply/projects/forms.py index f10819b4542cf32066b600f45b99eacfe9279c18..aa792f959902d802774cde363b8b08a37f6aacc5 100644 --- a/opentech/apply/projects/forms.py +++ b/opentech/apply/projects/forms.py @@ -12,7 +12,6 @@ from opentech.apply.funds.models import ApplicationSubmission from opentech.apply.stream_forms.fields import MultiFileField from opentech.apply.users.groups import STAFF_GROUP_NAME -from .files import get_files from .models import ( CHANGES_REQUESTED, COMMITTED, @@ -23,7 +22,6 @@ from .models import ( UNDER_REVIEW, Approval, Contract, - DocumentCategory, PacketFile, PaymentReceipt, PaymentRequest, @@ -69,15 +67,13 @@ class ChangePaymentRequestStatusForm(forms.ModelForm): def __init__(self, instance, *args, **kwargs): super().__init__(instance=instance, *args, **kwargs) - self.instance = instance - status_field = self.fields['status'] - if instance.status == SUBMITTED: + if self.instance.status == SUBMITTED: wanted = [CHANGES_REQUESTED, UNDER_REVIEW, DECLINED] - elif instance.status == CHANGES_REQUESTED: + elif self.instance.status == CHANGES_REQUESTED: wanted = [DECLINED] - elif instance.status == UNDER_REVIEW: + elif self.instance.status == UNDER_REVIEW: wanted = [PAID] else: wanted = [] @@ -89,8 +85,6 @@ class CreateProjectForm(forms.Form): submission = forms.ModelChoiceField( queryset=ApplicationSubmission.objects.filter(project__isnull=True), widget=forms.HiddenInput(), - label='', - required=False, ) def __init__(self, instance=None, user=None, *args, **kwargs): @@ -108,8 +102,6 @@ class CreateApprovalForm(forms.ModelForm): by = forms.ModelChoiceField( queryset=User.objects.approvers(), widget=forms.HiddenInput(), - label='', - required=False, ) class Meta: @@ -117,9 +109,14 @@ class CreateApprovalForm(forms.ModelForm): fields = ('by',) def __init__(self, user=None, *args, **kwargs): - initial = kwargs.pop('initial', {}) - initial.update(by=user) - super().__init__(*args, initial=initial, **kwargs) + self.user = user + super().__init__(*args, **kwargs) + + def clean_by(self): + by = self.cleaned_data['by'] + if by != self.user: + raise forms.ValidationError('Cannot approve for a different user') + return by class EditPaymentRequestForm(forms.ModelForm): @@ -243,41 +240,34 @@ class RequestPaymentForm(forms.ModelForm): return request -class SelectDocumentForm(forms.Form): - category = forms.ModelChoiceField(queryset=DocumentCategory.objects.all()) - files = forms.MultipleChoiceField() +class SelectDocumentForm(forms.ModelForm): + document = forms.ChoiceField() - name = 'select_document_form' + class Meta: + model = PacketFile + fields = ['category', 'document'] - def __init__(self, existing_files, project, *args, **kwargs): + def __init__(self, existing_files, *args, **kwargs): super().__init__(*args, **kwargs) - self.project = project + self.files = existing_files + + choices = [(f.url, f.filename) for f in self.files] - choices = [] - if existing_files is not None: - choices = [(f.url, f.filename) for f in existing_files] + self.fields['document'].choices = choices - self.fields['files'].choices = choices + def clean_document(self): + file_url = self.cleaned_data['document'] + for file in self.files: + if file.url == file_url: + new_file = ContentFile(file.read()) + new_file.name = file.filename + return new_file + raise forms.ValidationError("File not found on submission") @transaction.atomic() def save(self, *args, **kwargs): - category = self.cleaned_data['category'] - urls = self.cleaned_data['files'] - - files = get_files(self.project) - files = (f for f in files if f.url in urls) - - for f in files: - new_file = ContentFile(f.read()) - new_file.name = f.filename - - PacketFile.objects.create( - category=category, - project=self.project, - title=f.filename, - document=new_file, - ) + return super().save(*args, **kwargs) class SetPendingForm(forms.ModelForm): @@ -305,15 +295,14 @@ class SetPendingForm(forms.ModelForm): class UploadContractForm(forms.ModelForm): class Meta: - fields = ['file', 'is_signed'] + fields = ['file'] model = Contract - def __init__(self, user=None, instance=None, *args, **kwargs): - super().__init__(*args, **kwargs) - if not user.is_staff: - self.fields['is_signed'].widget = forms.HiddenInput() - self.fields['is_signed'].default = True +class StaffUploadContractForm(forms.ModelForm): + class Meta: + fields = ['file', 'is_signed'] + model = Contract class UploadDocumentForm(forms.ModelForm): diff --git a/opentech/apply/projects/models.py b/opentech/apply/projects/models.py index d788876bd422738f8b18c4118b5de4c4ac95e19d..9370c0c49e8f91bfedc31b448c0cdcebb37f1352 100644 --- a/opentech/apply/projects/models.py +++ b/opentech/apply/projects/models.py @@ -3,12 +3,17 @@ import decimal import json import logging + from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.contrib.postgres.fields import JSONField from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.db import models +from django.db.models import Sum, Value as V +from django.db.models.functions import Coalesce +from django.db.models.signals import post_delete +from django.dispatch.dispatcher import receiver from django.urls import reverse from django.utils import timezone from django.utils.translation import ugettext as _ @@ -97,6 +102,12 @@ class PacketFile(models.Model): return RemoveDocumentForm(instance=self) +@receiver(post_delete, sender=PacketFile) +def delete_packetfile_file(sender, instance, **kwargs): + # Remove the file and don't save the base model + instance.document.delete(False) + + class PaymentApproval(models.Model): request = models.ForeignKey('PaymentRequest', on_delete=models.CASCADE, related_name="approvals") by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="payment_approvals") @@ -130,6 +141,26 @@ REQUEST_STATUS_CHOICES = [ ] +class PaymentRequestQueryset(models.QuerySet): + def in_progress(self): + return self.exclude(status__in=[DECLINED, PAID]) + + def rejected(self): + return self.filter(status=DECLINED) + + def not_rejected(self): + return self.exclude(status=DECLINED) + + def total_value(self): + return self.aggregate(total=Coalesce(Sum('value'), V(0)))['total'] + + def paid_value(self): + return self.filter(status=PAID).total_value() + + def unpaid_value(self): + return self.filter(status__in=[SUBMITTED, UNDER_REVIEW]).total_value() + + class PaymentRequest(models.Model): project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="payment_requests") by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="payment_requests") @@ -147,18 +178,45 @@ class PaymentRequest(models.Model): comment = models.TextField() status = models.TextField(choices=REQUEST_STATUS_CHOICES, default=SUBMITTED) + objects = PaymentRequestQueryset.as_manager() + def __str__(self): return f'Payment requested for {self.project}' - def user_can_delete(self, user): + @property + def status_display(self): + return self.get_status_display() + + def can_user_delete(self, user): + if user.is_applicant: + if self.status in (SUBMITTED, CHANGES_REQUESTED): + return True + + return False + + def can_user_edit(self, user): + if user.is_applicant: + if self.status in {SUBMITTED, CHANGES_REQUESTED}: + return True + if user.is_apply_staff: - return False # Staff can reject + if self.status in {SUBMITTED}: + return True + + return False - if self.status not in (SUBMITTED, CHANGES_REQUESTED): + def can_user_change_status(self, user): + if not user.is_apply_staff: + return False # Users can't change status + + if self.status in {PAID, DECLINED}: return False return True + def get_absolute_url(self): + return reverse('apply:projects:payments:detail', args=[self.project.pk, self.pk]) + COMMITTED = 'committed' CONTRACTING = 'contracting' @@ -254,6 +312,12 @@ class Project(BaseStreamForm, AccessFormData, models.Model): value=submission.form_data.get('value', 0), ) + def paid_value(self): + return self.payment_requests.paid_value() + + def unpaid_value(self): + return self.payment_requests.unpaid_value() + def clean(self): if self.proposed_start is None: return diff --git a/opentech/apply/projects/templates/application_projects/includes/funding_block.html b/opentech/apply/projects/templates/application_projects/includes/funding_block.html index b08db90a3e2b72305054f9b85b0772abd8d69168..c5a1459fe73eb43e5eb1fc3abe8d452cf20c740d 100644 --- a/opentech/apply/projects/templates/application_projects/includes/funding_block.html +++ b/opentech/apply/projects/templates/application_projects/includes/funding_block.html @@ -1,4 +1,4 @@ -{% load humanize %} +{% load humanize payment_request_tools %} <ul class="funding-block"> <li class="funding-block__item"> <p class="funding-block__title">Fund total</p> @@ -6,12 +6,12 @@ </li> <li class="funding-block__item"> <p class="funding-block__title">Total paid</p> - <p class="funding-block__standout">${{ payments.totals.paid_absolute|default:0|intcomma }}</p> - <p class="funding-block__meta">({{ payments.totals.paid_percentage|default:0 }}%)</p> + <p class="funding-block__standout">${{ object.paid_value|intcomma }}</p> + <p class="funding-block__meta">({% percentage object.paid_value object.value %}%)</p> </li> <li class="funding-block__item"> <p class="funding-block__title">Awaiting payment</p> - <p class="funding-block__standout">${{ payments.totals.awaiting_absolute|default:0|intcomma}}</p> - <p class="funding-block__meta">({{ payments.totals.awaiting_percentage|default:0 }}%)</p> + <p class="funding-block__standout">${{ object.unpaid_value|intcomma}}</p> + <p class="funding-block__meta">({% percentage object.unpaid_value object.value %}%)</p> </li> </ul> 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 62c864470dd8885e7e131e13a1344de52f1f2efb..2ca9c43138753ebaf5e70cf958175d78f181066a 100644 --- a/opentech/apply/projects/templates/application_projects/includes/payment_requests.html +++ b/opentech/apply/projects/templates/application_projects/includes/payment_requests.html @@ -16,35 +16,25 @@ <tr> <th class="payment-block__table-amount">Amount</th> <th class="payment-block__table-status">Status</th> - <th class="payment-block__table-docs">Documents</th> + <th class="payment-block__table-date">From</th> + <th class="payment-block__table-date">To</th> <th class="payment-block__table-update"></th> </tr> </thead> <tbody> - {% for payment_request in payments.not_rejected %} + {% for payment_request in object.payment_requests.not_rejected %} <tr> <td><span class="payment-block__mobile-label">Amount: </span>${{ payment_request.value|intcomma }}</td> <td><span class="payment-block__mobile-label">Status: </span>{{ payment_request.get_status_display }}</td> - <td><span class="payment-block__mobile-label">Documents: </span><a href="#">Download</a></td> + <td><span class="payment-block__mobile-label">From: </span>{{ payment_request.date_from.date }}</td> + <td><span class="payment-block__mobile-label">To: </span>{{ payment_request.date_to.date }}</td> <td> - {% can_change_status payment_request user as user_can_change_status %} - {% if user_can_change_status %} - <a - data-fancybox - data-src="#change-payment-status-{{ payment_request.pk }}" - class="payment-block__status-link" - href="#"> - Change status - </a> - {% endif %} - - {% user_can_edit payment_request user as user_can_edit_request %} + <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 - data-fancybox - data-src="#edit-payment-request-{{ payment_request.pk }}" class="payment-block__status-link" - href="#"> + href="{% url "apply:projects:payments:edit" pk=object.pk pr_pk=payment_request.pk %}"> Edit </a> {% endif %} @@ -58,11 +48,15 @@ {% endif %} </td> </tr> + {% empty %} + <tr> + No active Payment Requests. + </tr> {% endfor %} </tbody> </table> - {% if payments.rejected %} + {% if object.payment_requests.rejected %} <p class="payment-block__rejected"> <a class="payment-block__rejected-link js-payment-block-rejected-link" href="#">Show rejected</a> </p> @@ -72,15 +66,15 @@ <tr> <th class="payment-block__table-amount">Amount</th> <th class="payment-block__table-status">Status</th> - <th class="payment-block__table-docs">Documents</th> + <th class="payment-block__table-view"></th> </tr> </thead> <tbody> - {% for payment_request in payments.rejected %} + {% for payment_request in object.payment_requests.rejected %} <tr> <td><span class="payment-block__mobile-label">Amount: </span>${{ payment_request.value }}</td> <td><span class="payment-block__mobile-label">Status: </span>{{ payment_request.get_status_display }}</td> - <td><span class="payment-block__mobile-label">Documents: </span><a href="#">Download</a></td> + <td><a href="{{ payment_request.get_absolute_url }}">View</a></td> </tr> {% endfor %} </tbody> @@ -99,13 +93,3 @@ </div> </div> {% endfor %} - -{% for form in edit_payment_request_forms %} -<div class="modal" id="edit-payment-request-{{ form.instance.pk }}"> - <h4 class="modal__header-bar">Edit request</h4> - <div class="wrapper--outer-space-medium"> - {% url 'apply:projects:edit-payment-request' pk=object.pk payment_request_id=form.instance.pk as edit_payment_request_action %} - {% include 'funds/includes/delegated_form_base.html' with form=form value='Update' action=edit_payment_request_action %} - </div> -</div> -{% endfor %} diff --git a/opentech/apply/projects/templates/application_projects/includes/paymentrequest_admin_detail.html b/opentech/apply/projects/templates/application_projects/includes/paymentrequest_admin_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..d5fd9a177e2fca985dad7fb769d4616316fc872a --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/includes/paymentrequest_admin_detail.html @@ -0,0 +1,28 @@ +{% extends "application-projects/paymentrequest_detail.html" %} + +{% block actions %} + {{ block.super }} + <a + data-fancybox + data-src="#change-status" + class="button button--bottom-space button--primary button--full-width" + href="#" + > + Change Status + </a> + <div class="modal" id="change-status"> + <h4 class="modal__header-bar">Change status</h4> + <p>Current status: {{ object.get_status_display }}</p> + {% include 'funds/includes/delegated_form_base.html' with form=change_payment_status value='Update'%} + </div> +{% endblock %} + +{% block extra_css %} +<link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}"> +{% endblock %} + +{% block extra_js %} +{{ block.super }} +<script src="//cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.js"></script> +<script src="{% static 'js/apply/fancybox-global.js' %}"></script> +{% endblock %} 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 60646f19cf41ae59c3e73388e9b4f3406dd77efe..1b596b336e36a00d61f6c325d0e115041aeac1cf 100644 --- a/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html +++ b/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html @@ -45,21 +45,24 @@ <li class="docs-block__row"> <div class="docs-block__row-inner"> - <svg class="icon docs-block__icon"><use xlink:href="#tick"></use></svg> + <svg class="icon docs-block__icon{% if not remaining_document_categories %} is-complete{% endif %}"> + <use xlink:href="#tick"></use> + </svg> <p class="docs-block__title">Supporting documents</p> </div> {% if editable %} <div class="docs-block__row-inner"> - <a - data-fancybox - data-src="#copy-supporting-doc" - class="docs-block__link" - style="margin-right:20px" - href="#"> - Choose file - </a> - </div> - <div class="docs-block__row-inner"> + {% if select_document_form %} + <a + data-fancybox + data-src="#copy-supporting-doc" + class="docs-block__link" + href="#"> + Choose file + </a> + {% else %} + <span class="docs-block__link is-disabled" data-tooltip="No files on submission">Choose file</span> + {% endif %} <a data-fancybox data-src="#upload-supporting-doc" class="docs-block__link" href="#">Upload new</a> </div> {% endif %} diff --git a/opentech/apply/projects/templates/application_projects/paymentrequest_admin_detail.html b/opentech/apply/projects/templates/application_projects/paymentrequest_admin_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..edfdd7fc6f4a3e136e0062c7733845b5c52f9fd9 --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/paymentrequest_admin_detail.html @@ -0,0 +1,36 @@ +{% extends "application_projects/paymentrequest_detail.html" %} +{% load static payment_request_tools %} + +{% block actions %} + {{ block.super }} + {% can_change_status object user as user_can_change_status %} + <a + {% if user_can_change_status %} + data-fancybox + data-src="#change-status" + {% else %} + data-tooltip="Cannot change from 'Paid' or 'Declined' state" + {% endif %} + class="button button--bottom-space button--primary button--full-width{% if not user_can_change_status %} button--tooltip-disabled{% endif %}" + href="#" + > + Change Status + </a> + {% if user_can_change_status %} + <div class="modal" id="change-status"> + <h4 class="modal__header-bar">Change status</h4> + <p>Current status: {{ object.get_status_display }}</p> + {% include 'funds/includes/delegated_form_base.html' with form=change_payment_status value='Update'%} + </div> + {% endif %} +{% endblock %} + +{% block extra_css %} +<link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}"> +{% endblock %} + +{% block extra_js %} +{{ block.super }} +<script src="//cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.js"></script> +<script src="{% static 'js/apply/fancybox-global.js' %}"></script> +{% endblock %} diff --git a/opentech/apply/projects/templates/application_projects/paymentrequest_detail.html b/opentech/apply/projects/templates/application_projects/paymentrequest_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..8dc4a84bda83a6491ebc9fa96e63dc47b71f0f76 --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/paymentrequest_detail.html @@ -0,0 +1,62 @@ +{% 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">Payment Request</h2> + <h5 class="heading heading--no-margin">For: {{ object.project.title }}</h5> + </div> +</div> + +<div class="wrapper wrapper--sidebar"> + <div class="wrapper--sidebar--inner"> + <h5>Status: {{ object.get_status_display }}</h5> + <p>Name of Vendor: {{ object.project.contact_legal_name }}</p> + <p>Invoice Number: {{ object.pk }}</p> + <p> + Period of Performance: + </p> + <p> + From: {{ object.date_from.date }} + </p> + <p> + To: {{ object.date_to.date }} + </p> + <p> + Total: ${{ object.value|intcomma }} + </p> + + <h3>Invoice</h3> + <a href="{% url "apply:projects:payments:invoice" pk=object.project.pk pr_pk=object.pk %}">Download</a> + <h5>Reciepts</h5> + {% for reciept in object.receipts.all %} + <a href="{% url "apply:projects:payments:receipt" pk=object.project.pk pr_pk=object.pk file_pk=reciept.pk %}">Download</a> + {% endfor %} + </div> + <aside class="sidebar"> + <div class="js-actions-sidebar sidebar__inner sidebar__inner--light-blue sidebar__inner--actions"> + {% block actions %} + {% can_edit object user as user_can_edit_request %} + <a + {% if not user_can_edit_request %} + data-tooltip="Only editable when 'Submitted' or you have been requested to make changes" + {% endif %} + class="button button--bottom-space button--primary button--full-width{% if not user_can_edit_request %} button--tooltip-disabled{% endif %}" + href={% if user_can_edit_request %} + "{% url "apply:projects:payments:edit" pk=object.project.pk pr_pk=object.pk %}" + {% else %} + "#" + {% endif %} + > + Edit + </a> + {% endblock %} + </div> + </aside> +</div> +{% endblock %} diff --git a/opentech/apply/projects/templates/application_projects/paymentrequest_form.html b/opentech/apply/projects/templates/application_projects/paymentrequest_form.html new file mode 100644 index 0000000000000000000000000000000000000000..b282e7ca13f3580c06d4119b778496055d38feb6 --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/paymentrequest_form.html @@ -0,0 +1,36 @@ +{% extends "base-apply.html" %} +{% load static %} + +{% block title %}Edit Payment Request: {{ object.project.title }}{% 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> + </div> +</div> + +{% include "forms/includes/form_errors.html" with form=form %} + +<div class="wrapper wrapper--light-grey-bg wrapper--form wrapper--sidebar"> + <div class="wrapper--sidebar--inner"> + <form class="form" action="" method="post" enctype="multipart/form-data"> + {% csrf_token %} + {{ form.media }} + + {% for field in form %} + {% if field.field %} + {% include "forms/includes/field.html" %} + {% else %} + {{ field }} + {% endif %} + {% endfor %} + <input class="button button--submit button--top-space button--primary" type="submit" name="save" value="Save" /> + </form> + </div> +</div> +{% endblock %} + +{% block extra_js %} +<script src="{% static 'js/apply/list-input-files.js' %}"></script> +{% endblock %} diff --git a/opentech/apply/projects/templates/application_projects/project_detail.html b/opentech/apply/projects/templates/application_projects/project_detail.html index aad2923a0bef311e624c4313891544a39a09ebbf..ce52bf0126fc2e47d9987048da59b89ded61e29e 100644 --- a/opentech/apply/projects/templates/application_projects/project_detail.html +++ b/opentech/apply/projects/templates/application_projects/project_detail.html @@ -189,10 +189,10 @@ <div class="sidebar__inner"> <h5>Supporting Information</h5> - <p><a href="{{ object.submission.get_absolute_url }}">Proposal</a></p> + <p><a class="link link--bold" href="{{ object.submission.get_absolute_url }}">Proposal</a></p> {% if request.user.is_apply_staff %} - <p><a href="{% url 'apply:projects:simplified' pk=project.pk %}">Approval form</a></p> + <p><a class="link link--bold" href="{% url 'apply:projects:simplified' pk=project.pk %}">Approval form</a></p> {% endif %} </div> {% endif %} @@ -207,16 +207,19 @@ {% for contract in contracts %} <p> - <a href="{{ contract.file.url }}"> - Uploaded at {{ contract.created_at|date:"j F Y" }} - ({% if contract.is_signed %}Signed{% else %}Unsigned{% endif %}) + <a href="{{ contract.file.url }}" class="link link--bold"> + {{ contract.created_at|date:"j F Y" }} </a> + {% if contract.approver %} + - Approved by {{ contract.approver }} + {% else %} + ({% if contract.is_signed %}Signed{% else %}Unsigned{% endif %}) + {% endif %} + + {% if request.user.is_apply_staff and forloop.first %} + {% block approve_contracts %}{% endblock %} + {% endif %} </p> - - {% if request.user.is_apply_staff and forloop.first %} - {% block approve_contracts %}{% endblock %} - {% endif %} - {% endfor %} </div> diff --git a/opentech/apply/projects/templatetags/payment_request_tools.py b/opentech/apply/projects/templatetags/payment_request_tools.py index e0c2b34bf85b370d143d7668981a0a85234e66e8..310e8a0bf4712762b03a5c5b70b449b48e5c9b02 100644 --- a/opentech/apply/projects/templatetags/payment_request_tools.py +++ b/opentech/apply/projects/templatetags/payment_request_tools.py @@ -1,34 +1,36 @@ -from django import template +import decimal -from ..models import CHANGES_REQUESTED, DECLINED, PAID, SUBMITTED +from django import template register = template.Library() @register.simple_tag def can_change_status(payment_request, user): - if not user.is_apply_staff: - return False # Users can't change status + return payment_request.can_user_change_status(user) - if payment_request.status in (PAID, DECLINED): - return False - return True +@register.simple_tag +def can_delete(payment_request, user): + return payment_request.can_user_delete(user) @register.simple_tag -def can_delete(payment_request, user): - return payment_request.user_can_delete(user) +def can_edit(payment_request, user): + return payment_request.can_user_edit(user) @register.simple_tag -def user_can_edit(payment_request, user): - # staff or applicant can edit when in SUBMITTED - if payment_request.status == SUBMITTED: - return True +def percentage(value, total): + if not total: + return decimal.Decimal(0) + + unrounded_total = (value / total) * 100 - # applicant can edit when in CHANGES_REQUESTED - if payment_request.status == CHANGES_REQUESTED and user.is_applicant: - return True + # round using Decimal since we're dealing with currency + rounded_total = unrounded_total.quantize( + decimal.Decimal('0.0'), + rounding=decimal.ROUND_DOWN, + ) - return False + return rounded_total diff --git a/opentech/apply/projects/tests/factories.py b/opentech/apply/projects/tests/factories.py index 098e85f7053d213326abd6ab25267d17e23863d1..084f7e65d40ec001f98c0439d0a25653f66fbad6 100644 --- a/opentech/apply/projects/tests/factories.py +++ b/opentech/apply/projects/tests/factories.py @@ -89,6 +89,11 @@ class ProjectFactory(factory.DjangoModelFactory): class Meta: model = Project + class Params: + in_approval = factory.Trait( + is_locked=True, + ) + class ContractFactory(factory.DjangoModelFactory): approver = factory.SubFactory(StaffFactory) diff --git a/opentech/apply/projects/tests/test_forms.py b/opentech/apply/projects/tests/test_forms.py index cc917b989abffdfe60402a1726d77c8c3e4ab255..29a1c657e55c763c2904f0686bb4c2d88facbafb 100644 --- a/opentech/apply/projects/tests/test_forms.py +++ b/opentech/apply/projects/tests/test_forms.py @@ -1,9 +1,12 @@ from io import BytesIO +from unittest import mock from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase, override_settings -from opentech.apply.users.tests.factories import UserFactory +from opentech.apply.users.tests.factories import ( + UserFactory, +) from ..files import get_files from ..forms import ( @@ -11,10 +14,18 @@ from ..forms import ( ProjectApprovalForm, RequestPaymentForm, SelectDocumentForm, + StaffUploadContractForm, + UploadContractForm, filter_choices, filter_request_choices ) -from ..models import CHANGES_REQUESTED, DECLINED, PAID, SUBMITTED, UNDER_REVIEW +from ..models import ( + CHANGES_REQUESTED, + DECLINED, + PAID, + SUBMITTED, + UNDER_REVIEW, +) from .factories import ( DocumentCategoryFactory, PaymentRequestFactory, @@ -28,7 +39,7 @@ class TestChangePaymentRequestStatusForm(TestCase): request = PaymentRequestFactory(status=SUBMITTED) form = ChangePaymentRequestStatusForm(instance=request) - expected = set(filter_request_choices([UNDER_REVIEW, CHANGES_REQUESTED, DECLINED, PAID])) + expected = set(filter_request_choices([UNDER_REVIEW, CHANGES_REQUESTED, DECLINED])) actual = set(form.fields['status'].choices) self.assertEqual(expected, actual) @@ -165,20 +176,48 @@ class TestSelectDocumentForm(TestCase): files = list(get_files(project)) self.assertEqual(len(files), 4) - urls = [files[0].url, files[2].url] + url = files[3].url form = SelectDocumentForm( files, - project, - data={'category': category.id, 'files': urls}, + data={'category': category.id, 'document': url}, ) self.assertTrue(form.is_valid(), form.errors) + form.instance.project = project form.save() packet_files = project.packet_files.order_by('id') - self.assertEqual(len(packet_files), 2) + self.assertEqual(len(packet_files), 1) + + self.assertEqual(packet_files.first().document.read(), files[3].read()) + + +class TestStaffContractUploadForm(TestCase): + mock_file = mock.MagicMock(spec=SimpleUploadedFile) + mock_file.read.return_value = b"fake file contents" + + def test_staff_can_upload_unsigned(self): + form = StaffUploadContractForm(data={}, files={'file': self.mock_file}) + self.assertTrue(form.is_valid(), form.errors) + self.assertFalse(form.cleaned_data.get('is_signed')) - first_file, second_file = packet_files - self.assertEqual(first_file.document.read(), files[0].read()) - self.assertEqual(second_file.document.read(), files[2].read()) + def test_staff_can_upload_signed(self): + form = StaffUploadContractForm(data={'is_signed': True}, files={'file': self.mock_file}) + self.assertTrue(form.is_valid(), form.errors) + self.assertTrue(form.cleaned_data.get('is_signed')) + + +class TestContractUploadForm(TestCase): + mock_file = mock.MagicMock(spec=SimpleUploadedFile) + mock_file.read.return_value = b"fake file contents" + + def test_applicant_cant_upload_unsigned(self): + form = UploadContractForm(data={}, files={'file': self.mock_file}) + self.assertTrue(form.is_valid(), form.errors) + self.assertIsNone(form.cleaned_data.get('is_signed')) + + def test_applicant_can_upload_signed(self): + form = UploadContractForm(data={'is_signed': True}, files={'file': self.mock_file}) + self.assertTrue(form.is_valid(), form.errors) + self.assertIsNone(form.cleaned_data.get('is_signed')) diff --git a/opentech/apply/projects/tests/test_models.py b/opentech/apply/projects/tests/test_models.py index a03d24b4cd7a29a67b19627f5bd73534232efcad..2ac88c4bac710089b1f9ebba81a0ae31878c4316 100644 --- a/opentech/apply/projects/tests/test_models.py +++ b/opentech/apply/projects/tests/test_models.py @@ -9,7 +9,8 @@ from ..models import ( PAID, SUBMITTED, UNDER_REVIEW, - Project + Project, + PaymentRequest, ) from .factories import ( DocumentCategoryFactory, @@ -76,58 +77,71 @@ class TestProjectModel(TestCase): payment_request = PaymentRequestFactory(status=SUBMITTED) staff = StaffFactory() - self.assertFalse(payment_request.user_can_delete(staff)) + self.assertFalse(payment_request.can_user_delete(staff)) def test_staff_cant_delete_from_under_review(self): payment_request = PaymentRequestFactory(status=UNDER_REVIEW) staff = StaffFactory() - self.assertFalse(payment_request.user_can_delete(staff)) + self.assertFalse(payment_request.can_user_delete(staff)) def test_staff_cant_delete_from_changes_requested(self): payment_request = PaymentRequestFactory(status=CHANGES_REQUESTED) staff = StaffFactory() - self.assertFalse(payment_request.user_can_delete(staff)) + self.assertFalse(payment_request.can_user_delete(staff)) def test_staff_cant_delete_from_paid(self): payment_request = PaymentRequestFactory(status=PAID) staff = StaffFactory() - self.assertFalse(payment_request.user_can_delete(staff)) + self.assertFalse(payment_request.can_user_delete(staff)) def test_staff_cant_delete_from_declined(self): payment_request = PaymentRequestFactory(status=DECLINED) staff = StaffFactory() - self.assertFalse(payment_request.user_can_delete(staff)) + self.assertFalse(payment_request.can_user_delete(staff)) - def test_user_can_delete_from_submitted(self): + def test_can_user_delete_from_submitted(self): payment_request = PaymentRequestFactory(status=SUBMITTED) user = ApplicantFactory() - self.assertTrue(payment_request.user_can_delete(user)) + self.assertTrue(payment_request.can_user_delete(user)) def test_user_cant_delete_from_under_review(self): payment_request = PaymentRequestFactory(status=UNDER_REVIEW) user = ApplicantFactory() - self.assertFalse(payment_request.user_can_delete(user)) + self.assertFalse(payment_request.can_user_delete(user)) def test_user_can_delete_from_changes_requested(self): payment_request = PaymentRequestFactory(status=CHANGES_REQUESTED) user = ApplicantFactory() - self.assertTrue(payment_request.user_can_delete(user)) + self.assertTrue(payment_request.can_user_delete(user)) def test_user_cant_delete_from_paid(self): payment_request = PaymentRequestFactory(status=PAID) user = ApplicantFactory() - self.assertFalse(payment_request.user_can_delete(user)) + self.assertFalse(payment_request.can_user_delete(user)) def test_user_cant_delete_from_declined(self): payment_request = PaymentRequestFactory(status=DECLINED) user = ApplicantFactory() - self.assertFalse(payment_request.user_can_delete(user)) + self.assertFalse(payment_request.can_user_delete(user)) + + +class TestPaymentRequestsQueryset(TestCase): + def test_get_totals(self): + PaymentRequestFactory(value=20) + PaymentRequestFactory(value=10, status=PAID) + + self.assertEqual(PaymentRequest.objects.paid_value(), 10) + self.assertEqual(PaymentRequest.objects.unpaid_value(), 20) + + def test_get_totals_no_value(self): + self.assertEqual(PaymentRequest.objects.paid_value(), 0) + self.assertEqual(PaymentRequest.objects.unpaid_value(), 0) diff --git a/opentech/apply/projects/tests/test_templatetags.py b/opentech/apply/projects/tests/test_templatetags.py index 9cfde17da0ecef0ceebe7e30883b754a8dacdaee..a6ce390f9b258aab59776bc06cfe0a1662bcd820 100644 --- a/opentech/apply/projects/tests/test_templatetags.py +++ b/opentech/apply/projects/tests/test_templatetags.py @@ -18,7 +18,7 @@ from ..templatetags.contract_tools import user_can_upload_contract from ..templatetags.payment_request_tools import ( can_change_status, can_delete, - user_can_edit + can_edit ) from .factories import ContractFactory, PaymentRequestFactory, ProjectFactory @@ -217,41 +217,41 @@ class TestPaymentRequestTools(TestCase): applicant = ApplicantFactory() staff = StaffFactory() - self.assertTrue(user_can_edit(payment_request, applicant)) - self.assertTrue(user_can_edit(payment_request, staff)) + self.assertTrue(can_edit(payment_request, applicant)) + self.assertTrue(can_edit(payment_request, staff)) def test_applicant_can_edit_in_changes_requested(self): payment_request = PaymentRequestFactory(status=CHANGES_REQUESTED) applicant = ApplicantFactory() - self.assertTrue(user_can_edit(payment_request, applicant)) + self.assertTrue(can_edit(payment_request, applicant)) def test_staff_cant_edit_in_changes_requested(self): payment_request = PaymentRequestFactory(status=CHANGES_REQUESTED) staff = StaffFactory() - self.assertFalse(user_can_edit(payment_request, staff)) + self.assertFalse(can_edit(payment_request, staff)) def test_applicant_and_staff_cant_edit_in_under_review(self): payment_request = PaymentRequestFactory(status=UNDER_REVIEW) applicant = ApplicantFactory() staff = StaffFactory() - self.assertFalse(user_can_edit(payment_request, applicant)) - self.assertFalse(user_can_edit(payment_request, staff)) + self.assertFalse(can_edit(payment_request, applicant)) + self.assertFalse(can_edit(payment_request, staff)) def test_applicant_and_staff_cant_edit_in_paid(self): payment_request = PaymentRequestFactory(status=PAID) applicant = ApplicantFactory() staff = StaffFactory() - self.assertFalse(user_can_edit(payment_request, applicant)) - self.assertFalse(user_can_edit(payment_request, staff)) + self.assertFalse(can_edit(payment_request, applicant)) + self.assertFalse(can_edit(payment_request, staff)) def test_applicant_and_staff_cant_edit_in_decline(self): payment_request = PaymentRequestFactory(status=DECLINED) applicant = ApplicantFactory() staff = StaffFactory() - self.assertFalse(user_can_edit(payment_request, applicant)) - self.assertFalse(user_can_edit(payment_request, staff)) + self.assertFalse(can_edit(payment_request, applicant)) + self.assertFalse(can_edit(payment_request, staff)) diff --git a/opentech/apply/projects/tests/test_views.py b/opentech/apply/projects/tests/test_views.py index 5384d1100e93f1e06e9ab15bad5407f484f9060d..374d9352b130764ddccfbe9f4cd5cbbd37700a56 100644 --- a/opentech/apply/projects/tests/test_views.py +++ b/opentech/apply/projects/tests/test_views.py @@ -18,8 +18,15 @@ from opentech.apply.users.tests.factories import ( from opentech.apply.utils.testing.tests import BaseViewTestCase from ..forms import SetPendingForm -from ..models import CONTRACTING, IN_PROGRESS, PAID -from ..views import ContractsMixin, PaymentsMixin, ProjectDetailSimplifiedView +from ..files import get_files +from ..models import ( + CHANGES_REQUESTED, + COMMITTED, + CONTRACTING, + IN_PROGRESS, + SUBMITTED, +) +from ..views import ContractsMixin, ProjectDetailSimplifiedView from .factories import ( ContractFactory, DocumentCategoryFactory, @@ -33,27 +40,38 @@ from .factories import ( class TestCreateApprovalView(BaseViewTestCase): base_view_name = 'detail' url_name = 'funds:projects:{}' - user_factory = StaffFactory + user_factory = ApproverFactory def get_kwargs(self, instance): return {'pk': instance.id} def test_creating_an_approval_happy_path(self): - project = ProjectFactory() + project = ProjectFactory(in_approval=True) self.assertEqual(project.approvals.count(), 0) response = self.post_page(project, {'form-submitted-add_approval_form': '', 'by': self.user.id}) self.assertEqual(response.status_code, 200) project.refresh_from_db() - approval = project.approvals.first() - self.assertEqual(project.approvals.count(), 1) self.assertFalse(project.is_locked) self.assertEqual(project.status, 'contracting') + approval = project.approvals.first() self.assertEqual(approval.project_id, project.pk) + def test_creating_an_approval_other_approver(self): + project = ProjectFactory(in_approval=True) + self.assertEqual(project.approvals.count(), 0) + + other = self.user_factory() + response = self.post_page(project, {'form-submitted-add_approval_form': '', 'by': other.id}) + self.assertEqual(response.status_code, 200) + + project.refresh_from_db() + self.assertEqual(project.approvals.count(), 0) + self.assertTrue(project.is_locked) + class BaseProjectDetailTestCase(BaseViewTestCase): url_name = 'funds:projects:{}' @@ -78,7 +96,7 @@ class TestStaffProjectDetailView(BaseProjectDetailTestCase): class TestUserProjectDetailView(BaseProjectDetailTestCase): - user_factory = UserFactory + user_factory = ApplicantFactory def test_doesnt_have_access(self): project = ProjectFactory() @@ -109,6 +127,62 @@ class TestReviewerUserProjectDetailView(BaseProjectDetailTestCase): self.assertEqual(response.status_code, 403) +class TestStaffProjectRejectView(BaseProjectDetailTestCase): + user_factory = StaffFactory + + def test_cant_reject(self): + project = ProjectFactory(in_approval=True) + response = self.post_page(project, { + 'form-submitted-rejection_form': '', + 'comment': 'needs to change', + }) + self.assertEqual(response.status_code, 403) + project = self.refresh(project) + self.assertEqual(project.status, COMMITTED) + self.assertTrue(project.is_locked) + + +class TestApproverProjectRejectView(BaseProjectDetailTestCase): + user_factory = ApproverFactory + + def test_can_reject(self): + project = ProjectFactory(in_approval=True) + response = self.post_page(project, { + 'form-submitted-rejection_form': '', + 'comment': 'needs to change', + }) + self.assertEqual(response.status_code, 200) + project = self.refresh(project) + self.assertEqual(project.status, COMMITTED) + self.assertFalse(project.is_locked) + + def test_cant_reject_no_comment(self): + project = ProjectFactory(in_approval=True) + response = self.post_page(project, { + 'form-submitted-rejection_form': '', + 'comment': '', + }) + self.assertEqual(response.status_code, 200) + project = self.refresh(project) + self.assertEqual(project.status, COMMITTED) + self.assertTrue(project.is_locked) + + +class TestUserProjectRejectView(BaseProjectDetailTestCase): + user_factory = ApplicantFactory + + def test_cant_reject(self): + project = ProjectFactory(in_approval=True, user=self.user) + response = self.post_page(project, { + 'form-submitted-rejection_form': '', + 'comment': 'needs to change', + }) + self.assertEqual(response.status_code, 200) + project = self.refresh(project) + self.assertEqual(project.status, COMMITTED) + self.assertTrue(project.is_locked) + + class TestRemoveDocumentView(BaseViewTestCase): base_view_name = 'detail' url_name = 'funds:projects:{}' @@ -260,6 +334,58 @@ class TestStaffUploadContractView(BaseViewTestCase): self.assertTrue(project.contracts.first().is_signed) +class TestStaffSelectDocumentView(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:{}' + user_factory = StaffFactory + + def get_kwargs(self, instance): + return {'pk': instance.id} + + def test_can_choose(self): + category = DocumentCategoryFactory() + project = ProjectFactory() + + files = get_files(project) + + response = self.post_page(project, { + 'form-submitted-select_document_form': '', + 'category': category.id, + 'document': files[0].url, + }) + self.assertEqual(response.status_code, 200) + + project.refresh_from_db() + + self.assertEqual(project.packet_files.count(), 1) + + +class TestApplicantSelectDocumentView(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:{}' + user_factory = ApplicantFactory + + def get_kwargs(self, instance): + return {'pk': instance.id} + + def test_can_choose(self): + category = DocumentCategoryFactory() + project = ProjectFactory(user=self.user) + + files = get_files(project) + + response = self.post_page(project, { + 'form-submitted-select_document_form': '', + 'category': category.id, + 'document': files[0].url, + }) + self.assertEqual(response.status_code, 200) + + project.refresh_from_db() + + self.assertEqual(project.packet_files.count(), 1) + + class TestUploadDocumentView(BaseViewTestCase): base_view_name = 'detail' url_name = 'funds:projects:{}' @@ -700,31 +826,6 @@ class TestRequestPaymentViewAsStaff(BaseViewTestCase): self.assertEqual(project.payment_requests.first().by, self.user) -class TestPaymentsMixin(TestCase): - def test_get_totals(self): - project = ProjectFactory(value=Decimal(100)) - user = UserFactory() - - PaymentRequestFactory(project=project, by=user, value=20) - PaymentRequestFactory(project=project, by=user, value=10, status=PAID) - - values = PaymentsMixin().get_totals(project) - - self.assertEqual(values['awaiting_absolute'], 20) - self.assertEqual(values['awaiting_percentage'], 20) - self.assertEqual(values['paid_absolute'], 10) - self.assertEqual(values['paid_percentage'], 10) - - def test_get_totals_no_value(self): - project = ProjectFactory(value=Decimal(0)) - values = PaymentsMixin().get_totals(project) - - self.assertEqual(values['awaiting_absolute'], 0) - self.assertEqual(values['awaiting_percentage'], 0) - self.assertEqual(values['paid_absolute'], 0) - self.assertEqual(values['paid_percentage'], 0) - - class TestProjectDetailSimplifiedView(TestCase): def test_staff_only(self): factory = RequestFactory() @@ -741,13 +842,58 @@ class TestProjectDetailSimplifiedView(TestCase): ProjectDetailSimplifiedView.as_view()(request, pk=project.pk) +class TestStaffDetailPaymentRequestStatus(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:payments:{}' + user_factory = StaffFactory + + def get_kwargs(self, instance): + return { + 'pk': instance.project.pk, + 'pr_pk': instance.pk, + } + + def test_can(self): + payment_request = PaymentRequestFactory() + response = self.get_page(payment_request) + self.assertEqual(response.status_code, 200) + + def test_wrong_project_cant(self): + other_project = ProjectFactory() + payment_request = PaymentRequestFactory() + response = self.get_page(payment_request, url_kwargs={'pk': other_project.pk}) + self.assertEqual(response.status_code, 404) + + +class TestApplicantDetailPaymentRequestStatus(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:payments:{}' + user_factory = ApplicantFactory + + def get_kwargs(self, instance): + return { + 'pk': instance.project.pk, + 'pr_pk': instance.pk, + } + + def test_can(self): + payment_request = PaymentRequestFactory(project__user=self.user) + response = self.get_page(payment_request) + self.assertEqual(response.status_code, 200) + + def test_other_cant(self): + payment_request = PaymentRequestFactory() + response = self.get_page(payment_request) + self.assertEqual(response.status_code, 403) + + class TestApplicantEditPaymentRequestView(BaseViewTestCase): - base_view_name = 'edit-payment-request' - url_name = 'funds:projects:{}' + base_view_name = 'edit' + url_name = 'funds:projects:payments:{}' user_factory = ApplicantFactory def get_kwargs(self, instance): - return {'pk': instance.project.pk, 'payment_request_id': instance.pk} + return {'pk': instance.project.pk, 'pr_pk': instance.pk} def test_editing_payment_request_fires_messaging(self): project = ProjectFactory(user=self.user) @@ -760,7 +906,6 @@ class TestApplicantEditPaymentRequestView(BaseViewTestCase): invoice.name = 'invoice.pdf' response = self.post_page(payment_request, { - 'form-submitted-edit_request_payment_form': '', 'value': value + 1, 'date_from': '2018-08-15', 'date_to': '2019-08-15', @@ -780,12 +925,12 @@ class TestApplicantEditPaymentRequestView(BaseViewTestCase): class TestStaffEditPaymentRequestView(BaseViewTestCase): - base_view_name = 'edit-payment-request' - url_name = 'funds:projects:{}' + base_view_name = 'edit' + url_name = 'funds:projects:payments:{}' user_factory = StaffFactory def get_kwargs(self, instance): - return {'pk': instance.project.pk, 'payment_request_id': instance.pk} + return {'pk': instance.project.pk, 'pr_pk': instance.pk} def test_editing_payment_request_fires_messaging(self): project = ProjectFactory() @@ -798,7 +943,6 @@ class TestStaffEditPaymentRequestView(BaseViewTestCase): invoice.name = 'invoice.pdf' response = self.post_page(payment_request, { - 'form-submitted-request_payment_form': '', 'value': value + 1, 'date_from': '2018-08-15', 'date_to': '2019-08-15', @@ -815,3 +959,155 @@ class TestStaffEditPaymentRequestView(BaseViewTestCase): self.assertEqual(project.payment_requests.first().pk, payment_request.pk) self.assertEqual(value + Decimal("1"), payment_request.value) + + +class TestStaffChangePaymentRequestStatus(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:payments:{}' + user_factory = StaffFactory + + def get_kwargs(self, instance): + return { + 'pk': instance.project.pk, + 'pr_pk': instance.pk, + } + + def test_can(self): + payment_request = PaymentRequestFactory() + response = self.post_page(payment_request, { + 'form-submitted-change_payment_status': '', + 'status': CHANGES_REQUESTED, + }) + self.assertEqual(response.status_code, 200) + payment_request.refresh_from_db() + self.assertEqual(payment_request.status, CHANGES_REQUESTED) + + +class TestApplicantChangePaymentRequestStatus(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:payments:{}' + user_factory = ApplicantFactory + + def get_kwargs(self, instance): + return { + 'pk': instance.project.pk, + 'pr_pk': instance.pk, + } + + def test_can(self): + payment_request = PaymentRequestFactory(project__user=self.user) + response = self.post_page(payment_request, { + 'form-submitted-change_payment_status': '', + 'status': CHANGES_REQUESTED, + }) + self.assertEqual(response.status_code, 200) + payment_request.refresh_from_db() + self.assertEqual(payment_request.status, SUBMITTED) + + def test_other_cant(self): + payment_request = PaymentRequestFactory() + response = self.post_page(payment_request, { + 'form-submitted-change_payment_status': '', + 'status': CHANGES_REQUESTED, + }) + self.assertEqual(response.status_code, 403) + payment_request.refresh_from_db() + self.assertEqual(payment_request.status, SUBMITTED) + + +class TestStaffPaymentRequestInvoicePrivateMedia(BaseViewTestCase): + base_view_name = 'invoice' + url_name = 'funds:projects:payments:{}' + user_factory = StaffFactory + + def get_kwargs(self, instance): + return { + 'pk': instance.project.pk, + 'pr_pk': instance.pk, + } + + def test_can_access(self): + payment_request = PaymentRequestFactory() + response = self.get_page(payment_request) + self.assertContains(response, payment_request.invoice.read()) + + def test_cant_access_if_project_wrong(self): + other_project = ProjectFactory() + payment_request = PaymentRequestFactory() + response = self.get_page(payment_request, url_kwargs={'pk': other_project.pk}) + self.assertEqual(response.status_code, 404) + + +class TestApplicantPaymentRequestInvoicePrivateMedia(BaseViewTestCase): + base_view_name = 'invoice' + url_name = 'funds:projects:payments:{}' + user_factory = ApplicantFactory + + def get_kwargs(self, instance): + return { + 'pk': instance.project.pk, + 'pr_pk': instance.pk, + } + + def test_can_access_own(self): + payment_request = PaymentRequestFactory(project__user=self.user) + response = self.get_page(payment_request) + self.assertContains(response, payment_request.invoice.read()) + + def test_cant_access_other(self): + payment_request = PaymentRequestFactory() + response = self.get_page(payment_request) + self.assertEqual(response.status_code, 403) + + +class TestStaffPaymentRequestReceiptPrivateMedia(BaseViewTestCase): + base_view_name = 'receipt' + url_name = 'funds:projects:payments:{}' + user_factory = StaffFactory + + def get_kwargs(self, instance): + return { + 'pk': instance.payment_request.project.pk, + 'pr_pk': instance.payment_request.pk, + 'file_pk': instance.pk, + } + + def test_can_access(self): + payment_receipt = PaymentReceiptFactory() + response = self.get_page(payment_receipt) + self.assertContains(response, payment_receipt.file.read()) + + def test_cant_access_if_project_wrong(self): + other_project = ProjectFactory() + payment_receipt = PaymentReceiptFactory() + response = self.get_page(payment_receipt, url_kwargs={'pk': other_project.pk}) + self.assertEqual(response.status_code, 404) + + def test_cant_access_if_request_is_wrong(self): + other_request = PaymentRequestFactory() + payment_receipt = PaymentReceiptFactory() + response = self.get_page(payment_receipt, url_kwargs={'pr_pk': other_request.pk}) + self.assertEqual(response.status_code, 404) + + +class TestApplicantPaymentRequestReceiptPrivateMedia(BaseViewTestCase): + base_view_name = 'receipt' + url_name = 'funds:projects:payments:{}' + user_factory = ApplicantFactory + + def get_kwargs(self, instance): + return { + 'pk': instance.payment_request.project.pk, + 'pr_pk': instance.payment_request.pk, + 'file_pk': instance.pk, + } + + def test_can_access_own(self): + payment_receipt = PaymentReceiptFactory(payment_request__project__user=self.user) + response = self.get_page(payment_receipt) + self.assertContains(response, payment_receipt.file.read()) + + def test_cant_access_other(self): + payment_receipt = PaymentReceiptFactory() + response = self.get_page(payment_receipt) + self.assertEqual(response.status_code, 403) diff --git a/opentech/apply/projects/urls.py b/opentech/apply/projects/urls.py index 8ca299d95bc1d388ca767702170389afc13c4e8b..d4f135205cac93b1357952e3a56c0e4b31241d07 100644 --- a/opentech/apply/projects/urls.py +++ b/opentech/apply/projects/urls.py @@ -6,6 +6,8 @@ from .views import ( ChangePaymentRequestStatusView, DeletePaymentRequestView, EditPaymentRequestView, + PaymentRequestView, + PaymentRequestPrivateMedia, ProjectDetailSimplifiedView, ProjectDetailView, ProjectEditView, @@ -38,16 +40,13 @@ if settings.PROJECTS_ENABLED: SelectDocumentView.as_view(), name="copy-documents", ), - path( - 'delete-payment-request/<int:payment_request_id>/', - DeletePaymentRequestView.as_view(), - name='delete-payment-request', - ), - path( - 'edit-payment-request/<int:payment_request_id>/', - EditPaymentRequestView.as_view(), - name='edit-payment-request', - ), path('simplified/', ProjectDetailSimplifiedView.as_view(), name='simplified'), + path('payment-requests/<int:pr_pk>/', include(([ + path('', PaymentRequestView.as_view(), name='detail'), + path('edit/', EditPaymentRequestView.as_view(), name='edit'), + path('delete/', DeletePaymentRequestView.as_view(), name='delete'), + path('documents/invoice/', PaymentRequestPrivateMedia.as_view(), name="invoice"), + path('documents/receipt/<int:file_pk>/', PaymentRequestPrivateMedia.as_view(), name="receipt"), + ], 'payments'))), ])), ] diff --git a/opentech/apply/projects/views/__init__.py b/opentech/apply/projects/views/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fa45c29c176b0b33d5b01a6e2e926dc9f487fbc7 --- /dev/null +++ b/opentech/apply/projects/views/__init__.py @@ -0,0 +1,2 @@ +from .payment import * # NOQA +from .project import * # NOQA diff --git a/opentech/apply/projects/views/payment.py b/opentech/apply/projects/views/payment.py new file mode 100644 index 0000000000000000000000000000000000000000..952ee30ee3371714a8e1b77eaad7686fd77ee570 --- /dev/null +++ b/opentech/apply/projects/views/payment.py @@ -0,0 +1,202 @@ +from django.contrib.auth.decorators import login_required +from django.contrib.auth.mixins import UserPassesTestMixin +from django.core.exceptions import PermissionDenied +from django.db import transaction +from django.http import Http404 +from django.shortcuts import get_object_or_404, redirect +from django.utils.decorators import method_decorator +from django.views.generic import ( + CreateView, + DeleteView, + DetailView, + UpdateView +) + +from opentech.apply.activity.messaging import MESSAGES, messenger +from opentech.apply.users.decorators import staff_required +from opentech.apply.utils.storage import PrivateMediaView +from opentech.apply.utils.views import ( + DelegateableView, + DelegatedViewMixin, + ViewDispatcher, +) + +from ..forms import ( + ChangePaymentRequestStatusForm, + EditPaymentRequestForm, + RequestPaymentForm, +) +from ..models import ( + PaymentRequest, + Project +) + + +@method_decorator(login_required, name='dispatch') +class PaymentRequestAccessMixin: + model = PaymentRequest + pk_url_kwarg = 'pr_pk' + + def dispatch(self, request, *args, **kwargs): + self.project = get_object_or_404(Project, pk=self.kwargs['pk']) + if self.get_object().project != self.project: + raise Http404 + + is_admin = request.user.is_apply_staff + is_owner = request.user == self.project.user + if not (is_owner or is_admin): + raise PermissionDenied + + return super().dispatch(request, *args, **kwargs) + + +@method_decorator(staff_required, name='dispatch') +class ChangePaymentRequestStatusView(DelegatedViewMixin, PaymentRequestAccessMixin, UpdateView): + form_class = ChangePaymentRequestStatusForm + context_name = 'change_payment_status' + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs.pop('user') + return kwargs + + def form_valid(self, form): + response = super().form_valid(form) + + messenger( + MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS, + request=self.request, + user=self.request.user, + source=self.project, + related=self.object, + ) + + return response + + +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): + raise PermissionDenied + + return super().dispatch(request, *args, **kwargs) + + @transaction.atomic() + def delete(self, request, *args, **kwargs): + response = super().delete(request, *args, **kwargs) + + messenger( + MESSAGES.DELETE_PAYMENT_REQUEST, + request=self.request, + user=self.request.user, + source=self.project, + related=self.object, + ) + + return response + + def get_success_url(self): + return self.project.get_absolute_url() + + +class PaymentRequestAdminView(PaymentRequestAccessMixin, DelegateableView, DetailView): + form_views = [ + ChangePaymentRequestStatusView + ] + template_name_suffix = '_admin_detail' + + +class PaymentRequestApplicantView(PaymentRequestAccessMixin, DelegateableView, DetailView): + form_views = [] + + +class PaymentRequestView(ViewDispatcher): + admin_view = PaymentRequestAdminView + applicant_view = PaymentRequestApplicantView + + +class EditPaymentRequestView(PaymentRequestAccessMixin, UpdateView): + form_class = EditPaymentRequestForm + + def dispatch(self, request, *args, **kwargs): + payment_request = self.get_object() + if not payment_request.can_user_edit(request.user): + return redirect(payment_request) + return super().dispatch(request, *args, **kwargs) + + def form_valid(self, form): + response = super().form_valid(form) + + messenger( + MESSAGES.UPDATE_PAYMENT_REQUEST, + request=self.request, + user=self.request.user, + source=self.project, + related=self.object, + ) + + return response + + def get_success_url(self): + return self.project.get_absolute_url() + + +@method_decorator(login_required, name='dispatch') +class PaymentRequestPrivateMedia(UserPassesTestMixin, PrivateMediaView): + raise_exception = True + + def dispatch(self, *args, **kwargs): + project_pk = self.kwargs['pk'] + payment_pk = self.kwargs['pr_pk'] + self.project = get_object_or_404(Project, pk=project_pk) + self.payment_request = get_object_or_404(PaymentRequest, pk=payment_pk) + + if self.payment_request.project != self.project: + raise Http404 + + return super().dispatch(*args, **kwargs) + + def get_media(self, *args, **kwargs): + file_pk = kwargs.get('file_pk') + if not file_pk: + return self.payment_request.invoice + + receipt = get_object_or_404(self.payment_request.receipts, pk=file_pk) + return receipt.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 + + +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 diff --git a/opentech/apply/projects/views.py b/opentech/apply/projects/views/project.py similarity index 62% rename from opentech/apply/projects/views.py rename to opentech/apply/projects/views/project.py index 6b62ab41607fca2fbbac4688a058892b2238307f..871629184ac4d56ec17e0d017ebb134894935f37 100644 --- a/opentech/apply/projects/views.py +++ b/opentech/apply/projects/views/project.py @@ -1,4 +1,3 @@ -import decimal from copy import copy from django.contrib import messages @@ -6,7 +5,6 @@ from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import UserPassesTestMixin from django.core.exceptions import PermissionDenied from django.db import transaction -from django.db.models import Q from django.http import Http404 from django.shortcuts import get_object_or_404, redirect from django.utils.decorators import method_decorator @@ -14,7 +12,6 @@ from django.utils.functional import cached_property from django.utils.translation import ugettext_lazy as _ from django.views.generic import ( CreateView, - DeleteView, DetailView, FormView, UpdateView @@ -22,225 +19,62 @@ from django.views.generic import ( from opentech.apply.activity.messaging import MESSAGES, messenger from opentech.apply.activity.views import ActivityContextMixin, CommentFormView -from opentech.apply.users.decorators import staff_required +from opentech.apply.users.decorators import approver_required, staff_required from opentech.apply.utils.storage import PrivateMediaView from opentech.apply.utils.views import ( DelegateableView, DelegatedViewMixin, - ViewDispatcher + ViewDispatcher, ) -from .files import get_files -from .forms import ( +from ..files import get_files +from ..forms import ( ApproveContractForm, - ChangePaymentRequestStatusForm, CreateApprovalForm, - EditPaymentRequestForm, ProjectApprovalForm, ProjectEditForm, RejectionForm, RemoveDocumentForm, - RequestPaymentForm, SelectDocumentForm, SetPendingForm, + StaffUploadContractForm, UpdateProjectLeadForm, UploadContractForm, UploadDocumentForm ) -from .models import ( - CHANGES_REQUESTED, +from ..models import ( CONTRACTING, - DECLINED, IN_PROGRESS, - PAID, PROJECT_STATUS_CHOICES, - REQUEST_STATUS_CHOICES, - SUBMITTED, - UNDER_REVIEW, Approval, Contract, PacketFile, - PaymentRequest, Project ) +from .payment import RequestPaymentView -class ContractsMixin: - def get_context_data(self, **kwargs): - project = self.get_object() - contracts = (project.contracts.select_related('approver') - .order_by('-created_at')) - - latest_contract = self.get_contract_to_approve(contracts) - - contracts = contracts.filter(is_signed=True, approver__isnull=False) - - if latest_contract: - contracts = [latest_contract, *contracts] - - context = super().get_context_data(**kwargs) - context['latest_contract'] = latest_contract - context['contracts'] = contracts - return context - - def get_contract_to_approve(self, contracts): - """If there's a contract to approve, get that""" - latest = contracts.first() - - if not latest: - return - - if latest.approver: - return - - return latest - - -class PaymentsMixin: - def get_context_data(self, **kwargs): - project = self.get_object() - - payments = { - 'availabe_statuses': REQUEST_STATUS_CHOICES, - 'not_rejected': project.payment_requests.exclude(status=DECLINED), - 'rejected': project.payment_requests.filter(status=DECLINED), - 'totals': self.get_totals(project), - } - - context = super().get_context_data(**kwargs) - context['payments'] = payments - context['edit_payment_request_forms'] = list(self.get_edit_payment_request_forms()) - return context - - def get_edit_payment_request_forms(self): - """ - Get an iterable of EditPaymentRequestForms - - We want to instantiate each EditPaymentRequestForm with a given - PaymentRequest. Each subclass of this mixin defines - .get_payment_requests_queryset() so we can change the available forms - based on the type of user viewing (applicant or staff). - """ - payment_requests = self.get_payment_requests_queryset().select_related('project') - for payment_request in payment_requests: - yield EditPaymentRequestForm(instance=payment_request) - - def get_totals(self, project): - def percentage(total, value): - if not total: - return decimal.Decimal(0) - - unrounded_total = (value / total) * 100 - - # round using Decimal since we're dealing with currency - rounded_total = unrounded_total.quantize( - decimal.Decimal('0.0'), - rounding=decimal.ROUND_DOWN, - ) - - return rounded_total - - unpaid_requests = project.payment_requests.filter(Q(status=SUBMITTED) | Q(status=UNDER_REVIEW)) - awaiting_absolute = sum(unpaid_requests.values_list('value', flat=True)) - awaiting_percentage = percentage(project.value, awaiting_absolute) - - paid_requests = project.payment_requests.filter(status=PAID) - paid_absolute = sum(paid_requests.values_list('value', flat=True)) - paid_percentage = percentage(project.value, paid_absolute) - - return { - 'awaiting_absolute': awaiting_absolute, - 'awaiting_percentage': awaiting_percentage, - 'paid_absolute': paid_absolute, - 'paid_percentage': paid_percentage, - } - - -class SubmissionFilesMixin: - """ - Mixin to provide an instantiated SelectDocumentForm - """ - def get_context_data(self, **kwargs): - project = self.get_object() - - files = get_files(project) - - context = super().get_context_data(**kwargs) - context['select_document_form'] = SelectDocumentForm(files, project) - return context - +# APPROVAL VIEWS @method_decorator(staff_required, name='dispatch') -class ApproveContractView(UpdateView): - form_class = ApproveContractForm - model = Contract - pk_url_kwarg = 'contract_pk' - - def dispatch(self, request, *args, **kwargs): - self.project = get_object_or_404(Project, pk=self.kwargs['pk']) - return super().dispatch(request, *args, **kwargs) - - def form_invalid(self, form): - for error in form.errors: - messages.error(self.request, error) - - return redirect(self.project) - - def form_valid(self, form): - with transaction.atomic(): - form.instance.approver = self.request.user - form.instance.project = self.project - response = super().form_valid(form) - - messenger( - MESSAGES.APPROVE_CONTRACT, - request=self.request, - user=self.request.user, - source=self.project, - related=self.object, - ) - - self.project.status = IN_PROGRESS - self.project.save(update_fields=['status']) - - return response - - def get_success_url(self): - return self.project.get_absolute_url() - - -@method_decorator(staff_required, name='dispatch') -class ChangePaymentRequestStatusView(UpdateView): - form_class = ChangePaymentRequestStatusForm - 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']) - return super().dispatch(request, *args, **kwargs) - - def form_invalid(self, form): - for error in form.errors: - messages.error(self.request, error) - - return redirect(self.project) +class SendForApprovalView(DelegatedViewMixin, UpdateView): + context_name = 'request_approval_form' + form_class = SetPendingForm + model = Project def form_valid(self, form): + # lock project response = super().form_valid(form) messenger( - MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS, + MESSAGES.SEND_FOR_APPROVAL, request=self.request, user=self.request.user, - source=self.project, - related=self.object, + source=self.object, ) return response - def get_success_url(self): - return self.project.get_absolute_url() - @method_decorator(staff_required, name='dispatch') class CreateApprovalView(DelegatedViewMixin, CreateView): @@ -248,10 +82,17 @@ class CreateApprovalView(DelegatedViewMixin, CreateView): form_class = CreateApprovalForm model = Approval + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs.pop('instance') + return kwargs + @transaction.atomic() def form_valid(self, form): project = self.kwargs['object'] + form.instance.project = project + response = super().form_valid(form) messenger( @@ -270,93 +111,49 @@ class CreateApprovalView(DelegatedViewMixin, CreateView): return response -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): - raise PermissionDenied - - return super().dispatch(request, *args, **kwargs) - - @transaction.atomic() - def delete(self, request, *args, **kwargs): - response = super().delete(request, *args, **kwargs) +@method_decorator(approver_required, name='dispatch') +class RejectionView(DelegatedViewMixin, UpdateView): + context_name = 'rejection_form' + form_class = RejectionForm + model = Project + def form_valid(self, form): messenger( - MESSAGES.DELETE_PAYMENT_REQUEST, + MESSAGES.REQUEST_PROJECT_CHANGE, request=self.request, user=self.request.user, - source=self.project, - related=self.object, + source=self.object, + comment=form.cleaned_data['comment'], ) - return response - - def get_success_url(self): - return self.project.get_absolute_url() - - -class EditPaymentRequestView(UpdateView): - form_class = EditPaymentRequestForm - model = PaymentRequest - pk_url_kwarg = 'payment_request_id' + self.object.is_locked = False + self.object.save(update_fields=['is_locked']) - def dispatch(self, request, *args, **kwargs): - self.project = get_object_or_404(Project, pk=self.kwargs['pk']) + return redirect(self.object) - is_admin = request.user.is_apply_staff - is_owner = request.user == self.project.user - if not (is_owner or is_admin): - raise PermissionDenied - return super().dispatch(request, *args, **kwargs) +# PROJECT DOCUMENTS - def form_invalid(self, form): - messages.error(self.request, form.errors) - return redirect(self.project) +@method_decorator(staff_required, name='dispatch') +class UploadDocumentView(DelegatedViewMixin, CreateView): + context_name = 'document_form' + form_class = UploadDocumentForm + model = Project def form_valid(self, form): + project = self.kwargs['object'] + form.instance.project = project response = super().form_valid(form) messenger( - MESSAGES.UPDATE_PAYMENT_REQUEST, + MESSAGES.UPLOAD_DOCUMENT, request=self.request, user=self.request.user, - source=self.project, - related=self.object, + source=project, ) return response - def get_success_url(self): - return self.project.get_absolute_url() - - -@method_decorator(staff_required, name='dispatch') -class RejectionView(DelegatedViewMixin, UpdateView): - context_name = 'rejection_form' - form_class = RejectionForm - model = Project - - def form_valid(self, form): - messenger( - MESSAGES.REQUEST_PROJECT_CHANGE, - request=self.request, - user=self.request.user, - source=self.object, - comment=form.cleaned_data['comment'], - ) - - self.object.is_locked = False - self.object.save(update_fields=['is_locked']) - - return redirect(self.object) - @method_decorator(staff_required, name='dispatch') class RemoveDocumentView(DelegatedViewMixin, FormView): @@ -376,33 +173,11 @@ class RemoveDocumentView(DelegatedViewMixin, FormView): return redirect(project) -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.APPROVE_PROJECT, - request=self.request, - user=self.request.user, - source=project, - ) - - return response - - @method_decorator(login_required, name='dispatch') -class SelectDocumentView(SubmissionFilesMixin, FormView): - context_name = 'select_document_form' +class SelectDocumentView(DelegatedViewMixin, CreateView): form_class = SelectDocumentForm - model = Project + context_name = 'select_document_form' + model = PacketFile def dispatch(self, request, *args, **kwargs): self.project = get_object_or_404(Project, pk=self.kwargs['pk']) @@ -415,6 +190,9 @@ class SelectDocumentView(SubmissionFilesMixin, FormView): return redirect(self.project) def form_valid(self, form): + form.instance.project = self.project + form.instance.name = form.instance.document.name + response = super().form_valid(form) messenger( @@ -428,33 +206,13 @@ class SelectDocumentView(SubmissionFilesMixin, FormView): def get_form_kwargs(self): kwargs = super().get_form_kwargs() - kwargs['existing_files'] = get_files(self.project) - kwargs['project'] = self.project + kwargs.pop('user') + kwargs.pop('instance') + kwargs['existing_files'] = get_files(self.get_parent_object()) return kwargs - def get_success_url(self): - return self.project.get_absolute_url() - - -@method_decorator(staff_required, name='dispatch') -class SendForApprovalView(DelegatedViewMixin, UpdateView): - context_name = 'request_approval_form' - form_class = SetPendingForm - model = Project - - def form_valid(self, form): - # lock project - response = super().form_valid(form) - - messenger( - MESSAGES.SEND_FOR_APPROVAL, - request=self.request, - user=self.request.user, - source=self.object, - ) - - return response +# GENERAL FORM VIEWS @method_decorator(staff_required, name='dispatch') class UpdateLeadView(DelegatedViewMixin, UpdateView): @@ -479,10 +237,81 @@ class UpdateLeadView(DelegatedViewMixin, UpdateView): return response +# CONTRACTS + +class ContractsMixin: + def get_context_data(self, **kwargs): + project = self.get_object() + contracts = (project.contracts.select_related('approver') + .order_by('-created_at')) + + latest_contract = self.get_contract_to_approve(contracts) + + contracts = contracts.filter(is_signed=True, approver__isnull=False) + + if latest_contract: + contracts = [latest_contract, *contracts] + + context = super().get_context_data(**kwargs) + context['latest_contract'] = latest_contract + context['contracts'] = contracts + return context + + def get_contract_to_approve(self, contracts): + """If there's a contract to approve, get that""" + latest = contracts.first() + + if not latest: + return + + if latest.approver: + return + + return latest + + +@method_decorator(staff_required, name='dispatch') +class ApproveContractView(UpdateView): + form_class = ApproveContractForm + model = Contract + pk_url_kwarg = 'contract_pk' + + def dispatch(self, request, *args, **kwargs): + self.project = get_object_or_404(Project, pk=self.kwargs['pk']) + return super().dispatch(request, *args, **kwargs) + + def form_invalid(self, form): + for error in form.errors: + messages.error(self.request, error) + + return redirect(self.project) + + def form_valid(self, form): + with transaction.atomic(): + form.instance.approver = self.request.user + form.instance.project = self.project + response = super().form_valid(form) + + messenger( + MESSAGES.APPROVE_CONTRACT, + request=self.request, + user=self.request.user, + source=self.project, + related=self.object, + ) + + self.project.status = IN_PROGRESS + self.project.save(update_fields=['status']) + + return response + + def get_success_url(self): + return self.project.get_absolute_url() + + @method_decorator(login_required, name='dispatch') class UploadContractView(DelegatedViewMixin, CreateView): context_name = 'contract_form' - form_class = UploadContractForm model = Project def dispatch(self, request, *args, **kwargs): @@ -495,8 +324,20 @@ class UploadContractView(DelegatedViewMixin, CreateView): return response + def get_form_class(self): + if self.request.user.is_apply_staff: + return StaffUploadContractForm + return UploadContractForm + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs.pop('instance') + kwargs.pop('user') + return kwargs + def form_valid(self, form): project = self.kwargs['object'] + form.instance.project = project if self.request.user == project.user: @@ -514,33 +355,12 @@ class UploadContractView(DelegatedViewMixin, CreateView): return response -@method_decorator(staff_required, name='dispatch') -class UploadDocumentView(DelegatedViewMixin, CreateView): - context_name = 'document_form' - form_class = UploadDocumentForm - model = Project - - def form_valid(self, form): - project = self.kwargs['object'] - form.instance.project = project - response = super().form_valid(form) - - messenger( - MESSAGES.UPLOAD_DOCUMENT, - request=self.request, - user=self.request.user, - source=project, - ) - - return response - +# PROJECT VIEW class AdminProjectDetailView( ActivityContextMixin, DelegateableView, ContractsMixin, - PaymentsMixin, - SubmissionFilesMixin, DetailView, ): form_views = [ @@ -549,6 +369,7 @@ class AdminProjectDetailView( RejectionView, RemoveDocumentView, RequestPaymentView, + SelectDocumentView, SendForApprovalView, UpdateLeadView, UploadContractView, @@ -563,29 +384,15 @@ class AdminProjectDetailView( context['current_status_index'] = [status for status, _ in PROJECT_STATUS_CHOICES].index(self.object.status) context['approvals'] = self.object.approvals.distinct('by') context['approve_contract_form'] = ApproveContractForm() - context['change_payment_request_status_forms'] = list(self.get_change_payment_request_status_forms()) context['remaining_document_categories'] = list(self.object.get_missing_document_categories()) return context - def get_change_payment_request_status_forms(self): - """ - Get an iterable of ChangePaymentRequestStatusForms - - We want to filter the available options based on the current - PaymentRequest object so we need to initialise those forms outside of - the template. - """ - for payment_request in self.object.payment_requests.exclude(status=DECLINED).select_related('project'): - yield ChangePaymentRequestStatusForm(instance=payment_request) - - def get_payment_requests_queryset(self): - return self.object.payment_requests.filter(status=SUBMITTED) - -class ApplicantProjectDetailView(ActivityContextMixin, DelegateableView, ContractsMixin, PaymentsMixin, DetailView): +class ApplicantProjectDetailView(ActivityContextMixin, DelegateableView, ContractsMixin, DetailView): form_views = [ CommentFormView, RequestPaymentView, + SelectDocumentView, UploadContractView, ] @@ -594,13 +401,14 @@ class ApplicantProjectDetailView(ActivityContextMixin, DelegateableView, Contrac def dispatch(self, request, *args, **kwargs): project = self.get_object() - # This view is only for applicants. if project.user != request.user: raise PermissionDenied return super().dispatch(request, *args, **kwargs) - def get_payment_requests_queryset(self): - return self.object.payment_requests.filter(status__in=[SUBMITTED, CHANGES_REQUESTED]) + +class ProjectDetailView(ViewDispatcher): + admin_view = AdminProjectDetailView + applicant_view = ApplicantProjectDetailView @method_decorator(login_required, name='dispatch') @@ -628,10 +436,7 @@ class ProjectPrivateMediaView(UserPassesTestMixin, PrivateMediaView): return False -class ProjectDetailView(ViewDispatcher): - admin_view = AdminProjectDetailView - applicant_view = ApplicantProjectDetailView - +# PROJECT EDIT @method_decorator(staff_required, name='dispatch') class ProjectDetailSimplifiedView(DetailView): diff --git a/opentech/apply/users/decorators.py b/opentech/apply/users/decorators.py index 26d4016f57356e56ce077dfa4c40d012ee2ccef1..b41a4aaee89d268e53e97700d2fb91b8d897377c 100644 --- a/opentech/apply/users/decorators.py +++ b/opentech/apply/users/decorators.py @@ -19,8 +19,16 @@ def is_apply_staff(user): return True +def is_approver(user): + if not user.is_approver: + raise PermissionDenied + return True + + staff_required = [login_required, user_passes_test(is_apply_staff)] +approver_required = [login_required, user_passes_test(is_approver)] + def superuser_decorator(fn): check = user_passes_test(lambda user: user.is_superuser) diff --git a/opentech/apply/users/models.py b/opentech/apply/users/models.py index 768b583c958865dcd2851c39fa5ffeb686d3845b..1c60d84be3766d124aa41c2a7616f04ba16a8a1a 100644 --- a/opentech/apply/users/models.py +++ b/opentech/apply/users/models.py @@ -2,7 +2,6 @@ from django.contrib.auth.hashers import make_password from django.contrib.auth.models import AbstractUser, BaseUserManager, Group from django.db import models from django.db.models import Q -from django.urls import reverse from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ @@ -102,9 +101,6 @@ class User(AbstractUser): objects = UserManager() - def get_absolute_url(self): - return reverse('wagtailusers_users:edit', args=(self.id,)) - def __str__(self): return self.get_full_name() if self.get_full_name() else self.get_short_name() diff --git a/opentech/apply/users/wagtail_hooks.py b/opentech/apply/users/wagtail_hooks.py index dcd5276f5f356dea951f5dfba794fb96dda19954..5e865f25945de71a80959830c5c56a52e16cd998 100644 --- a/opentech/apply/users/wagtail_hooks.py +++ b/opentech/apply/users/wagtail_hooks.py @@ -1,4 +1,5 @@ from django.conf.urls import url +from django.urls import reverse from wagtail.core import hooks @@ -20,6 +21,7 @@ def notify_after_create_user(request, user): message=f'{request.user} has crated a new account for {user}.', request=request, related=user, + path=reverse('wagtailusers_users:edit', args=(user.id,)) ) @@ -32,4 +34,5 @@ def notify_after_edit_user(request, user): message=f'{request.user} has edited the account for {user} that now have these roles: {roles}.', request=request, related=user, + path=reverse('wagtailusers_users:edit', args=(user.id,)) ) diff --git a/opentech/apply/utils/notifications.py b/opentech/apply/utils/notifications.py index bed3683da90108043926563a59f6c05e7d8f3178..037b23a32bec317028ad8baf077e2ad85e4f7c92 100644 --- a/opentech/apply/utils/notifications.py +++ b/opentech/apply/utils/notifications.py @@ -19,16 +19,15 @@ class SlackNotifications(): slack_users.append(f'<{user.slack}>') return ' '.join(slack_users) - def slack_link(self, request, related): - slack_link = '' + def slack_link(self, request, related, path=None, **kwargs): try: - link = request.scheme + '://' + request.get_host() + related.get_absolute_url() + url = path or related.get_absolute_url() except AttributeError: - pass - else: - title = str(related) - slack_link = f'<{link}|{title}>' - return slack_link + return '' + + link = request.scheme + '://' + request.get_host() + url + title = str(related) + return f'<{link}|{title}>' def send_message(self, message, request, recipients=None, related=None, **kwargs): if not self.destination or not self.target_room: @@ -41,7 +40,7 @@ class SlackNotifications(): slack_users = self.slack_users(recipients) if recipients else '' - slack_link = self.slack_link(request, related) if related else '' + slack_link = self.slack_link(request, related, **kwargs) if related else '' message = ' '.join([slack_users, message, slack_link]).strip() diff --git a/opentech/apply/utils/templatetags/__init__.py b/opentech/apply/utils/templatetags/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/opentech/apply/utils/templatetags/apply_tags.py b/opentech/apply/utils/templatetags/apply_tags.py new file mode 100644 index 0000000000000000000000000000000000000000..c010e4d6cefb172eecdce993a6aff3e292da214b --- /dev/null +++ b/opentech/apply/utils/templatetags/apply_tags.py @@ -0,0 +1,9 @@ +from django import template + +register = template.Library() + + +# Get the verbose name of a model instance +@register.filter +def model_verbose_name(instance): + return instance._meta.verbose_name.title() diff --git a/opentech/apply/utils/testing/tests.py b/opentech/apply/utils/testing/tests.py index 2940aceb26b441b25726c68255964da9d9f1db54..b52c1d389828854a3fcb0aff8d0ef2d5891037b6 100644 --- a/opentech/apply/utils/testing/tests.py +++ b/opentech/apply/utils/testing/tests.py @@ -36,7 +36,7 @@ class BaseViewTestCase(TestCase): def get_kwargs(self, instance): return {} - def url(self, instance, view_name=None, absolute=True, kwargs=dict()): + def url(self, instance, view_name=None, absolute=True, url_kwargs=None): view = view_name or self.base_view_name full_url_name = self.url_name.format(view) kwargs_method = f'get_{view}_kwargs' @@ -44,6 +44,8 @@ class BaseViewTestCase(TestCase): kwargs = getattr(self, kwargs_method)(instance) else: kwargs = self.get_kwargs(instance) + if url_kwargs: + kwargs.update(url_kwargs) return self.url_from_pattern(full_url_name, kwargs, secure=True, absolute=absolute) def absolute_url(self, location, secure=True): @@ -57,11 +59,11 @@ class BaseViewTestCase(TestCase): request = self.factory.get(url, secure=secure) return request.path - def get_page(self, instance=None, view_name=None): - return self.client.get(self.url(instance, view_name), secure=True, follow=True) + def get_page(self, instance=None, view_name=None, url_kwargs=None): + return self.client.get(self.url(instance, view_name, url_kwargs=url_kwargs), secure=True, follow=True) - def post_page(self, instance=None, data=dict(), view_name=None): - return self.client.post(self.url(instance, view_name), data, secure=True, follow=True) + def post_page(self, instance=None, data=dict(), view_name=None, url_kwargs=None): + return self.client.post(self.url(instance, view_name, url_kwargs=url_kwargs), data, secure=True, follow=True) def refresh(self, instance): return instance.__class__.objects.get(id=instance.id) diff --git a/opentech/apply/utils/views.py b/opentech/apply/utils/views.py index 6ea0fb1673f27e375ac389147740534bf6a3ed73..17f5faa3dfc4bf260bb6df097f6d8e12eaf943c3 100644 --- a/opentech/apply/utils/views.py +++ b/opentech/apply/utils/views.py @@ -7,6 +7,7 @@ from django.views.generic import View from django.views.generic.base import ContextMixin from django.views.generic.detail import SingleObjectTemplateResponseMixin from django.views.generic.edit import ModelFormMixin, ProcessFormView +from django.shortcuts import redirect def page_not_found(request, exception=None, template_name='apply/404.html'): @@ -60,12 +61,22 @@ class DelegatableBase(ContextMixin): """ form_prefix = 'form-submitted-' + def __init__(self, *args, **kwargs): + self._form_views = { + self.form_prefix + form_view.context_name: form_view + for form_view in self.form_views + } + def get_form_kwargs(self): return {} def get_context_data(self, **kwargs): - form_kwargs = self.get_form_kwargs() - forms = dict(form_view.contribute_form(**form_kwargs) for form_view in self.form_views) + forms = {} + for form_view in self._form_views.values(): + view = form_view() + view.setup(self.request, self.args, self.kwargs) + context_key, form = view.contribute_form(self) + forms[context_key] = form return super().get_context_data( form_prefix=self.form_prefix, @@ -78,12 +89,12 @@ class DelegatableBase(ContextMixin): kwargs['context'] = self.get_context_data() kwargs['template_names'] = self.get_template_names() - for form_view in self.form_views: - if self.form_prefix + form_view.context_name in request.POST: - return form_view.as_view()(request, *args, **kwargs) + for form_key, form_view in self._form_views.items(): + if form_key in request.POST: + return form_view.as_view()(request, *args, parent=self, **kwargs) # Fall back to get if not form exists as submitted - return self.get(request, *args, **kwargs) + return redirect(request.path) class DelegateableView(DelegatableBase): @@ -115,17 +126,46 @@ class DelegateableListView(DelegatableBase): class DelegatedViewMixin(View): """For use on create views accepting forms from another view""" + # TODO: REMOVE IN DJANGO 2.2 + def setup(self, request, *args, **kwargs): + """Initialize attributes shared by all view methods.""" + self.request = request + self.args = args + self.kwargs = kwargs + + def get_object(self): + # We want to make sure we share the same instance between the form + # and the view where appropriate + parent_object = self.get_parent_kwargs()['instance'] + if isinstance(parent_object, self.model): + return parent_object + + return super().get_object() + def get_template_names(self): return self.kwargs['template_names'] + def get_form_name(self): + return self.context_name + def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs['user'] = self.request.user - return kwargs + form_kwargs = super().get_form_kwargs() + form_kwargs['user'] = self.request.user + form_kwargs.update(**self.get_parent_kwargs()) + return form_kwargs + + def get_parent_kwargs(self): + try: + return self.parent.get_form_kwargs() + except AttributeError: + return self.kwargs['parent'].get_form_kwargs() + + def get_parent_object(self): + return self.get_parent_kwargs()['instance'] def get_form(self, *args, **kwargs): form = super().get_form(*args, **kwargs) - form.name = self.context_name + form.name = self.get_form_name() return form def get_context_data(self, **kwargs): @@ -139,11 +179,10 @@ class DelegatedViewMixin(View): def is_model_form(cls): return issubclass(cls.form_class, ModelForm) - @classmethod - def contribute_form(cls, **kwargs): - form = cls.form_class(**kwargs) - form.name = cls.context_name - return cls.context_name, form + def contribute_form(self, parent): + self.parent = parent + form = self.get_form() + return self.context_name, form def get_success_url(self): query = self.request.GET.urlencode() diff --git a/opentech/static_src/src/sass/apply/components/_docs-block.scss b/opentech/static_src/src/sass/apply/components/_docs-block.scss index b48aac80abc5d9f22920a2e43ed15de4c2273248..eae2ff83ac07d6460de6f5e709f1ba4fb66c92c7 100644 --- a/opentech/static_src/src/sass/apply/components/_docs-block.scss +++ b/opentech/static_src/src/sass/apply/components/_docs-block.scss @@ -95,6 +95,12 @@ font-weight: $weight--bold; margin-right: 1rem; + &:disabled, + &.is-disabled { + color: $color--mid-grey; + cursor: default; + } + &:last-child { margin-right: 0; } 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 32dc22e9279fc16079ed394b786c522986aab453..97e4d726c5881a25ef0cfa8974d2821ad240fd24 100644 --- a/opentech/static_src/src/sass/apply/components/_payment-block.scss +++ b/opentech/static_src/src/sass/apply/components/_payment-block.scss @@ -139,12 +139,12 @@ &__table-status { min-width: 160px; - width: 30%; + width: 25%; } - &__table-docs { + &__table-date{ min-width: 180px; - width: 20%; + width: 25%; } &__table-update {