diff --git a/hypha/apply/activity/messaging.py b/hypha/apply/activity/messaging.py index 69559237e050c48a913d3f2d53b237e5237a4630..a40904b4261235d0bd87580e52d8d45eefe9ee78 100644 --- a/hypha/apply/activity/messaging.py +++ b/hypha/apply/activity/messaging.py @@ -62,9 +62,13 @@ neat_related = { MESSAGES.APPROVE_CONTRACT: 'contract', MESSAGES.UPLOAD_CONTRACT: 'contract', MESSAGES.REQUEST_PAYMENT: 'payment_request', + MESSAGES.CREATE_INVOICE: 'create_invoice', MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: 'payment_request', + MESSAGES.UPDATE_INVOICE_STATUS: 'invoice', MESSAGES.DELETE_PAYMENT_REQUEST: 'payment_request', + MESSAGES.DELETE_INVOICE: 'invoice', MESSAGES.UPDATE_PAYMENT_REQUEST: 'payment_request', + MESSAGES.UPDATE_INVOICE: 'invoice', MESSAGES.SUBMIT_REPORT: 'report', MESSAGES.SKIPPED_REPORT: 'report', MESSAGES.REPORT_FREQUENCY_CHANGED: 'config', @@ -245,7 +249,9 @@ class ActivityAdapter(AdapterBase): MESSAGES.UPLOAD_CONTRACT: _('Uploaded a {contract.state} contract'), MESSAGES.APPROVE_CONTRACT: _('Approved contract'), MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: _('Updated Payment Request status to: {payment_request.status_display}'), + MESSAGES.UPDATE_INVOICE_STATUS: _('Updated Invoice status to: {invoice.status_display}'), MESSAGES.REQUEST_PAYMENT: _('Payment Request submitted'), + MESSAGES.CREATE_INVOICE: _('Invoice created'), MESSAGES.SUBMIT_REPORT: _('Submitted a report'), MESSAGES.SKIPPED_REPORT: 'handle_skipped_report', MESSAGES.REPORT_FREQUENCY_CHANGED: 'handle_report_frequency', @@ -436,9 +442,13 @@ 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.CREATE_INVOICE: _('{user} has created invoice for <{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.UPDATE_INVOICE_STATUS: _('{user} has changed the status of <{link_related}|invoice> on <{link}|{source.title}> to {invoice.status_display}.'), MESSAGES.DELETE_PAYMENT_REQUEST: _('{user} has deleted payment request from <{link}|{source.title}>.'), + MESSAGES.DELETE_INVOICE: _('{user} has deleted invoice from <{link}|{source.title}>.'), MESSAGES.UPDATE_PAYMENT_REQUEST: _('{user} has updated payment request for <{link}|{source.title}>.'), + MESSAGES.UPDATE_INVOICE: _('{user} has updated invoice for <{link}|{source.title}>.'), MESSAGES.SUBMIT_REPORT: _('{user} has submitted a report for <{link}|{source.title}>.'), MESSAGES.BATCH_DELETE_SUBMISSION: 'handle_batch_delete_submission' } @@ -712,7 +722,9 @@ class EmailAdapter(AdapterBase): MESSAGES.UPDATED_VENDOR: 'handle_vendor_updated', MESSAGES.SENT_TO_COMPLIANCE: 'messages/email/sent_to_compliance.html', MESSAGES.UPDATE_PAYMENT_REQUEST: 'messages/email/payment_request_updated.html', + MESSAGES.UPDATE_INVOICE: 'handle_invoice_updated', MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: 'handle_payment_status_updated', + MESSAGES.UPDATE_INVOICE_STATUS: 'handle_invoice_status_updated', MESSAGES.SUBMIT_REPORT: 'messages/email/report_submitted.html', MESSAGES.SKIPPED_REPORT: 'messages/email/report_skipped.html', MESSAGES.REPORT_FREQUENCY_CHANGED: 'messages/email/report_frequency.html', @@ -770,6 +782,19 @@ class EmailAdapter(AdapterBase): **kwargs, ) + def handle_invoice_status_updated(self, related, **kwargs): + return self.render_message( + 'messages/email/invoice_status_updated.html', + has_changes_requested=related.has_changes_requested, + **kwargs, + ) + + def handle_invoice_updated(self, **kwargs): + return self.render_message( + 'messages/email/invoice_updated.html', + **kwargs, + ) + def handle_project_created(self, source, **kwargs): from hypha.apply.projects.models import ProjectSettings request = kwargs.get('request') @@ -857,7 +882,7 @@ class EmailAdapter(AdapterBase): return [project_settings.compliance_email] - if message_type in {MESSAGES.SUBMIT_REPORT, MESSAGES.UPDATE_PAYMENT_REQUEST}: + if message_type in {MESSAGES.SUBMIT_REPORT, MESSAGES.UPDATE_PAYMENT_REQUEST, MESSAGES.UPDATE_INVOICE}: # Don't tell the user if they did these activities if user.is_applicant: return [] diff --git a/hypha/apply/activity/migrations/0057_add_invoices.py b/hypha/apply/activity/migrations/0057_add_invoices.py new file mode 100644 index 0000000000000000000000000000000000000000..ae053359206857af397190456b472e1d381ccfc4 --- /dev/null +++ b/hypha/apply/activity/migrations/0057_add_invoices.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.24 on 2021-07-07 00:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0056_add_updated_vendor'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATED_VENDOR', 'Updated Vendor'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('PROJECT_TRANSITION', 'Project was Transitioned'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project'), ('UPLOAD_CONTRACT', 'Contract was Uploaded to Project'), ('APPROVE_CONTRACT', 'Contract was Approved'), ('REQUEST_PAYMENT', 'Payment was requested for Project'), ('CREATE_INVOICE', 'Invoice was created for Project'), ('UPDATE_PAYMENT_REQUEST_STATUS', 'Updated Payment Request Status'), ('DELETE_PAYMENT_REQUEST', 'Delete Payment Request'), ('SENT_TO_COMPLIANCE', 'Project was sent to Compliance'), ('UPDATE_PAYMENT_REQUEST', 'Updated Payment Request'), ('SUBMIT_REPORT', 'Submit Report'), ('SKIPPED_REPORT', 'Skipped Report'), ('REPORT_FREQUENCY_CHANGED', 'Report Frequency Changed'), ('REPORT_NOTIFY', 'Report Notify'), ('CREATE_REMINDER', 'Reminder Created'), ('DELETE_REMINDER', 'Reminder Deleted'), ('REVIEW_REMINDER', 'Reminde to Review'), ('BATCH_DELETE_SUBMISSION', 'Delete Batch Submissions')], max_length=50), + ), + ] diff --git a/hypha/apply/activity/migrations/0058_add_project_invoicing.py b/hypha/apply/activity/migrations/0058_add_project_invoicing.py new file mode 100644 index 0000000000000000000000000000000000000000..d244da829a7c1867bf38e8c95e6f40619ff48fe4 --- /dev/null +++ b/hypha/apply/activity/migrations/0058_add_project_invoicing.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.24 on 2021-07-08 07:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0057_add_invoices'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATED_VENDOR', 'Updated Vendor'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('PROJECT_TRANSITION', 'Project was Transitioned'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project'), ('UPLOAD_CONTRACT', 'Contract was Uploaded to Project'), ('APPROVE_CONTRACT', 'Contract was Approved'), ('REQUEST_PAYMENT', 'Payment was requested for Project'), ('CREATE_INVOICE', 'Invoice was created for Project'), ('UPDATE_PAYMENT_REQUEST_STATUS', 'Updated Payment Request Status'), ('UPDATE_INVOICE_STATUS', 'Updated Invoice Status'), ('DELETE_PAYMENT_REQUEST', 'Delete Payment Request'), ('DELETE_INVOICE', 'Delete Invoice'), ('SENT_TO_COMPLIANCE', 'Project was sent to Compliance'), ('UPDATE_PAYMENT_REQUEST', 'Updated Payment Request'), ('UPDATE_INVOICE', 'Updated Invoice'), ('SUBMIT_REPORT', 'Submit Report'), ('SKIPPED_REPORT', 'Skipped Report'), ('REPORT_FREQUENCY_CHANGED', 'Report Frequency Changed'), ('REPORT_NOTIFY', 'Report Notify'), ('CREATE_REMINDER', 'Reminder Created'), ('DELETE_REMINDER', 'Reminder Deleted'), ('REVIEW_REMINDER', 'Reminde to Review'), ('BATCH_DELETE_SUBMISSION', 'Delete Batch Submissions')], max_length=50), + ), + ] diff --git a/hypha/apply/activity/options.py b/hypha/apply/activity/options.py index 75cf974f80b3715fb899ec07df90c4dda7f00b7f..41f4146fead0eed6264a0cc3f79fe0a67f199833 100644 --- a/hypha/apply/activity/options.py +++ b/hypha/apply/activity/options.py @@ -39,10 +39,14 @@ class MESSAGES(Enum): UPLOAD_CONTRACT = 'Contract was Uploaded to Project' APPROVE_CONTRACT = 'Contract was Approved' REQUEST_PAYMENT = 'Payment was requested for Project' + CREATE_INVOICE = 'Invoice was created for Project' UPDATE_PAYMENT_REQUEST_STATUS = 'Updated Payment Request Status' + UPDATE_INVOICE_STATUS = 'Updated Invoice Status' DELETE_PAYMENT_REQUEST = 'Delete Payment Request' + DELETE_INVOICE = 'Delete Invoice' SENT_TO_COMPLIANCE = 'Project was sent to Compliance' UPDATE_PAYMENT_REQUEST = 'Updated Payment Request' + UPDATE_INVOICE = 'Updated Invoice' SUBMIT_REPORT = 'Submit Report' SKIPPED_REPORT = 'Skipped Report' REPORT_FREQUENCY_CHANGED = 'Report Frequency Changed' diff --git a/hypha/apply/activity/templates/messages/email/invoice_status_updated.html b/hypha/apply/activity/templates/messages/email/invoice_status_updated.html new file mode 100644 index 0000000000000000000000000000000000000000..1f4704643ec72620b35e0e8130cc540b1537d0e3 --- /dev/null +++ b/hypha/apply/activity/templates/messages/email/invoice_status_updated.html @@ -0,0 +1,16 @@ +{% extends "messages/email/applicant_base.html" %} + +{% load i18n %} +{% block content %} +{% blocktrans %}An {{ ORG_SHORT_NAME }} staff member has updated your invoice for {{ source.title }} for period {{ invoice.date_from }} to {{ invoice.date_to }}.{% endblocktrans %} +{% blocktrans %}It is now {{ invoice.get_status_display }}.{% endblocktrans %} + +{% if has_changes_requested %} +{% trans "The staff member left this comment" %}: + +{{ payment_request.comment }} +{% endif %} + +{% trans "Title" %}: {{ source.title }} +{% trans "Link" %}: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }} +{% endblock %} diff --git a/hypha/apply/activity/templates/messages/email/invoice_updated.html b/hypha/apply/activity/templates/messages/email/invoice_updated.html new file mode 100644 index 0000000000000000000000000000000000000000..fe447dcf480b0fa8c67a0cbeeeeb282619f9bcd5 --- /dev/null +++ b/hypha/apply/activity/templates/messages/email/invoice_updated.html @@ -0,0 +1,11 @@ +{% extends "messages/email/applicant_base.html" %} + +{% load i18n %} +{% block content %} + +{% blocktrans %}An {{ ORG_SHORT_NAME }} staff member has updated your invoice for {{ source.title }} for period {{ invoice.date_from }} to {{ invoice.date_to }}.{% endblocktrans %} +{% blocktrans %}It is now {{ invoice.get_status_display }}.{% endblocktrans %} + +{% trans "Title" %}: {{ source.title }} +{% trans "Link" %}: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }} +{% endblock %} diff --git a/hypha/apply/projects/filters.py b/hypha/apply/projects/filters.py index 83525d361f596d8b5d96caf6914a25bf9cd0ba63..403af6f0943d4146cfd66805c51b70d564560fed 100644 --- a/hypha/apply/projects/filters.py +++ b/hypha/apply/projects/filters.py @@ -11,7 +11,7 @@ from hypha.apply.funds.tables import ( get_used_funds, ) -from .models.payment import REQUEST_STATUS_CHOICES, PaymentRequest +from .models.payment import REQUEST_STATUS_CHOICES, Invoice, PaymentRequest from .models.project import CLOSING, IN_PROGRESS, PROJECT_STATUS_CHOICES, Project from .models.report import Report @@ -32,6 +32,16 @@ class PaymentRequestListFilter(filters.FilterSet): model = PaymentRequest +class InvoiceListFilter(filters.FilterSet): + fund = Select2ModelMultipleChoiceFilter(label=_('Funds'), queryset=get_used_funds, field_name='project__submission__page') + status = Select2MultipleChoiceFilter(label=_('Status'), choices=REQUEST_STATUS_CHOICES) + lead = Select2ModelMultipleChoiceFilter(label=_('Lead'), queryset=get_project_leads, field_name='project__lead') + + class Meta: + fields = ['lead', 'fund', 'status'] + model = Invoice + + class ProjectListFilter(filters.FilterSet): REPORTING_CHOICES = ( (0, 'Up to date'), diff --git a/hypha/apply/projects/forms/__init__.py b/hypha/apply/projects/forms/__init__.py index 29c59a1b239b0c83a03cddcf47b68c74c8b3b858..ccc00a50980957c137a7d38d7963b7d4d8fb3690 100644 --- a/hypha/apply/projects/forms/__init__.py +++ b/hypha/apply/projects/forms/__init__.py @@ -1,6 +1,9 @@ from .payment import ( + ChangeInvoiceStatusForm, ChangePaymentRequestStatusForm, + CreateInvoiceForm, CreatePaymentRequestForm, + EditInvoiceForm, EditPaymentRequestForm, SelectDocumentForm, ) @@ -53,4 +56,7 @@ __all__ = [ 'CreateVendorFormStep4', 'CreateVendorFormStep5', 'CreateVendorFormStep6', + 'CreateInvoiceForm', + 'ChangeInvoiceStatusForm', + 'EditInvoiceForm', ] diff --git a/hypha/apply/projects/forms/payment.py b/hypha/apply/projects/forms/payment.py index 4af94f103beb80bbf2cc44e0bf3b4ce4fe18a79e..3c6c8c8c9ce311e8f9852240bbb61d973b78d850 100644 --- a/hypha/apply/projects/forms/payment.py +++ b/hypha/apply/projects/forms/payment.py @@ -1,11 +1,13 @@ import functools +import json from django import forms from django.core.files.base import ContentFile from django.db import transaction +from django.db.models.fields.files import FieldFile from django_file_form.forms import FileFormMixin -from hypha.apply.stream_forms.fields import MultiFileField +from hypha.apply.stream_forms.fields import MultiFileField, SingleFileField from ..models.payment import ( CHANGES_REQUESTED, @@ -14,8 +16,10 @@ from ..models.payment import ( REQUEST_STATUS_CHOICES, SUBMITTED, UNDER_REVIEW, + Invoice, PaymentReceipt, PaymentRequest, + SupportingDocument, ) from ..models.project import PacketFile @@ -61,6 +65,40 @@ class ChangePaymentRequestStatusForm(forms.ModelForm): return cleaned_data +class ChangeInvoiceStatusForm(forms.ModelForm): + name_prefix = 'change_invoice_status_form' + + class Meta: + fields = ['status', 'comment', 'paid_value'] + model = Invoice + + def __init__(self, instance, *args, **kwargs): + super().__init__(instance=instance, *args, **kwargs) + + self.initial['paid_value'] = self.instance.amount + + status_field = self.fields['status'] + + possible_status_transitions_lut = { + CHANGES_REQUESTED: filter_request_choices([DECLINED]), + SUBMITTED: filter_request_choices([CHANGES_REQUESTED, UNDER_REVIEW, DECLINED]), + UNDER_REVIEW: filter_request_choices([PAID]), + } + status_field.choices = possible_status_transitions_lut.get(instance.status, []) + + if instance.status != UNDER_REVIEW: + del self.fields['paid_value'] + + def clean(self): + cleaned_data = super().clean() + status = cleaned_data['status'] + paid_value = cleaned_data.get('paid_value') + + if paid_value and status != PAID: + self.add_error('paid_value', 'You can only set a value when moving to the Paid status.') + return cleaned_data + + class PaymentRequestBaseForm(forms.ModelForm): class Meta: fields = ['requested_value', 'invoice', 'date_from', 'date_to'] @@ -104,6 +142,53 @@ class CreatePaymentRequestForm(FileFormMixin, PaymentRequestBaseForm): return request +class InvoiceBaseForm(forms.ModelForm): + class Meta: + fields = ['date_from', 'date_to', 'amount', 'document', 'message_for_pm'] + model = Invoice + widgets = { + 'date_from': forms.DateInput, + 'date_to': forms.DateInput, + } + + def __init__(self, user=None, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['amount'].widget.attrs['min'] = 0 + + def clean(self): + cleaned_data = super().clean() + date_from = cleaned_data['date_from'] + date_to = cleaned_data['date_to'] + + if date_from > date_to: + self.add_error('date_from', 'Date From must be before Date To') + + return cleaned_data + + +class CreateInvoiceForm(FileFormMixin, InvoiceBaseForm): + document = SingleFileField(label='Invoice File', required=True) + supporting_documents = MultiFileField( + required=False, + help_text='Files that are related to the invoice. ' + 'They could be xls, microsoft office documents, open office documents, pdfs, txt files.' + ) + + field_order = ['date_from', 'date_to', 'amount', 'document', 'supporting_documents', 'message_for_pm'] + + def save(self, commit=True): + invoice = super().save(commit=commit) + + supporting_documents = self.cleaned_data['supporting_documents'] or [] + + SupportingDocument.objects.bulk_create( + SupportingDocument(invoice=invoice, document=document) + for document in supporting_documents + ) + + return invoice + + class EditPaymentRequestForm(FileFormMixin, PaymentRequestBaseForm): receipt_list = forms.ModelMultipleChoiceField( widget=forms.CheckboxSelectMultiple(attrs={'class': 'delete'}), @@ -137,6 +222,32 @@ class EditPaymentRequestForm(FileFormMixin, PaymentRequestBaseForm): return request +class EditInvoiceForm(FileFormMixin, InvoiceBaseForm): + document = SingleFileField(label='Invoice File', required=True) + supporting_documents = MultiFileField(required=False) + + field_order = ['date_from', 'date_to', 'amount', 'document', 'supporting_documents', 'message_for_pm'] + + @transaction.atomic + def save(self, commit=True): + invoice = super().save(commit=commit) + not_deleted_original_filenames = [ + file['name'] for file in json.loads(self.cleaned_data['supporting_documents-uploads']) + ] + for f in invoice.supporting_documents.all(): + if f.document.name not in not_deleted_original_filenames: + f.document.delete() + f.delete() + + for f in self.cleaned_data["supporting_documents"]: + if not isinstance(f, FieldFile): + try: + SupportingDocument.objects.create(invoice=invoice, document=f) + finally: + f.close() + return invoice + + class SelectDocumentForm(forms.ModelForm): document = forms.ChoiceField( label="Document", diff --git a/hypha/apply/projects/migrations/0037_add_project_invoicing.py b/hypha/apply/projects/migrations/0037_add_project_invoicing.py new file mode 100644 index 0000000000000000000000000000000000000000..996bb3761dac2541915ff64afb3c7ddbc325bb3c --- /dev/null +++ b/hypha/apply/projects/migrations/0037_add_project_invoicing.py @@ -0,0 +1,45 @@ +# Generated by Django 2.2.24 on 2021-07-28 07:08 + +from decimal import Decimal +from django.conf import settings +import django.core.files.storage +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import hypha.apply.projects.models.payment + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('application_projects', '0036_add_vendor'), + ] + + operations = [ + migrations.CreateModel( + name='Invoice', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date_from', models.DateTimeField()), + ('date_to', models.DateTimeField()), + ('amount', models.DecimalField(decimal_places=2, default=0, max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))])), + ('paid_value', models.DecimalField(decimal_places=2, max_digits=10, null=True, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))])), + ('document', models.FileField(storage=django.core.files.storage.FileSystemStorage(), upload_to=hypha.apply.projects.models.payment.invoice_path)), + ('requested_at', models.DateTimeField(auto_now_add=True)), + ('message_for_pm', models.TextField(blank=True)), + ('comment', models.TextField(blank=True)), + ('status', models.TextField(choices=[('submitted', 'Submitted'), ('changes_requested', 'Changes Requested'), ('under_review', 'Under Review'), ('paid', 'Paid'), ('declined', 'Declined')], default='submitted')), + ('by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to=settings.AUTH_USER_MODEL)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to='application_projects.Project')), + ], + ), + migrations.CreateModel( + name='SupportingDocument', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('document', models.FileField(storage=django.core.files.storage.FileSystemStorage(), upload_to='supporting_documents')), + ('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='supporting_documents', to='application_projects.Invoice')), + ], + ), + ] diff --git a/hypha/apply/projects/models/__init__.py b/hypha/apply/projects/models/__init__.py index 760e37b3dde0c4ce821cdb564e19582e05508c66..71e3b7734417ad174372a0679d39667aef83f0ee 100644 --- a/hypha/apply/projects/models/__init__.py +++ b/hypha/apply/projects/models/__init__.py @@ -1,4 +1,10 @@ -from .payment import PaymentApproval, PaymentReceipt, PaymentRequest +from .payment import ( + Invoice, + PaymentApproval, + PaymentReceipt, + PaymentRequest, + SupportingDocument, +) from .project import ( Approval, Contract, @@ -29,4 +35,6 @@ __all__ = [ 'Vendor', 'BankInformation', 'DueDiligenceDocument', + 'Invoice', + 'SupportingDocument', ] diff --git a/hypha/apply/projects/models/payment.py b/hypha/apply/projects/models/payment.py index b0a5ccfc8fcd1fe6e851b4a300a0e364924b1bbf..2b6a0a1da9a5a945b577fd15dab9ad638ac5d286 100644 --- a/hypha/apply/projects/models/payment.py +++ b/hypha/apply/projects/models/payment.py @@ -71,6 +71,115 @@ class PaymentRequestQueryset(models.QuerySet): return self.filter(status__in=[SUBMITTED, UNDER_REVIEW]).total_value('requested_value') +class InvoiceQueryset(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, field): + return self.aggregate(total=Coalesce(Sum(field), Value(0)))['total'] + + def paid_value(self): + return self.filter(status=PAID).total_value('paid_value') + + def unpaid_value(self): + return self.filter(status__in=[SUBMITTED, UNDER_REVIEW]).total_value('requested_value') + + +class Invoice(models.Model): + project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="invoices") + by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="invoices") + date_from = models.DateTimeField() + date_to = models.DateTimeField() + amount = models.DecimalField( + default=0, + max_digits=10, + decimal_places=2, + validators=[MinValueValidator(decimal.Decimal('0.01'))], + ) + paid_value = models.DecimalField( + max_digits=10, + decimal_places=2, + validators=[MinValueValidator(decimal.Decimal('0.01'))], + null=True + ) + document = models.FileField(upload_to=invoice_path, storage=PrivateStorage()) + requested_at = models.DateTimeField(auto_now_add=True) + message_for_pm = models.TextField(blank=True) + comment = models.TextField(blank=True) + status = models.TextField(choices=REQUEST_STATUS_CHOICES, default=SUBMITTED) + + objects = InvoiceQueryset.as_manager() + + def __str__(self): + return f'Invoice requested for {self.project}' + + @property + def has_changes_requested(self): + return self.status == CHANGES_REQUESTED + + @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 + + if user.is_apply_staff: + if self.status in {SUBMITTED}: + 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: + if self.status in {SUBMITTED}: + return True + + return False + + def can_user_change_status(self, user): + if not user.is_apply_staff: + return False # Users can't change status + + if self.status in {PAID, DECLINED}: + return False + + return True + + @property + def value(self): + return self.paid_value or self.amount + + def get_absolute_url(self): + return reverse('apply:projects:invoices:detail', args=[self.pk]) + + +class SupportingDocument(models.Model): + document = models.FileField( + upload_to="supporting_documents", storage=PrivateStorage() + ) + invoice = models.ForeignKey( + Invoice, + on_delete=models.CASCADE, + related_name='supporting_documents', + ) + + def __str__(self): + return self.invoice.name + ' -> ' + self.document.name + + 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") diff --git a/hypha/apply/projects/models/vendor.py b/hypha/apply/projects/models/vendor.py index 83b0523d7ef6bdd1e14b38535783fd7e38b0e998..95c070f93c5e3437cb2b97a4007e4f69f2ea44f7 100644 --- a/hypha/apply/projects/models/vendor.py +++ b/hypha/apply/projects/models/vendor.py @@ -1,5 +1,6 @@ from django.conf import settings from django.db import models +from django.urls import reverse from django.utils.translation import gettext_lazy as _ from wagtail.admin.edit_handlers import FieldPanel, MultiFieldPanel from wagtail.contrib.settings.models import BaseSetting, register_setting @@ -68,6 +69,9 @@ class Vendor(models.Model): def __str__(self): return self.name + def get_absolute_url(self): + return reverse('apply:projects:vendor-detail', args=[self.pk]) + class DueDiligenceDocument(models.Model): document = models.FileField( diff --git a/hypha/apply/projects/tables.py b/hypha/apply/projects/tables.py index 58c9deb093c3f98c2da08d33a697321ca060019b..836eae078afac5f0e6292c6c78362096c191ed18 100644 --- a/hypha/apply/projects/tables.py +++ b/hypha/apply/projects/tables.py @@ -7,7 +7,7 @@ from django.db.models import F, Sum from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ -from .models import PaymentRequest, Project, Report +from .models import Invoice, PaymentRequest, Project, Report class BasePaymentRequestsTable(tables.Table): @@ -70,6 +70,47 @@ class PaymentRequestsListTable(BasePaymentRequestsTable): return qs, True +class BaseInvoiceTable(tables.Table): + project = tables.LinkColumn( + 'funds:projects:invoices:detail', + verbose_name=_('Invoice reference'), + text=lambda r: textwrap.shorten(r.project.title, width=30, placeholder="..."), + args=[tables.utils.A('pk')], + ) + status = tables.Column() + requested_at = tables.DateColumn(verbose_name=_('Submitted')) + amount = tables.Column(verbose_name=_('Value ({currency})').format(currency=settings.CURRENCY_SYMBOL)) + + def render_amount(self, value): + return intcomma(value) + + +class InvoiceListTable(BaseInvoiceTable): + fund = tables.Column(verbose_name=_('Fund'), accessor='project.submission.page') + lead = tables.Column(verbose_name=_('Lead'), accessor='project.lead') + + class Meta: + fields = [ + 'requested_at', + 'project', + 'amount', + 'status', + 'lead', + 'fund', + ] + model = Invoice + orderable = True + order_by = ['-requested_at'] + attrs = {'class': 'payment-requests-table'} + + def order_value(self, qs, is_descending): + direction = '-' if is_descending else '' + + qs = qs.order_by(f'{direction}paid_value', f'{direction}amount') + + return qs, True + + class BaseProjectsTable(tables.Table): title = tables.LinkColumn( 'funds:projects:detail', diff --git a/hypha/apply/projects/templates/application_projects/includes/invoices.html b/hypha/apply/projects/templates/application_projects/includes/invoices.html new file mode 100644 index 0000000000000000000000000000000000000000..6b3ab7c7ca3f70f99d46676a95c6b76d9bb709aa --- /dev/null +++ b/hypha/apply/projects/templates/application_projects/includes/invoices.html @@ -0,0 +1,79 @@ +{% load invoice_tools humanize %} + +<div id="payment-requests" class="data-block"> + <div class="data-block__header"> + <p class="data-block__title">Invoice Requests</p> + <a class="data-block__button button button--primary" + href="{% url "apply:projects:invoice" pk=object.pk %}"> + Add Request + </a> + </div> + <div class="data-block__body"> + <table class="data-block__table"> + <thead> + <tr> + <th class="data-block__table-amount">Amount ({{ CURRENCY_SYMBOL }})</th> + <th class="data-block__table-status">Status</th> + <th class="data-block__table-date">From</th> + <th class="data-block__table-date">To</th> + <th class="data-block__table-update"></th> + </tr> + </thead> + <tbody> + {% for invoice in object.invoices.not_rejected %} + <tr> + <td><span class="data-block__mobile-label">Amount: </span>{{ invoice.amount|intcomma }}</td> + <td><span class="data-block__mobile-label">Status: </span>{{ invoice.get_status_display }}</td> + <td><span class="data-block__mobile-label">From: </span>{{ invoice.date_from.date }}</td> + <td><span class="data-block__mobile-label">To: </span>{{ invoice.date_to.date }}</td> + <td> + <a class="data-block__action-link" href="{{ invoice.get_absolute_url }}">View</a> + {% can_edit invoice user as user_can_edit_request %} + {% if user_can_edit_request %} + <a class="data-block__action-link" href="{% url "apply:projects:invoices:edit" pk=invoice.pk %}"> + Edit + </a> + {% endif %} + + {% can_delete invoice user as user_can_delete_request %} + {% if user_can_delete_request %} + <a class="data-block__action-link" href="{% url 'apply:projects:invoices:delete' pk=invoice.pk %}"> + Delete + </a> + {% endif %} + </td> + </tr> + {% empty %} + <tr> + <td colspan="5">No active Invoices.</td> + </tr> + {% endfor %} + </tbody> + </table> + + {% if object.invoices.rejected %} + <p class="data-block__rejected"> + <a class="data-block__rejected-link js-payment-block-rejected-link" href="#">Show rejected</a> + </p> + + <table class="data-block__table is-hidden js-payment-block-rejected-table"> + <thead> + <tr> + <th class="data-block__table-amount">Amount</th> + <th class="data-block__table-status">Status</th> + <th class="data-block__table-view"></th> + </tr> + </thead> + <tbody> + {% for invoice in object.invoices.rejected %} + <tr> + <td><span class="data-block__mobile-label">Amount: </span>{{ CURRENCY_SYMBOL }}{{ invoice.value }}</td> + <td><span class="data-block__mobile-label">Status: </span>{{ invoice.get_status_display }}</td> + <td><a href="{{ invoice.get_absolute_url }}">View</a></td> + </tr> + {% endfor %} + </tbody> + </table> + {% endif %} + </div> +</div> diff --git a/hypha/apply/projects/templates/application_projects/invoice_admin_detail.html b/hypha/apply/projects/templates/application_projects/invoice_admin_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..2d21b08eb1f0c754b12ea4fe636dec7a12b3d932 --- /dev/null +++ b/hypha/apply/projects/templates/application_projects/invoice_admin_detail.html @@ -0,0 +1,36 @@ +{% extends "application_projects/invoice_detail.html" %} +{% load static invoice_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_invoice_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/hypha/apply/projects/templates/application_projects/invoice_confirm_delete.html b/hypha/apply/projects/templates/application_projects/invoice_confirm_delete.html new file mode 100644 index 0000000000000000000000000000000000000000..3d5448fccc0213a937b69d88adc86a2320e5884b --- /dev/null +++ b/hypha/apply/projects/templates/application_projects/invoice_confirm_delete.html @@ -0,0 +1,37 @@ + +{% extends "base-apply.html" %} +{% load humanize invoice_tools %} + +{% block title %} Invoice: {{ object.project.title }}{% endblock %} +{% block content %} +<div class="admin-bar"> + <div class="admin-bar__inner"> + <a class="simplified__projects-link" href="{{ object.project.get_absolute_url }}"> + Project + </a> + <h2 class="heading heading--no-margin">Delete Invoice</h2> + <h5 class="heading heading--no-margin">For: {{ object.project.title }}</h5> + </div> +</div> + +<div class="wrapper wrapper--sidebar wrapper--outer-space-medium"> + <div class="wrapper--sidebar--inner"> + + <div class="card card--solid"> + <p class="card__text"><b>Status:</b> {{ object.get_status_display }}</p> + <p class="card__text"><b>Vendor:</b> {{ object.project.vendor.name }}</p> + <p class="card__text"><b>Invoice Number:</b> {{ object.pk }}</p> + <p class="card__text"><b>Period of Performance:</b> {{ object.date_from.date }} | {{ object.date_to.date }}</p> + <p class="card__text"><b>Total:</b> {{ CURRENCY_SYMBOL }}{{ object.amount|intcomma }}</p> + + </div> + <div class="card card--solid"> + <form method="post">{% csrf_token %} + <p>Are you sure you want to delete this invoice for {{ object.project.title }}?</p> + <button class="button button--primary" type="submit">Confirm</button> + </form> + + </div> + </div> +</div> +{% endblock %} diff --git a/hypha/apply/projects/templates/application_projects/invoice_detail.html b/hypha/apply/projects/templates/application_projects/invoice_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..741235d7000cba618c19d2856266a5a70c63e60f --- /dev/null +++ b/hypha/apply/projects/templates/application_projects/invoice_detail.html @@ -0,0 +1,66 @@ +{% extends "base-apply.html" %} +{% load humanize invoice_tools %} + +{% block title %}Invoice 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">Invoice Request</h2> + <h5 class="heading heading--no-margin">For: {{ object.project.title }}</h5> + </div> +</div> + +<div class="wrapper wrapper--sidebar wrapper--outer-space-medium"> + <div class="wrapper--sidebar--inner"> + <div class="card card--solid"> + <p class="card__text"><b>Status:</b> {{ object.get_status_display }}</p> + <p class="card__text"><b>Vendor:</b> {{ object.project.vendor.name }}</p> + <p class="card__text"><b>Invoice Number:</b> {{ object.pk }}</p> + <p class="card__text"><b>Period of Performance:</b> {{ object.date_from.date }} | {{ object.date_to.date }}</p> + <p class="card__text"><b>Total:</b> {{ CURRENCY_SYMBOL }}{{ object.amount|intcomma }}</p> + </div> + + <div class="card card--solid"> + <div class="card__inner"> + <h5 class="card__heading">Invoice</h5> + <p class="card__text"><a href="{% url "apply:projects:invoices:invoice-document" pk=object.pk %}">{{invoice.document.name}}</a></p> + </div> + <div class="card__inner"> + <h5 class="card__heading">Supporting Documents</h5> + {% for document in object.supporting_documents.all %} + <p class="card__text"><a href="{% url "apply:projects:invoices:supporting-document" pk=object.pk file_pk=document.pk %}">{{document.document.name}}</a></p> + {% endfor %} + </div> + </div> + </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:invoices:edit" pk=object.pk %}" + {% else %} + "#" + {% endif %} + > + Edit + </a> + {% can_delete object user as user_can_delete_request %} + {% if user_can_delete_request %} + <a + class="button button--bottom-space button--primary button--full-width" + href="{% url 'apply:projects:invoices:delete' pk=object.pk %}">Delete</a> + {% endif %} + {% endblock %} + </div> + </aside> +</div> +{% endblock %} diff --git a/hypha/apply/projects/templates/application_projects/invoice_form.html b/hypha/apply/projects/templates/application_projects/invoice_form.html new file mode 100644 index 0000000000000000000000000000000000000000..64a40ac0404bb8116d2e7781155a2031e7fc3d4d --- /dev/null +++ b/hypha/apply/projects/templates/application_projects/invoice_form.html @@ -0,0 +1,36 @@ +{% extends "base-apply.html" %} +{% load static %} + +{% block title %}{% if object %}Edit{% else %}Create{% endif %} Invoice: {% if object %}{{ object.project.title }}{% else %}{{ project.title }}{% endif %}{% endblock %} +{% block content %} +<div class="admin-bar"> + <div class="admin-bar__inner"> + <h2 class="heading heading--no-margin">{% if object %}Editing{% else %}Create{% endif %} Invoice</h2> + <h5 class="heading heading--no-margin">{% if object %}{{ object.project.title }}{% else %}For: {{ project.title }}{% endif %}</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 %} + <button class="button button--submit button--top-space button--primary" type="submit" name="save">Save</button> + </form> + </div> +</div> +{% endblock %} + +{% block extra_js %} +<script src="{% static 'js/apply/list-input-files.js' %}"></script> +{% endblock %} diff --git a/hypha/apply/projects/templates/application_projects/invoice_list.html b/hypha/apply/projects/templates/application_projects/invoice_list.html new file mode 100644 index 0000000000000000000000000000000000000000..c837ec5b28bf98b0f029c4b4ed622565c70593d2 --- /dev/null +++ b/hypha/apply/projects/templates/application_projects/invoice_list.html @@ -0,0 +1,38 @@ +{% extends "base-apply.html" %} + +{% load render_table from django_tables2 %} +{% load static %} + +{% block title %}Invoice Request{% endblock %} + +{% block content %} +<div class="admin-bar"> + <div class="admin-bar__inner wrapper--search"> + {% block page_header %} + <div> + <h1 class="gamma heading heading--no-margin heading--bold">All Invoice Requests</h1> + </div> + {% endblock %} + </div> +</div> + +<div class="wrapper wrapper--large wrapper--inner-space-medium"> + {% if table %} + {% include "funds/includes/table_filter_and_search.html" with filter_form=filter_form search_term=search_term use_search=True filter_action=filter_action use_batch_actions=True search_placeholder="invoice requests" %} + {% render_table table %} + {% else %} + <p>No Requests Available</p> + {% endif %} +</div> + +{% endblock content %} + +{% block extra_css %} + <link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}"> + {{ filter.form.media.css }} +{% endblock %} + +{% block extra_js %} + {{ filter.form.media.js }} + <script src="{% static 'js/apply/submission-filters.js' %}"></script> +{% endblock %} diff --git a/hypha/apply/projects/templates/application_projects/project_detail.html b/hypha/apply/projects/templates/application_projects/project_detail.html index fdce23c27a09ff58d3d8cb8b58d35670ca3ddd1a..66a5ce0825016f29d7940a8872f1729c21925b80 100644 --- a/hypha/apply/projects/templates/application_projects/project_detail.html +++ b/hypha/apply/projects/templates/application_projects/project_detail.html @@ -127,7 +127,7 @@ {% if object.can_request_funding %} <div class="wrapper wrapper--outer-space-large"> {% include "application_projects/includes/funding_block.html" %} - {% include "application_projects/includes/payment_requests.html" %} + {% include "application_projects/includes/invoices.html" %} </div> {% endif %} @@ -176,8 +176,8 @@ {% if object.can_request_funding %} <a class="button button--primary button--bottom-space button--full-width" - href="{% url "apply:projects:request" pk=object.pk %}"> - Add payment request + href="{% url "apply:projects:invoice" pk=object.pk %}"> + Add Invoice </a> {% endif %} @@ -192,7 +192,9 @@ <h5>Supporting Information</h5> <p><a class="link link--bold" href="{{ object.submission.get_absolute_url }}">Proposal</a></p> - + {% if project.vendor %} + <p><a class="link link--bold" href="{% url 'apply:projects:vendor-detail' pk=project.pk vendor_pk=project.vendor.pk %}">Contractor Setup Form</a></p> + {% endif %} {% if request.user.is_apply_staff %} <p><a class="link link--bold" href="{% url 'apply:projects:simplified' pk=project.pk %}">Approval form</a></p> {% endif %} diff --git a/hypha/apply/projects/templatetags/invoice_tools.py b/hypha/apply/projects/templatetags/invoice_tools.py new file mode 100644 index 0000000000000000000000000000000000000000..24e0abdb3d441ec41455741fcae0bde464fa30d6 --- /dev/null +++ b/hypha/apply/projects/templatetags/invoice_tools.py @@ -0,0 +1,36 @@ +import decimal + +from django import template + +register = template.Library() + + +@register.simple_tag +def can_change_status(invoice, user): + return invoice.can_user_change_status(user) + + +@register.simple_tag +def can_delete(invoice, user): + return invoice.can_user_delete(user) + + +@register.simple_tag +def can_edit(invoice, user): + return invoice.can_user_edit(user) + + +@register.simple_tag +def percentage(value, total): + 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 diff --git a/hypha/apply/projects/tests/factories.py b/hypha/apply/projects/tests/factories.py index 62ccf3fc653119ce1551ada118822b525867dac4..5c8bacdf5ab89a54ab055ff838647570aabd2766 100644 --- a/hypha/apply/projects/tests/factories.py +++ b/hypha/apply/projects/tests/factories.py @@ -12,7 +12,7 @@ from hypha.apply.stream_forms.testing.factories import ( ) from hypha.apply.users.tests.factories import StaffFactory, UserFactory -from ..models.payment import PaymentReceipt, PaymentRequest +from ..models.payment import Invoice, PaymentReceipt, PaymentRequest, SupportingDocument from ..models.project import ( COMPLETE, IN_PROGRESS, @@ -140,6 +140,20 @@ class PaymentRequestFactory(factory.DjangoModelFactory): model = PaymentRequest +class InvoiceFactory(factory.DjangoModelFactory): + project = factory.SubFactory(ProjectFactory) + by = factory.SubFactory(UserFactory) + amount = factory.Faker('pydecimal', min_value=1, max_value=10000000, right_digits=2) + + date_from = factory.Faker('date_time').generate({'tzinfo': pytz.utc}) + date_to = factory.Faker('date_time').generate({'tzinfo': pytz.utc}) + + document = factory.django.FileField() + + class Meta: + model = Invoice + + class PaymentReceiptFactory(factory.DjangoModelFactory): payment_request = factory.SubFactory(PaymentRequestFactory) @@ -149,6 +163,15 @@ class PaymentReceiptFactory(factory.DjangoModelFactory): model = PaymentReceipt +class SupportingDocumentFactory(factory.DjangoModelFactory): + invoice = factory.SubFactory(InvoiceFactory) + + document = factory.django.FileField() + + class Meta: + model = SupportingDocument + + class ReportConfigFactory(factory.DjangoModelFactory): project = factory.SubFactory( "hypha.apply.projects.tests.factories.ApprovedProjectFactory", diff --git a/hypha/apply/projects/tests/test_views.py b/hypha/apply/projects/tests/test_views.py index 5c29058b0858ccad111d32b99aeec3a98a3fde3f..013e86b1174e8ab687f251f8c4405014aea0081d 100644 --- a/hypha/apply/projects/tests/test_views.py +++ b/hypha/apply/projects/tests/test_views.py @@ -1,3 +1,4 @@ +import json from decimal import Decimal from io import BytesIO @@ -28,12 +29,12 @@ from ..views.project import ContractsMixin, ProjectDetailSimplifiedView from .factories import ( ContractFactory, DocumentCategoryFactory, + InvoiceFactory, PacketFileFactory, - PaymentReceiptFactory, - PaymentRequestFactory, ProjectFactory, ReportFactory, ReportVersionFactory, + SupportingDocumentFactory, ) @@ -916,9 +917,9 @@ class TestProjectDetailSimplifiedView(TestCase): ProjectDetailSimplifiedView.as_view()(request, pk=project.pk) -class TestStaffDetailPaymentRequestStatus(BaseViewTestCase): +class TestStaffDetailInvoiceStatus(BaseViewTestCase): base_view_name = 'detail' - url_name = 'funds:projects:payments:{}' + url_name = 'funds:projects:invoices:{}' user_factory = StaffFactory def get_kwargs(self, instance): @@ -927,20 +928,20 @@ class TestStaffDetailPaymentRequestStatus(BaseViewTestCase): } def test_can(self): - payment_request = PaymentRequestFactory() - response = self.get_page(payment_request) + invoice = InvoiceFactory() + response = self.get_page(invoice) 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}) + invoice = InvoiceFactory() + response = self.get_page(invoice, url_kwargs={'pk': other_project.pk}) self.assertEqual(response.status_code, 404) -class TestFinanceDetailPaymentRequestStatus(BaseViewTestCase): +class TestFinanceDetailInvoiceStatus(BaseViewTestCase): base_view_name = 'detail' - url_name = 'funds:projects:payments:{}' + url_name = 'funds:projects:invoices:{}' user_factory = FinanceFactory def get_kwargs(self, instance): @@ -949,20 +950,20 @@ class TestFinanceDetailPaymentRequestStatus(BaseViewTestCase): } def test_can(self): - payment_request = PaymentRequestFactory() - response = self.get_page(payment_request) + invoice = InvoiceFactory() + response = self.get_page(invoice) 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}) + invoice = InvoiceFactory() + response = self.get_page(invoice, url_kwargs={'pk': other_project.pk}) self.assertEqual(response.status_code, 404) -class TestApplicantDetailPaymentRequestStatus(BaseViewTestCase): +class TestApplicantDetailInvoiceStatus(BaseViewTestCase): base_view_name = 'detail' - url_name = 'funds:projects:payments:{}' + url_name = 'funds:projects:invoices:{}' user_factory = ApplicantFactory def get_kwargs(self, instance): @@ -971,125 +972,127 @@ class TestApplicantDetailPaymentRequestStatus(BaseViewTestCase): } def test_can(self): - payment_request = PaymentRequestFactory(project__user=self.user) - response = self.get_page(payment_request) + invoice = InvoiceFactory(project__user=self.user) + response = self.get_page(invoice) self.assertEqual(response.status_code, 200) def test_other_cant(self): - payment_request = PaymentRequestFactory() - response = self.get_page(payment_request) + invoice = InvoiceFactory() + response = self.get_page(invoice) self.assertEqual(response.status_code, 403) -class TestApplicantEditPaymentRequestView(BaseViewTestCase): +class TestApplicantEditInvoiceView(BaseViewTestCase): base_view_name = 'edit' - url_name = 'funds:projects:payments:{}' + url_name = 'funds:projects:invoices:{}' user_factory = ApplicantFactory def get_kwargs(self, instance): return {'pk': instance.pk} - def test_editing_payment_remove_receipt(self): - payment_request = PaymentRequestFactory(project__user=self.user) - receipt = PaymentReceiptFactory(payment_request=payment_request) + def test_editing_invoice_remove_supporting_document(self): + invoice = InvoiceFactory(project__user=self.user) + SupportingDocumentFactory(invoice=invoice) - response = self.post_page(payment_request, { - 'requested_value': payment_request.requested_value, + self.assertTrue(invoice.supporting_documents.exists()) + + response = self.post_page(invoice, { + 'amount': invoice.amount, 'date_from': '2018-08-15', 'date_to': '2019-08-15', 'comment': 'test comment', 'invoice': '', - 'receipt_list': [receipt.pk], + 'supporting_documents-uploads': '[]', }) self.assertEqual(response.status_code, 200) - - self.assertFalse(payment_request.receipts.exists()) + self.assertFalse(invoice.supporting_documents.exists()) def test_editing_payment_keeps_receipts(self): project = ProjectFactory(user=self.user) - payment_request = PaymentRequestFactory(project=project) - receipt = PaymentReceiptFactory(payment_request=payment_request) + invoice = InvoiceFactory(project=project) + supporting_document = SupportingDocumentFactory(invoice=invoice) - requested_value = payment_request.requested_value + amount = invoice.amount - response = self.post_page(payment_request, { - 'requested_value': requested_value + 1, + response = self.post_page(invoice, { + 'amount': amount + 1, 'date_from': '2018-08-15', 'date_to': '2019-08-15', 'comment': 'test comment', 'invoice': '', - 'receipt_list': [], + 'supporting_documents-uploads': json.dumps([{"name": supporting_document.document.name, "size": supporting_document.document.size, "type": "existing"}]), }) self.assertEqual(response.status_code, 200) - self.assertEqual(project.payment_requests.count(), 1) + self.assertEqual(project.invoices.count(), 1) - payment_request.refresh_from_db() + invoice.refresh_from_db() - self.assertEqual(project.payment_requests.first().pk, payment_request.pk) + self.assertEqual(project.invoices.first().pk, invoice.pk) - self.assertEqual(requested_value + Decimal("1"), payment_request.requested_value) - self.assertEqual(payment_request.receipts.first().file, receipt.file) + self.assertEqual(amount + Decimal("1"), invoice.amount) + self.assertEqual(invoice.supporting_documents.first().document, supporting_document.document) -class TestStaffEditPaymentRequestView(BaseViewTestCase): +class TestStaffEditInvoiceView(BaseViewTestCase): base_view_name = 'edit' - url_name = 'funds:projects:payments:{}' + url_name = 'funds:projects:invoices:{}' user_factory = StaffFactory def get_kwargs(self, instance): return {'pk': instance.pk} - def test_editing_payment_remove_receipt(self): - payment_request = PaymentRequestFactory() - receipt = PaymentReceiptFactory(payment_request=payment_request) + def test_editing_invoice_remove_supporting_document(self): + invoice = InvoiceFactory() + SupportingDocumentFactory(invoice=invoice) - response = self.post_page(payment_request, { - 'requested_value': payment_request.requested_value, + response = self.post_page(invoice, { + 'amount': invoice.amount, 'date_from': '2018-08-15', 'date_to': '2019-08-15', 'comment': 'test comment', 'invoice': '', - 'receipt_list': [receipt.pk], + 'supporting_documents-uploads': '[]', }) self.assertEqual(response.status_code, 200) - self.assertFalse(payment_request.receipts.exists()) + self.assertFalse(invoice.supporting_documents.exists()) - def test_editing_payment_keeps_receipts(self): + def test_editing_invoice_keeps_supprting_document(self): project = ProjectFactory() - payment_request = PaymentRequestFactory(project=project) - receipt = PaymentReceiptFactory(payment_request=payment_request) + invoice = InvoiceFactory(project=project) + supporting_document = SupportingDocumentFactory(invoice=invoice) - requested_value = payment_request.requested_value + amount = invoice.amount - invoice = BytesIO(b'somebinarydata') - invoice.name = 'invoice.pdf' + document = BytesIO(b'somebinarydata') + document.name = 'invoice.pdf' - response = self.post_page(payment_request, { - 'requested_value': requested_value + 1, + response = self.post_page(invoice, { + 'amount': amount + 1, 'date_from': '2018-08-15', 'date_to': '2019-08-15', 'comment': 'test comment', - 'invoice': invoice, - 'receipt_list': [receipt.pk], + 'document': document, + 'supporting_documents-uploads': json.dumps([{"name": supporting_document.document.name, "size": supporting_document.document.size, "type": "existing"}]), }) self.assertEqual(response.status_code, 200) - self.assertEqual(project.payment_requests.count(), 1) + self.assertEqual(project.invoices.count(), 1) - payment_request.refresh_from_db() + invoice.refresh_from_db() - self.assertEqual(project.payment_requests.first().pk, payment_request.pk) + self.assertEqual(project.invoices.first().pk, invoice.pk) - self.assertEqual(requested_value + Decimal("1"), payment_request.requested_value) + self.assertEqual(amount + Decimal("1"), invoice.amount) + self.assertEqual(invoice.supporting_documents.first().document, supporting_document.document) -class TestStaffChangePaymentRequestStatus(BaseViewTestCase): +class TestStaffChangeInvoiceStatus(BaseViewTestCase): base_view_name = 'detail' - url_name = 'funds:projects:payments:{}' + url_name = 'funds:projects:invoices:{}' user_factory = StaffFactory def get_kwargs(self, instance): @@ -1098,20 +1101,20 @@ class TestStaffChangePaymentRequestStatus(BaseViewTestCase): } def test_can(self): - payment_request = PaymentRequestFactory() - response = self.post_page(payment_request, { - 'form-submitted-change_payment_status': '', + invoice = InvoiceFactory() + response = self.post_page(invoice, { + 'form-submitted-change_invoice_status': '', 'status': CHANGES_REQUESTED, 'comment': 'this is a comment', }) self.assertEqual(response.status_code, 200) - payment_request.refresh_from_db() - self.assertEqual(payment_request.status, CHANGES_REQUESTED) + invoice.refresh_from_db() + self.assertEqual(invoice.status, CHANGES_REQUESTED) -class TestApplicantChangePaymentRequestStatus(BaseViewTestCase): +class TestApplicantChangeInoviceStatus(BaseViewTestCase): base_view_name = 'detail' - url_name = 'funds:projects:payments:{}' + url_name = 'funds:projects:invoices:{}' user_factory = ApplicantFactory def get_kwargs(self, instance): @@ -1120,29 +1123,29 @@ class TestApplicantChangePaymentRequestStatus(BaseViewTestCase): } def test_can(self): - payment_request = PaymentRequestFactory(project__user=self.user) - response = self.post_page(payment_request, { - 'form-submitted-change_payment_status': '', + invoice = InvoiceFactory(project__user=self.user) + response = self.post_page(invoice, { + 'form-submitted-change_invoice_status': '', 'status': CHANGES_REQUESTED, }) self.assertEqual(response.status_code, 200) - payment_request.refresh_from_db() - self.assertEqual(payment_request.status, SUBMITTED) + invoice.refresh_from_db() + self.assertEqual(invoice.status, SUBMITTED) def test_other_cant(self): - payment_request = PaymentRequestFactory() - response = self.post_page(payment_request, { - 'form-submitted-change_payment_status': '', + invoice = InvoiceFactory() + response = self.post_page(invoice, { + 'form-submitted-change_invoice_status': '', 'status': CHANGES_REQUESTED, }) self.assertEqual(response.status_code, 403) - payment_request.refresh_from_db() - self.assertEqual(payment_request.status, SUBMITTED) + invoice.refresh_from_db() + self.assertEqual(invoice.status, SUBMITTED) -class TestStaffPaymentRequestInvoicePrivateMedia(BaseViewTestCase): - base_view_name = 'invoice' - url_name = 'funds:projects:payments:{}' +class TestStaffInoviceDocumentPrivateMedia(BaseViewTestCase): + base_view_name = 'invoice-document' + url_name = 'funds:projects:invoices:{}' user_factory = StaffFactory def get_kwargs(self, instance): @@ -1151,20 +1154,20 @@ class TestStaffPaymentRequestInvoicePrivateMedia(BaseViewTestCase): } def test_can_access(self): - payment_request = PaymentRequestFactory() - response = self.get_page(payment_request) - self.assertContains(response, payment_request.invoice.read()) + invoice = InvoiceFactory() + response = self.get_page(invoice) + self.assertContains(response, invoice.document.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}) + invoice = InvoiceFactory() + response = self.get_page(invoice, url_kwargs={'pk': other_project.pk}) self.assertEqual(response.status_code, 404) -class TestApplicantPaymentRequestInvoicePrivateMedia(BaseViewTestCase): - base_view_name = 'invoice' - url_name = 'funds:projects:payments:{}' +class TestApplicantInvoiceDocumentPrivateMedia(BaseViewTestCase): + base_view_name = 'invoice-document' + url_name = 'funds:projects:invoices:{}' user_factory = ApplicantFactory def get_kwargs(self, instance): @@ -1173,52 +1176,52 @@ class TestApplicantPaymentRequestInvoicePrivateMedia(BaseViewTestCase): } 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()) + invoice = InvoiceFactory(project__user=self.user) + response = self.get_page(invoice) + self.assertContains(response, invoice.document.read()) def test_cant_access_other(self): - payment_request = PaymentRequestFactory() - response = self.get_page(payment_request) + invoice = InvoiceFactory() + response = self.get_page(invoice) self.assertEqual(response.status_code, 403) -class TestStaffPaymentRequestReceiptPrivateMedia(BaseViewTestCase): - base_view_name = 'receipt' - url_name = 'funds:projects:payments:{}' +class TestStaffInvoiceSupportingDocumentPrivateMedia(BaseViewTestCase): + base_view_name = 'supporting-document' + url_name = 'funds:projects:invoices:{}' user_factory = StaffFactory def get_kwargs(self, instance): return { - 'pk': instance.payment_request.pk, + 'pk': instance.invoice.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()) + supporting_document = SupportingDocumentFactory() + response = self.get_page(supporting_document) + self.assertContains(response, supporting_document.document.read()) -class TestApplicantPaymentRequestReceiptPrivateMedia(BaseViewTestCase): - base_view_name = 'receipt' - url_name = 'funds:projects:payments:{}' +class TestApplicantSupportingDocumentPrivateMedia(BaseViewTestCase): + base_view_name = 'supporting-document' + url_name = 'funds:projects:invoices:{}' user_factory = ApplicantFactory def get_kwargs(self, instance): return { - 'pk': instance.payment_request.pk, + 'pk': instance.invoice.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()) + supporting_document = SupportingDocumentFactory(invoice__project__user=self.user) + response = self.get_page(supporting_document) + self.assertContains(response, supporting_document.document.read()) def test_cant_access_other(self): - payment_receipt = PaymentReceiptFactory() - response = self.get_page(payment_receipt) + supporting_document = SupportingDocumentFactory() + response = self.get_page(supporting_document) self.assertEqual(response.status_code, 403) diff --git a/hypha/apply/projects/urls.py b/hypha/apply/projects/urls.py index 35b4a9eccfe1f9f9f1341faba814667947510b58..0011fb3ad2bc27087d0b123ced53f95a10079dd3 100644 --- a/hypha/apply/projects/urls.py +++ b/hypha/apply/projects/urls.py @@ -2,10 +2,16 @@ from django.urls import include, path from .views import ( ContractPrivateMediaView, + CreateInvoiceView, CreatePaymentRequestView, CreateVendorView, + DeleteInvoiceView, DeletePaymentRequestView, + EditInvoiceView, EditPaymentRequestView, + InvoiceListView, + InvoicePrivateMedia, + InvoiceView, PaymentRequestListView, PaymentRequestPrivateMedia, PaymentRequestView, @@ -38,6 +44,7 @@ urlpatterns = [ path('download/', ProjectDetailPDFView.as_view(), name='download'), path('simplified/', ProjectDetailSimplifiedView.as_view(), name='simplified'), path('request/', CreatePaymentRequestView.as_view(), name='request'), + path('invoice/', CreateInvoiceView.as_view(), name='invoice'), path('vendor/', CreateVendorView.as_view(), name='vendor'), path('vendor/<int:vendor_pk>/', VendorDetailView.as_view(), name='vendor-detail'), path('vendor/<int:vendor_pk>/documents/<int:file_pk>/', VendorPrivateMediaView.as_view(), name='vendor-documents'), @@ -52,6 +59,16 @@ urlpatterns = [ path('documents/receipt/<int:file_pk>/', PaymentRequestPrivateMedia.as_view(), name="receipt"), ])), ], 'payments'))), + path('invoices/', include(([ + path('', InvoiceListView.as_view(), name='all'), + path('<int:pk>/', include([ + path('', InvoiceView.as_view(), name='detail'), + path('edit/', EditInvoiceView.as_view(), name='edit'), + path('delete/', DeleteInvoiceView.as_view(), name='delete'), + path('documents/invoice/', InvoicePrivateMedia.as_view(), name="invoice-document"), + path('documents/supporting/<int:file_pk>/', InvoicePrivateMedia.as_view(), name="supporting-document"), + ])), + ], 'invoices'))), path('reports/', include(([ path('', ReportListView.as_view(), name='all'), path('<int:pk>/', include([ diff --git a/hypha/apply/projects/views/__init__.py b/hypha/apply/projects/views/__init__.py index 61a184d6d290b93f85e964e2d6820ecffb46a609..ee128ddce8e7315a59764417bc0f87db3b636f11 100644 --- a/hypha/apply/projects/views/__init__.py +++ b/hypha/apply/projects/views/__init__.py @@ -1,8 +1,15 @@ from .payment import ( + ChangeInvoiceStatusView, ChangePaymentRequestStatusView, + CreateInvoiceView, CreatePaymentRequestView, + DeleteInvoiceView, DeletePaymentRequestView, + EditInvoiceView, EditPaymentRequestView, + InvoiceListView, + InvoicePrivateMedia, + InvoiceView, PaymentRequestAdminView, PaymentRequestApplicantView, PaymentRequestListView, @@ -44,6 +51,7 @@ from .report import ( from .vendor import CreateVendorView, VendorDetailView, VendorPrivateMediaView __all__ = [ + 'ChangeInvoiceStatusView', 'ChangePaymentRequestStatusView', 'DeletePaymentRequestView', 'PaymentRequestAdminView', @@ -84,4 +92,10 @@ __all__ = [ 'CreateVendorView', 'VendorDetailView', 'VendorPrivateMediaView', + 'CreateInvoiceView', + 'InvoiceListView', + 'InvoiceView', + 'EditInvoiceView', + 'DeleteInvoiceView', + 'InvoicePrivateMedia', ] diff --git a/hypha/apply/projects/views/payment.py b/hypha/apply/projects/views/payment.py index 7016ff8b8959c20e36049783dadbd5257836a2be..950abc54c67781e329ff688faf971c944e167380 100644 --- a/hypha/apply/projects/views/payment.py +++ b/hypha/apply/projects/views/payment.py @@ -13,15 +13,18 @@ from hypha.apply.users.decorators import staff_or_finance_required from hypha.apply.utils.storage import PrivateMediaView from hypha.apply.utils.views import DelegateableView, DelegatedViewMixin, ViewDispatcher -from ..filters import PaymentRequestListFilter +from ..filters import InvoiceListFilter, PaymentRequestListFilter from ..forms import ( + ChangeInvoiceStatusForm, ChangePaymentRequestStatusForm, + CreateInvoiceForm, CreatePaymentRequestForm, + EditInvoiceForm, EditPaymentRequestForm, ) -from ..models.payment import PaymentRequest +from ..models.payment import Invoice, PaymentRequest from ..models.project import Project -from ..tables import PaymentRequestsListTable +from ..tables import InvoiceListTable, PaymentRequestsListTable @method_decorator(login_required, name='dispatch') @@ -41,6 +44,23 @@ class PaymentRequestAccessMixin(UserPassesTestMixin): return False +@method_decorator(login_required, name='dispatch') +class InvoiceAccessMixin(UserPassesTestMixin): + model = Invoice + + def test_func(self): + if self.request.user.is_apply_staff: + return True + + if self.request.user.is_finance: + return True + + if self.request.user == self.get_object().project.user: + return True + + return False + + @method_decorator(staff_or_finance_required, name='dispatch') class ChangePaymentRequestStatusView(DelegatedViewMixin, PaymentRequestAccessMixin, UpdateView): form_class = ChangePaymentRequestStatusForm @@ -65,6 +85,30 @@ class ChangePaymentRequestStatusView(DelegatedViewMixin, PaymentRequestAccessMix return response +@method_decorator(staff_or_finance_required, name='dispatch') +class ChangeInvoiceStatusView(DelegatedViewMixin, InvoiceAccessMixin, UpdateView): + form_class = ChangeInvoiceStatusForm + context_name = 'change_invoice_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_INVOICE_STATUS, + request=self.request, + user=self.request.user, + source=self.object.project, + related=self.object, + ) + + return response + + class DeletePaymentRequestView(DeleteView): model = PaymentRequest @@ -93,6 +137,34 @@ class DeletePaymentRequestView(DeleteView): return self.project.get_absolute_url() +class DeleteInvoiceView(DeleteView): + model = Invoice + + def dispatch(self, request, *args, **kwargs): + self.object = self.get_object() + if not self.object.can_user_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_INVOICE, + request=self.request, + user=self.request.user, + source=self.object.project, + related=self.object.project, + ) + + return response + + def get_success_url(self): + return self.object.project.get_absolute_url() + + class PaymentRequestAdminView(PaymentRequestAccessMixin, DelegateableView, DetailView): form_views = [ ChangePaymentRequestStatusView @@ -110,6 +182,23 @@ class PaymentRequestView(ViewDispatcher): applicant_view = PaymentRequestApplicantView +class InvoiceAdminView(InvoiceAccessMixin, DelegateableView, DetailView): + form_views = [ + ChangeInvoiceStatusView + ] + template_name_suffix = '_admin_detail' + + +class InvoiceApplicantView(InvoiceAccessMixin, DelegateableView, DetailView): + form_views = [] + + +class InvoiceView(ViewDispatcher): + admin_view = InvoiceAdminView + finance_view = InvoiceAdminView + applicant_view = InvoiceApplicantView + + class CreatePaymentRequestView(CreateView): model = PaymentRequest form_class = CreatePaymentRequestForm @@ -143,6 +232,39 @@ class CreatePaymentRequestView(CreateView): return response +class CreateInvoiceView(CreateView): + model = Invoice + form_class = CreateInvoiceForm + + def dispatch(self, request, *args, **kwargs): + self.project = Project.objects.get(pk=kwargs['pk']) + if not request.user.is_apply_staff and not self.project.user == request.user: + return redirect(self.project) + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + return super().get_context_data(project=self.project, **kwargs) + + def form_valid(self, form): + form.instance.project = self.project + form.instance.by = self.request.user + + response = super().form_valid(form) + + messenger( + MESSAGES.CREATE_INVOICE, + request=self.request, + user=self.request.user, + source=self.project, + related=self.object, + ) + + # Required for django-file-form: delete temporary files for the new files + # that are uploaded. + form.delete_temporary_files() + return response + + class EditPaymentRequestView(PaymentRequestAccessMixin, UpdateView): form_class = EditPaymentRequestForm @@ -169,6 +291,40 @@ class EditPaymentRequestView(PaymentRequestAccessMixin, UpdateView): return response +class EditInvoiceView(InvoiceAccessMixin, UpdateView): + form_class = EditInvoiceForm + + def dispatch(self, request, *args, **kwargs): + invoice = self.get_object() + if not invoice.can_user_edit(request.user): + return redirect(invoice) + return super().dispatch(request, *args, **kwargs) + + def get_initial(self): + initial = super().get_initial() + + initial["supporting_documents"] = [ + document.document for document in self.object.supporting_documents.all() + ] + return initial + + def form_valid(self, form): + response = super().form_valid(form) + + messenger( + MESSAGES.UPDATE_INVOICE, + request=self.request, + user=self.request.user, + source=self.object.project, + related=self.object, + ) + + # Required for django-file-form: delete temporary files for the new files + # that are uploaded. + form.delete_temporary_files() + return response + + @method_decorator(login_required, name='dispatch') class PaymentRequestPrivateMedia(UserPassesTestMixin, PrivateMediaView): raise_exception = True @@ -200,6 +356,37 @@ class PaymentRequestPrivateMedia(UserPassesTestMixin, PrivateMediaView): return False +@method_decorator(login_required, name='dispatch') +class InvoicePrivateMedia(UserPassesTestMixin, PrivateMediaView): + raise_exception = True + + def dispatch(self, *args, **kwargs): + invoice_pk = self.kwargs['pk'] + self.invoice = get_object_or_404(Invoice, pk=invoice_pk) + + return super().dispatch(*args, **kwargs) + + def get_media(self, *args, **kwargs): + file_pk = kwargs.get('file_pk') + if not file_pk: + return self.invoice.document + + document = get_object_or_404(self.invoice.supporting_documents, pk=file_pk) + return document.document + + def test_func(self): + if self.request.user.is_apply_staff: + return True + + if self.request.user.is_finance: + return True + + if self.request.user == self.invoice.project.user: + return True + + return False + + @method_decorator(staff_or_finance_required, name='dispatch') class PaymentRequestListView(SingleTableMixin, FilterView): filterset_class = PaymentRequestListFilter @@ -209,3 +396,14 @@ class PaymentRequestListView(SingleTableMixin, FilterView): def get_queryset(self): return PaymentRequest.objects.order_by('date_to') + + +@method_decorator(staff_or_finance_required, name='dispatch') +class InvoiceListView(SingleTableMixin, FilterView): + filterset_class = InvoiceListFilter + model = Invoice + table_class = InvoiceListTable + template_name = 'application_projects/invoice_list.html' + + def get_queryset(self): + return Invoice.objects.order_by('date_to') diff --git a/hypha/locale/en/LC_MESSAGES/django.po b/hypha/locale/en/LC_MESSAGES/django.po index 0694fc5b73a32170269c6d9c501c5bd5d3e37aee..3ee745a91a5d1231efcbfde99a456d40ccbb6292 100644 --- a/hypha/locale/en/LC_MESSAGES/django.po +++ b/hypha/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-07-08 06:43+0000\n" +"POT-Creation-Date: 2021-07-28 07:03+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" @@ -23,413 +23,444 @@ msgstr "" msgid " as {role}" msgstr "" -#: hypha/apply/activity/messaging.py:224 +#: hypha/apply/activity/messaging.py:228 #, python-brace-format msgid "Submitted {source.title} for {source.page.title}" msgstr "" -#: hypha/apply/activity/messaging.py:225 hypha/apply/activity/messaging.py:226 +#: hypha/apply/activity/messaging.py:229 hypha/apply/activity/messaging.py:230 msgid "Edited" msgstr "" -#: hypha/apply/activity/messaging.py:227 hypha/apply/activity/messaging.py:241 +#: hypha/apply/activity/messaging.py:231 hypha/apply/activity/messaging.py:245 #, python-brace-format msgid "Lead changed from {old_lead} to {source.lead}" msgstr "" -#: hypha/apply/activity/messaging.py:228 +#: hypha/apply/activity/messaging.py:232 #, python-brace-format msgid "Batch Lead changed to {new_lead}" msgstr "" -#: hypha/apply/activity/messaging.py:229 +#: hypha/apply/activity/messaging.py:233 #, python-brace-format msgid "Sent a determination. Outcome: {determination.clean_outcome}" msgstr "" -#: hypha/apply/activity/messaging.py:231 +#: hypha/apply/activity/messaging.py:235 msgid "Invited to submit a proposal" msgstr "" -#: hypha/apply/activity/messaging.py:235 +#: hypha/apply/activity/messaging.py:239 msgid "Submitted a review" msgstr "" -#: hypha/apply/activity/messaging.py:236 +#: hypha/apply/activity/messaging.py:240 msgid "Opened the submission while still sealed" msgstr "" -#: hypha/apply/activity/messaging.py:238 +#: hypha/apply/activity/messaging.py:242 #, python-brace-format msgid "" "{user} {opinion.opinion_display}s with {opinion.review.author}s review of " "{source}" msgstr "" -#: hypha/apply/activity/messaging.py:239 +#: hypha/apply/activity/messaging.py:243 msgid "Created" msgstr "" -#: hypha/apply/activity/messaging.py:240 +#: hypha/apply/activity/messaging.py:244 #, python-brace-format msgid "Progressed from {old_stage} to {source.status_display}" msgstr "" -#: hypha/apply/activity/messaging.py:242 +#: hypha/apply/activity/messaging.py:246 msgid "Requested approval" msgstr "" -#: hypha/apply/activity/messaging.py:243 +#: hypha/apply/activity/messaging.py:247 #: hypha/apply/determinations/options.py:12 msgid "Approved" msgstr "" -#: hypha/apply/activity/messaging.py:244 +#: hypha/apply/activity/messaging.py:248 #, python-brace-format msgid "Requested changes for acceptance: \"{comment}\"" msgstr "" -#: hypha/apply/activity/messaging.py:245 +#: hypha/apply/activity/messaging.py:249 #, python-brace-format msgid "Uploaded a {contract.state} contract" msgstr "" -#: hypha/apply/activity/messaging.py:246 +#: hypha/apply/activity/messaging.py:250 msgid "Approved contract" msgstr "" -#: hypha/apply/activity/messaging.py:247 +#: hypha/apply/activity/messaging.py:251 #, python-brace-format msgid "Updated Payment Request status to: {payment_request.status_display}" msgstr "" -#: hypha/apply/activity/messaging.py:248 +#: hypha/apply/activity/messaging.py:252 +#, python-brace-format +msgid "Updated Invoice status to: {invoice.status_display}" +msgstr "" + +#: hypha/apply/activity/messaging.py:253 msgid "Payment Request submitted" msgstr "" -#: hypha/apply/activity/messaging.py:249 +#: hypha/apply/activity/messaging.py:254 +msgid "Invoice created" +msgstr "" + +#: hypha/apply/activity/messaging.py:255 msgid "Submitted a report" msgstr "" -#: hypha/apply/activity/messaging.py:279 +#: hypha/apply/activity/messaging.py:285 msgid "Reviewers updated." msgstr "" -#: hypha/apply/activity/messaging.py:281 hypha/apply/activity/messaging.py:351 -#: hypha/apply/activity/messaging.py:508 +#: hypha/apply/activity/messaging.py:287 hypha/apply/activity/messaging.py:357 +#: hypha/apply/activity/messaging.py:518 msgid "Added:" msgstr "" -#: hypha/apply/activity/messaging.py:285 hypha/apply/activity/messaging.py:355 -#: hypha/apply/activity/messaging.py:512 +#: hypha/apply/activity/messaging.py:291 hypha/apply/activity/messaging.py:361 +#: hypha/apply/activity/messaging.py:522 msgid "Removed:" msgstr "" -#: hypha/apply/activity/messaging.py:291 +#: hypha/apply/activity/messaging.py:297 msgid "Batch Reviewers Updated." msgstr "" -#: hypha/apply/activity/messaging.py:293 +#: hypha/apply/activity/messaging.py:299 #, python-brace-format msgid "{user} as {name}." msgstr "" -#: hypha/apply/activity/messaging.py:311 +#: hypha/apply/activity/messaging.py:317 #, python-brace-format msgid "Successfully deleted submissions: {title}" msgstr "" -#: hypha/apply/activity/messaging.py:315 +#: hypha/apply/activity/messaging.py:321 #, python-brace-format msgid "Progressed from {old_display} to {new_display}" msgstr "" -#: hypha/apply/activity/messaging.py:349 +#: hypha/apply/activity/messaging.py:355 msgid "Partners updated." msgstr "" -#: hypha/apply/activity/messaging.py:362 +#: hypha/apply/activity/messaging.py:368 #, python-brace-format msgid "" "Updated reporting frequency. New schedule is: {new_schedule} starting on " "{schedule_start}" msgstr "" -#: hypha/apply/activity/messaging.py:400 +#: hypha/apply/activity/messaging.py:406 #, python-brace-format msgid "Screening status from {old_status} to {new_status}" msgstr "" -#: hypha/apply/activity/messaging.py:407 +#: hypha/apply/activity/messaging.py:413 #, python-brace-format msgid "" "A new submission has been submitted for {source.page.title}: <{link}|{source." "title}>" msgstr "" -#: hypha/apply/activity/messaging.py:408 +#: hypha/apply/activity/messaging.py:414 #, python-brace-format msgid "" "The lead of <{link}|{source.title}> has been updated from {old_lead} to " "{source.lead} by {user}" msgstr "" -#: hypha/apply/activity/messaging.py:410 +#: hypha/apply/activity/messaging.py:416 #, python-brace-format msgid "" "A new {comment.visibility} comment has been posted on <{link}|{source.title}" "> by {user}" msgstr "" -#: hypha/apply/activity/messaging.py:411 hypha/apply/activity/messaging.py:412 +#: hypha/apply/activity/messaging.py:417 hypha/apply/activity/messaging.py:418 #, python-brace-format msgid "{user} has edited <{link}|{source.title}>" msgstr "" -#: hypha/apply/activity/messaging.py:415 +#: hypha/apply/activity/messaging.py:421 #, python-brace-format msgid "{user} has updated the partners on <{link}|{source.title}>" msgstr "" -#: hypha/apply/activity/messaging.py:416 +#: hypha/apply/activity/messaging.py:422 #, python-brace-format msgid "" "{user} has updated the status of <{link}|{source.title}>: {old_phase." "display_name} → {source.phase}" msgstr "" -#: hypha/apply/activity/messaging.py:420 +#: hypha/apply/activity/messaging.py:426 #, python-brace-format msgid "A proposal has been submitted for review: <{link}|{source.title}>" msgstr "" -#: hypha/apply/activity/messaging.py:421 +#: hypha/apply/activity/messaging.py:427 #, python-brace-format msgid "" "<{link}|{source.title}> by {source.user} has been invited to submit a " "proposal" msgstr "" -#: hypha/apply/activity/messaging.py:422 +#: hypha/apply/activity/messaging.py:428 #, python-brace-format msgid "" "{user} has submitted a review for <{link}|{source.title}>. Outcome: {review." "outcome}, Score: {review.get_score_display}" msgstr "" -#: hypha/apply/activity/messaging.py:424 +#: hypha/apply/activity/messaging.py:430 #, python-brace-format msgid "{user} has opened the sealed submission: <{link}|{source.title}>" msgstr "" -#: hypha/apply/activity/messaging.py:425 +#: hypha/apply/activity/messaging.py:431 #, python-brace-format msgid "" "{user} {opinion.opinion_display}s with {opinion.review.author}s review of " "{source.title}" msgstr "" -#: hypha/apply/activity/messaging.py:427 +#: hypha/apply/activity/messaging.py:433 #, python-brace-format msgid "{user} has deleted {source.title}" msgstr "" -#: hypha/apply/activity/messaging.py:428 +#: hypha/apply/activity/messaging.py:434 #, python-brace-format msgid "{user} has deleted {review.author} review for <{link}|{source.title}>." msgstr "" -#: hypha/apply/activity/messaging.py:429 +#: hypha/apply/activity/messaging.py:435 #, python-brace-format msgid "{user} has created a Project: <{link}|{source.title}>." msgstr "" -#: hypha/apply/activity/messaging.py:430 +#: hypha/apply/activity/messaging.py:436 #, python-brace-format msgid "" "The lead of project <{link}|{source.title}> has been updated from {old_lead} " "to {source.lead} by {user}" msgstr "" -#: hypha/apply/activity/messaging.py:431 +#: hypha/apply/activity/messaging.py:437 #, python-brace-format msgid "{user} has edited {review.author} review for <{link}|{source.title}>." msgstr "" -#: hypha/apply/activity/messaging.py:432 +#: hypha/apply/activity/messaging.py:438 #, python-brace-format msgid "{user} has requested approval on project <{link}|{source.title}>." msgstr "" -#: hypha/apply/activity/messaging.py:433 +#: hypha/apply/activity/messaging.py:439 #, python-brace-format msgid "{user} has approved project <{link}|{source.title}>." msgstr "" -#: hypha/apply/activity/messaging.py:434 +#: hypha/apply/activity/messaging.py:440 #, python-brace-format msgid "" "{user} has requested changes for project acceptance on <{link}|{source.title}" ">." msgstr "" -#: hypha/apply/activity/messaging.py:435 +#: hypha/apply/activity/messaging.py:441 #, python-brace-format msgid "{user} has uploaded a contract for <{link}|{source.title}>." msgstr "" -#: hypha/apply/activity/messaging.py:436 +#: hypha/apply/activity/messaging.py:442 #, python-brace-format msgid "{user} has approved contract for <{link}|{source.title}>." msgstr "" -#: hypha/apply/activity/messaging.py:437 +#: hypha/apply/activity/messaging.py:443 #, python-brace-format msgid "{user} has requested payment for <{link}|{source.title}>." msgstr "" -#: hypha/apply/activity/messaging.py:438 +#: hypha/apply/activity/messaging.py:444 +#, python-brace-format +msgid "{user} has created invoice for <{link}|{source.title}>." +msgstr "" + +#: hypha/apply/activity/messaging.py:445 #, python-brace-format msgid "" "{user} has changed the status of <{link_related}|payment request> on <{link}|" "{source.title}> to {payment_request.status_display}." msgstr "" -#: hypha/apply/activity/messaging.py:439 +#: hypha/apply/activity/messaging.py:446 +#, python-brace-format +msgid "" +"{user} has changed the status of <{link_related}|invoice> on <{link}|{source." +"title}> to {invoice.status_display}." +msgstr "" + +#: hypha/apply/activity/messaging.py:447 #, python-brace-format msgid "{user} has deleted payment request from <{link}|{source.title}>." msgstr "" -#: hypha/apply/activity/messaging.py:440 +#: hypha/apply/activity/messaging.py:448 +#, python-brace-format +msgid "{user} has deleted invoice from <{link}|{source.title}>." +msgstr "" + +#: hypha/apply/activity/messaging.py:449 #, python-brace-format msgid "{user} has updated payment request for <{link}|{source.title}>." msgstr "" -#: hypha/apply/activity/messaging.py:441 +#: hypha/apply/activity/messaging.py:450 +#, python-brace-format +msgid "{user} has updated invoice for <{link}|{source.title}>." +msgstr "" + +#: hypha/apply/activity/messaging.py:451 #, python-brace-format msgid "{user} has submitted a report for <{link}|{source.title}>." msgstr "" -#: hypha/apply/activity/messaging.py:505 +#: hypha/apply/activity/messaging.py:515 #, python-brace-format msgid "{user} has updated the reviewers on <{link}|{title}>." msgstr "" -#: hypha/apply/activity/messaging.py:521 +#: hypha/apply/activity/messaging.py:531 #, python-brace-format msgid "{user} has batch changed lead to {new_lead} on: {submissions_text}" msgstr "" -#: hypha/apply/activity/messaging.py:532 hypha/apply/activity/messaging.py:947 +#: hypha/apply/activity/messaging.py:542 hypha/apply/activity/messaging.py:972 #, python-brace-format msgid "{user} as {name}," msgstr "" -#: hypha/apply/activity/messaging.py:537 +#: hypha/apply/activity/messaging.py:547 #, python-brace-format msgid "" "{user} has batch added {reviewers_text} as reviewers on: {submissions_text}" msgstr "" -#: hypha/apply/activity/messaging.py:555 +#: hypha/apply/activity/messaging.py:565 #, python-brace-format msgid "{user} has transitioned the following submissions: {submissions_links}" msgstr "" -#: hypha/apply/activity/messaging.py:565 +#: hypha/apply/activity/messaging.py:575 #, python-brace-format msgid "" "A determination for <{link}|{submission_title}> was sent by email. Outcome: " "{determination_outcome}" msgstr "" -#: hypha/apply/activity/messaging.py:572 +#: hypha/apply/activity/messaging.py:582 #, python-brace-format msgid "" "A determination for <{link}|{submission_title}> was saved without sending an " "email. Outcome: {determination_outcome}" msgstr "" -#: hypha/apply/activity/messaging.py:589 +#: hypha/apply/activity/messaging.py:599 #, python-brace-format msgid "Determinations of {outcome} was sent for: {submissions_links}" msgstr "" -#: hypha/apply/activity/messaging.py:598 +#: hypha/apply/activity/messaging.py:608 #, python-brace-format msgid "{user} has deleted submissions: {title}" msgstr "" -#: hypha/apply/activity/messaging.py:612 +#: hypha/apply/activity/messaging.py:622 #, python-brace-format msgid "" "<{link}|{title}> is ready for review. The following are assigned as " "reviewers: {reviewers}" msgstr "" -#: hypha/apply/activity/messaging.py:725 +#: hypha/apply/activity/messaging.py:737 #, python-brace-format msgid "Application ready to review: {source.title}" msgstr "" -#: hypha/apply/activity/messaging.py:727 +#: hypha/apply/activity/messaging.py:739 msgid "Multiple applications are now ready for your review" msgstr "" -#: hypha/apply/activity/messaging.py:729 +#: hypha/apply/activity/messaging.py:741 #, python-brace-format msgid "Reminder: Application ready to review: {source.title}" msgstr "" -#: hypha/apply/activity/messaging.py:732 +#: hypha/apply/activity/messaging.py:744 #, python-brace-format msgid "Your application to {org_long_name}: {source.title}" msgstr "" -#: hypha/apply/activity/messaging.py:734 +#: hypha/apply/activity/messaging.py:746 #, python-brace-format msgid "Your {org_long_name} Project: {source.title}" msgstr "" -#: hypha/apply/activity/messaging.py:937 +#: hypha/apply/activity/messaging.py:962 msgid "Successfully uploaded document" msgstr "" -#: hypha/apply/activity/messaging.py:938 +#: hypha/apply/activity/messaging.py:963 msgid "Successfully removed document" msgstr "" -#: hypha/apply/activity/messaging.py:941 +#: hypha/apply/activity/messaging.py:966 msgid "Reminder created" msgstr "" -#: hypha/apply/activity/messaging.py:942 +#: hypha/apply/activity/messaging.py:967 msgid "Reminder deleted" msgstr "" -#: hypha/apply/activity/messaging.py:953 +#: hypha/apply/activity/messaging.py:978 #, python-brace-format msgid "Batch reviewers added: {reviewers_text} to " msgstr "" -#: hypha/apply/activity/messaging.py:958 +#: hypha/apply/activity/messaging.py:983 #, python-brace-format msgid "" "Successfully updated reporting frequency. They will now report " "{new_schedule} starting on {schedule_start}" msgstr "" -#: hypha/apply/activity/messaging.py:962 +#: hypha/apply/activity/messaging.py:987 #, python-brace-format msgid "Successfully skipped a Report for {start_date} to {end_date}" msgstr "" -#: hypha/apply/activity/messaging.py:964 +#: hypha/apply/activity/messaging.py:989 #, python-brace-format msgid "Successfully unskipped a Report for {start_date} to {end_date}" msgstr "" -#: hypha/apply/activity/messaging.py:983 +#: hypha/apply/activity/messaging.py:1008 #, python-brace-format msgid "Successfully determined as {outcome}: " msgstr "" @@ -487,6 +518,8 @@ msgstr "" #: hypha/apply/activity/templates/messages/email/batch_ready_to_review.html:10 #: hypha/apply/activity/templates/messages/email/contract_uploaded.html:7 +#: hypha/apply/activity/templates/messages/email/invoice_status_updated.html:14 +#: hypha/apply/activity/templates/messages/email/invoice_updated.html:9 #: hypha/apply/activity/templates/messages/email/partners_update_applicant.html:9 #: hypha/apply/activity/templates/messages/email/partners_update_partner.html:9 #: hypha/apply/activity/templates/messages/email/payment_request_status_updated.html:14 @@ -500,6 +533,8 @@ msgstr "" #: hypha/apply/activity/templates/messages/email/batch_ready_to_review.html:11 #: hypha/apply/activity/templates/messages/email/contract_uploaded.html:8 +#: hypha/apply/activity/templates/messages/email/invoice_status_updated.html:15 +#: hypha/apply/activity/templates/messages/email/invoice_updated.html:10 #: hypha/apply/activity/templates/messages/email/partners_update_applicant.html:10 #: hypha/apply/activity/templates/messages/email/partners_update_partner.html:10 #: hypha/apply/activity/templates/messages/email/payment_request_status_updated.html:15 @@ -571,6 +606,25 @@ msgstr "" msgid "Here is the link to start creating your proposal" msgstr "" +#: hypha/apply/activity/templates/messages/email/invoice_status_updated.html:5 +#: hypha/apply/activity/templates/messages/email/invoice_updated.html:6 +#, python-format +msgid "" +"An %(ORG_SHORT_NAME)s staff member has updated your invoice for " +"%(source.title)s for period %(invoice.date_from)s to %(invoice.date_to)s." +msgstr "" + +#: hypha/apply/activity/templates/messages/email/invoice_status_updated.html:6 +#: hypha/apply/activity/templates/messages/email/invoice_updated.html:7 +#, python-format +msgid "It is now %(invoice.get_status_display)s." +msgstr "" + +#: hypha/apply/activity/templates/messages/email/invoice_status_updated.html:9 +#: hypha/apply/activity/templates/messages/email/payment_request_status_updated.html:9 +msgid "The staff member left this comment" +msgstr "" + #: hypha/apply/activity/templates/messages/email/partners_update_applicant.html:5 msgid "New partner(s) has been added to your submission." msgstr "" @@ -598,10 +652,6 @@ msgstr "" msgid "It is now %(payment_request.get_status_display)s." msgstr "" -#: hypha/apply/activity/templates/messages/email/payment_request_status_updated.html:9 -msgid "The staff member left this comment" -msgstr "" - #: hypha/apply/activity/templates/messages/email/ready_to_review.html:6 msgid "This application is awaiting your review." msgstr "" @@ -897,12 +947,12 @@ msgid "Draft" msgstr "" #: hypha/apply/determinations/models.py:107 -#: hypha/apply/projects/models/vendor.py:48 hypha/apply/review/models.py:163 +#: hypha/apply/projects/models/vendor.py:49 hypha/apply/review/models.py:163 msgid "Creation time" msgstr "" #: hypha/apply/determinations/models.py:108 -#: hypha/apply/projects/models/vendor.py:49 hypha/apply/review/models.py:164 +#: hypha/apply/projects/models/vendor.py:50 hypha/apply/review/models.py:164 msgid "Update time" msgstr "" @@ -1014,8 +1064,8 @@ msgstr "" msgid "Requested amount" msgstr "" -#: hypha/apply/funds/blocks.py:65 hypha/apply/projects/models/vendor.py:19 -#: hypha/apply/projects/models/vendor.py:56 +#: hypha/apply/funds/blocks.py:65 hypha/apply/projects/models/vendor.py:20 +#: hypha/apply/projects/models/vendor.py:57 msgid "Address" msgstr "" @@ -1031,7 +1081,8 @@ msgid "Take action" msgstr "" #: hypha/apply/funds/forms.py:143 hypha/apply/projects/filters.py:28 -#: hypha/apply/projects/filters.py:42 hypha/apply/projects/tables.py:49 +#: hypha/apply/projects/filters.py:38 hypha/apply/projects/filters.py:52 +#: hypha/apply/projects/tables.py:49 hypha/apply/projects/tables.py:90 msgid "Lead" msgstr "" @@ -1057,7 +1108,8 @@ msgid "Meta terms are hierarchical in nature." msgstr "" #: hypha/apply/funds/models/__init__.py:17 hypha/apply/funds/tables.py:74 -#: hypha/apply/projects/tables.py:48 hypha/apply/projects/tables.py:80 +#: hypha/apply/projects/tables.py:48 hypha/apply/projects/tables.py:89 +#: hypha/apply/projects/tables.py:121 msgid "Fund" msgstr "" @@ -1177,11 +1229,13 @@ msgid "Confirmation email" msgstr "" #: hypha/apply/funds/tables.py:71 hypha/apply/projects/tables.py:21 +#: hypha/apply/projects/tables.py:81 msgid "Submitted" msgstr "" #: hypha/apply/funds/tables.py:72 hypha/apply/projects/filters.py:27 -#: hypha/apply/projects/filters.py:43 hypha/apply/projects/tables.py:79 +#: hypha/apply/projects/filters.py:37 hypha/apply/projects/filters.py:53 +#: hypha/apply/projects/tables.py:120 #: hypha/apply/users/templates/wagtailusers/users/list.html:23 #: hypha/public/partner/tables.py:51 hypha/public/partner/tables.py:97 msgid "Status" @@ -1223,8 +1277,8 @@ msgstr "" #: hypha/apply/funds/tables.py:257 hypha/apply/funds/tables.py:322 #: hypha/apply/funds/tables.py:414 hypha/apply/funds/tables.py:448 -#: hypha/apply/projects/filters.py:26 hypha/apply/projects/filters.py:41 -#: hypha/public/home/models.py:125 +#: hypha/apply/projects/filters.py:26 hypha/apply/projects/filters.py:36 +#: hypha/apply/projects/filters.py:51 hypha/public/home/models.py:125 msgid "Funds" msgstr "" @@ -1563,7 +1617,7 @@ msgid "" "usages and try again!." msgstr "" -#: hypha/apply/projects/filters.py:83 +#: hypha/apply/projects/filters.py:93 msgid "Reporting Period" msgstr "" @@ -1591,19 +1645,19 @@ msgstr "" msgid "Proposed End Date must be after Proposed Start Date" msgstr "" -#: hypha/apply/projects/models/vendor.py:45 +#: hypha/apply/projects/models/vendor.py:46 msgid "Yes, the account belongs to the organisation above" msgstr "" -#: hypha/apply/projects/models/vendor.py:46 +#: hypha/apply/projects/models/vendor.py:47 msgid "No, it is a personal bank account" msgstr "" -#: hypha/apply/projects/tables.py:16 +#: hypha/apply/projects/tables.py:16 hypha/apply/projects/tables.py:76 msgid "Invoice reference" msgstr "" -#: hypha/apply/projects/tables.py:22 +#: hypha/apply/projects/tables.py:22 hypha/apply/projects/tables.py:82 #, python-brace-format msgid "Value ({currency})" msgstr "" @@ -1616,15 +1670,15 @@ msgstr "" msgid "Period End" msgstr "" -#: hypha/apply/projects/tables.py:81 +#: hypha/apply/projects/tables.py:122 msgid "Reporting" msgstr "" -#: hypha/apply/projects/tables.py:83 +#: hypha/apply/projects/tables.py:124 msgid "End Date" msgstr "" -#: hypha/apply/projects/tables.py:84 +#: hypha/apply/projects/tables.py:125 #, python-brace-format msgid "Fund Allocation ({currency})" msgstr ""