+ {% include "includes/dialog_form_base.html" with form=form value=value %}
+
diff --git a/hypha/apply/projects/templates/application_projects/modals/disbursement_confirm_delete.html b/hypha/apply/projects/templates/application_projects/modals/disbursement_confirm_delete.html
new file mode 100644
index 0000000000..f9e3d0a2e9
--- /dev/null
+++ b/hypha/apply/projects/templates/application_projects/modals/disbursement_confirm_delete.html
@@ -0,0 +1,7 @@
+{% load i18n %}
+
+
+ {% project_can_have_invoices_section object user as can_have_invoices_section %}
+ {% if can_have_invoices_section %}
+ {% include "application_projects/includes/invoices.html" %}
+ {% endif %}
+
+ {% project_can_have_disbursements_section object as can_have_disbursements_section %}
+ {% if can_have_disbursements_section %}
+ {% include "application_projects/includes/disbursements.html" %}
+ {% endif %}
+
{% user_can_view_project_documents object user as can_view_project_documents %}
{% if can_view_project_documents %}
{% include "application_projects/includes/project_documents.html" %}
diff --git a/hypha/apply/projects/templatetags/project_tags.py b/hypha/apply/projects/templatetags/project_tags.py
index 0bbb733251..8964865d6f 100644
--- a/hypha/apply/projects/templatetags/project_tags.py
+++ b/hypha/apply/projects/templatetags/project_tags.py
@@ -13,6 +13,7 @@
INTERNAL_APPROVAL,
INVOICING_AND_REPORTING,
)
+from hypha.apply.projects.payments import PaymentsFlow
from hypha.apply.projects.permissions import has_permission
from hypha.apply.projects.utils import get_project_public_status, no_pafreviewer_role
@@ -69,6 +70,16 @@ def user_can_view_project_documents(project, user):
@register.simple_tag
def user_can_view_contracting_documents(project, user):
+ if project.status == CONTRACTING and not user.is_apply_staff:
+ return False
+ if (
+ user.is_reviewer
+ and not user.is_apply_staff
+ and not user.is_staff
+ and not user.is_contracting
+ and not user.is_applicant
+ ):
+ return False
if project.submission.co_applicants.filter(user=user).exists():
co_applicant = project.submission.co_applicants.filter(user=user).first()
if (
@@ -328,9 +339,57 @@ def user_next_step_instructions(project, user):
def project_can_have_contracting_section(project):
if project.status in [DRAFT, INTERNAL_APPROVAL]:
return False
+ # Under DISBURSEMENTS the "Contracts and Disbursements" section lists the
+ # contracts, so hide the contracting documents section once a contract
+ # exists and the project is past the contracting stage (the first
+ # contract still goes through the contracting flow, which transitions the
+ # project).
+ if (
+ settings.PROJECTS_PAYMENTS_FLOW == PaymentsFlow.DISBURSEMENTS
+ and project.status != CONTRACTING
+ and project.contracts.exists()
+ ):
+ return False
+ return True
+
+
+@register.simple_tag
+def project_can_have_invoices_section(project, user):
+ """Show the invoices section only under the INVOICING payments flow, and
+ only for users who may view invoices on a project that can have them.
+ Folds the payments-flow check in with the existing visibility rules so
+ the template stays terse.
+ """
+ if settings.PROJECTS_PAYMENTS_FLOW != PaymentsFlow.INVOICING:
+ return False
+ # project_can_have_invoices: invoices are visible once the project has
+ # moved past the contracting/internal-approval phases.
+ if project.status not in [
+ INVOICING_AND_REPORTING,
+ CLOSING,
+ COMPLETE,
+ ]:
+ return False
+ # user_can_view_invoices: co-applicants may be restricted from invoices.
+ if project.submission.co_applicants.filter(user=user).exists():
+ co_applicant = project.submission.co_applicants.filter(user=user).first()
+ if (
+ co_applicant
+ and CoApplicantProjectPermission.INVOICES
+ not in co_applicant.project_permission
+ ):
+ return False
return True
+@register.simple_tag
+def project_can_have_disbursements_section(project):
+ """Show the disbursements section only under the DISBURSEMENTS payments
+ flow. The include itself only renders once the project has a contract.
+ """
+ return settings.PROJECTS_PAYMENTS_FLOW == PaymentsFlow.DISBURSEMENTS
+
+
@register.simple_tag
def can_access_supporting_documents_section(project):
if project.status not in [INVOICING_AND_REPORTING, CLOSING, COMPLETE]:
@@ -424,3 +483,14 @@ def show_start_date(project) -> bool:
CLOSING,
COMPLETE,
]
+
+
+@register.filter
+def trimmed_decimal(value):
+ """Format a decimal with a minimum of 2 decimal places, preserving any
+ extra precision, so the disbursements table matches the amount input.
+
+ """
+ from hypha.apply.projects.forms.project import TrimmedDecimalInput
+
+ return TrimmedDecimalInput().format_value(value)
diff --git a/hypha/apply/projects/tests/factories.py b/hypha/apply/projects/tests/factories.py
index 1c7baf4755..3fce14f29a 100644
--- a/hypha/apply/projects/tests/factories.py
+++ b/hypha/apply/projects/tests/factories.py
@@ -13,6 +13,7 @@
from hypha.apply.users.tests.factories import GroupFactory, StaffFactory, UserFactory
from hypha.home.factories import ApplySiteFactory
+from ..models.disbursement import Disbursement
from ..models.invoice import Invoice, SupportingDocument
from ..models.project import (
COMPLETE,
@@ -189,6 +190,18 @@ class Meta:
skip_postgeneration_save = True
+class DisbursementFactory(factory.django.DjangoModelFactory):
+ contract = factory.SubFactory(ContractFactory)
+ amount = decimal.Decimal("257.41")
+ date = datetime.date(2543, 5, 23)
+ notes = ""
+ updated_by = factory.SubFactory(StaffFactory)
+
+ class Meta:
+ model = Disbursement
+ skip_postgeneration_save = True
+
+
class PacketFileFactory(factory.django.DjangoModelFactory):
category = factory.SubFactory(DocumentCategoryFactory)
project = factory.SubFactory(ProjectFactory)
diff --git a/hypha/apply/projects/tests/test_forms.py b/hypha/apply/projects/tests/test_forms.py
index b7deddf50c..7d24058ab0 100644
--- a/hypha/apply/projects/tests/test_forms.py
+++ b/hypha/apply/projects/tests/test_forms.py
@@ -1,9 +1,10 @@
import datetime
import json
+from decimal import Decimal
from io import BytesIO
from django.core.files.uploadedfile import SimpleUploadedFile
-from django.test import TestCase
+from django.test import TestCase, override_settings
from hypha.apply.users.tests.factories import (
FinanceFactory,
@@ -412,6 +413,7 @@ def test_staff_can_upload_signed(self):
self.assertTrue(form.cleaned_data.get("signed_by_applicant"))
+@override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
class TestContractUploadForm(TestCase):
mock_file = SimpleUploadedFile(
"test_contract.pdf", BytesIO(b"somebinarydata").read()
@@ -428,3 +430,147 @@ def test_applicant_can_upload_signed(self):
)
self.assertTrue(form.is_valid(), form.errors)
self.assertIsNone(form.cleaned_data.get("signed_by_applicant"))
+
+ def test_can_set_amounts_to_wide_precision_with_no_currency(self):
+ form = UploadContractForm(
+ data={
+ "amount_approved": "43.0000000000043",
+ "amount_requested": "17.0000000000000000017",
+ },
+ files={"file": self.mock_file},
+ )
+ self.assertTrue(form.is_valid(), form.errors)
+ self.assertEqual(
+ form.cleaned_data.get("amount_approved"), Decimal("43.0000000000043")
+ )
+ self.assertEqual(
+ form.cleaned_data.get("amount_requested"), Decimal("17.0000000000000000017")
+ )
+
+ def test_can_set_amounts_to_smaller_than_two_decimal_places_and_see_exactly_two_decimals(
+ self,
+ ):
+ form = UploadContractForm(
+ data={"amount_approved": "23", "amount_requested": "31.3"},
+ files={"file": self.mock_file},
+ )
+ self.assertTrue(form.is_valid(), form.errors)
+
+ # Render fields to HTML: triggers widget.render() which calls format_value()
+ html_approved = str(form["amount_approved"])
+ html_requested = str(form["amount_requested"])
+
+ self.assertIn('value="23.00"', html_approved)
+ self.assertIn('value="31.30"', html_requested)
+
+ def test_can_set_amounts_to_larger_than_two_decimal_places_and_see_that_precision(
+ self,
+ ):
+ form = UploadContractForm(
+ data={"amount_approved": "23.2323", "amount_requested": "31.313131"},
+ files={"file": self.mock_file},
+ )
+ self.assertTrue(form.is_valid(), form.errors)
+
+ # Render fields to HTML: triggers widget.render() which calls format_value()
+ html_approved = str(form["amount_approved"])
+ html_requested = str(form["amount_requested"])
+
+ self.assertIn('value="23.2323"', html_approved)
+ self.assertIn('value="31.313131"', html_requested)
+
+ def test_can_set_currency_to_USD_with_no_amounts(self):
+ form = UploadContractForm(
+ data={"currency": "USD"}, files={"file": self.mock_file}
+ )
+ self.assertTrue(form.is_valid(), form.errors)
+ self.assertEqual(form.cleaned_data.get("currency"), "USD")
+
+ def test_cannot_set_currency_to_two_digits(self):
+ form = UploadContractForm(
+ data={"currency": "NA"}, files={"file": self.mock_file}
+ )
+ self.assertFalse(form.is_valid())
+ self.assertIn("currency", form.errors.keys())
+
+ def test_zero_amount_with_full_scale_renders_as_zero_not_scientific_notation(self):
+ # NUMERIC(38,19) returns Decimal("0.0000000000000000000"), whose
+ # str() is "0E-19" -- the widget must not emit "0E-19.00".
+ form = UploadContractForm(
+ data={
+ "amount_approved": "0.0000000000000000000",
+ "amount_requested": "0.0000000000000000000",
+ },
+ files={"file": self.mock_file},
+ )
+ self.assertTrue(form.is_valid(), form.errors)
+ self.assertEqual(form.cleaned_data["amount_approved"], Decimal("0"))
+ self.assertEqual(form.cleaned_data["amount_requested"], Decimal("0"))
+
+ html_approved = str(form["amount_approved"])
+ html_requested = str(form["amount_requested"])
+ self.assertIn('value="0.00"', html_approved)
+ self.assertIn('value="0.00"', html_requested)
+ self.assertNotIn("E", html_approved)
+ self.assertNotIn("E", html_requested)
+
+ def test_blank_amount_renders_empty_not_dot_zero(self):
+ # Blank amount on a bound (error) re-render must show empty, NOT ".00".
+ # Otherwise a resubmit silently stores 0 -> NUMERIC(38,19) -> 0E-19.
+ form = UploadContractForm(
+ data={"amount_approved": "", "amount_requested": ""},
+ files={"file": self.mock_file},
+ )
+ # file is required but amount blank is valid -> form may be invalid only
+ # due to file; in any case the bound field must not emit ".00".
+ html_approved = str(form["amount_approved"])
+ html_requested = str(form["amount_requested"])
+ self.assertIn('value=""', html_approved)
+ self.assertIn('value=""', html_requested)
+ self.assertNotIn('value=".00"', html_approved)
+ self.assertNotIn('value=".00"', html_requested)
+
+
+class TestContractFormPaymentsFlowGating(TestCase):
+ """The contract forms expose ``amount_requested``, ``amount_approved``
+ and ``currency`` only under the DISBURSEMENTS payments flow; under
+ INVOICING or DISABLED those fields are popped so the form collects only
+ the file and the relevant signed flag.
+ """
+
+ amount_fields = ("amount_requested", "amount_approved", "currency")
+
+ def _forms(self):
+ # Unbound instances are enough to inspect declared fields after
+ # __init__ has run (the gating pops fields there).
+ return [
+ UploadContractForm(),
+ StaffUploadContractForm(),
+ ]
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+ def test_amount_and_currency_fields_present_under_disbursements(self):
+ for form in self._forms():
+ for field in self.amount_fields:
+ self.assertIn(
+ field, form.fields, f"{type(form).__name__} missing {field}"
+ )
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="INVOICING")
+ def test_amount_and_currency_fields_hidden_under_invoicing(self):
+ for form in self._forms():
+ # The file field always remains; only the ledger fields are hidden.
+ self.assertIn("file", form.fields)
+ for field in self.amount_fields:
+ self.assertNotIn(
+ field, form.fields, f"{type(form).__name__} leaked {field}"
+ )
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISABLED")
+ def test_amount_and_currency_fields_hidden_under_disabled(self):
+ for form in self._forms():
+ self.assertIn("file", form.fields)
+ for field in self.amount_fields:
+ self.assertNotIn(
+ field, form.fields, f"{type(form).__name__} leaked {field}"
+ )
diff --git a/hypha/apply/projects/tests/test_models.py b/hypha/apply/projects/tests/test_models.py
index 670ae713e6..8e872b2935 100644
--- a/hypha/apply/projects/tests/test_models.py
+++ b/hypha/apply/projects/tests/test_models.py
@@ -1,6 +1,9 @@
+import datetime
from decimal import Decimal
-from django.test import TestCase
+from django.contrib.contenttypes.models import ContentType
+from django.test import TestCase, override_settings
+from wagtail.models import ModelLogEntry
from hypha.apply.funds.tests.factories import ApplicationSubmissionFactory
from hypha.apply.users.tests.factories import (
@@ -9,6 +12,8 @@
StaffFactory,
)
+from ..forms.project import TrimmedDecimalInput
+from ..models.disbursement import Disbursement
from ..models.invoice import (
APPROVED_BY_FINANCE,
APPROVED_BY_STAFF,
@@ -24,7 +29,8 @@
invoice_status_user_choices,
)
from ..models.project import Project
-from .factories import InvoiceFactory
+from ..utils import get_disbursement_total_cell
+from .factories import ContractFactory, InvoiceFactory, ProjectFactory
class TestProjectModel(TestCase):
@@ -223,3 +229,240 @@ def test_get_totals(self):
def test_get_totals_no_value(self):
self.assertEqual(Invoice.objects.paid_value(), 0)
self.assertEqual(Invoice.objects.unpaid_value(), 0)
+
+
+class TestDisbursementModel(TestCase):
+ """Test values use prime numbers (prime left and right of the decimal
+ point) that are unique to this test, and dates several hundred years in
+ the future, so a correct assertion is traceable to this test and not a
+ coincidence. The system under test (Disbursement) is constructed directly
+ via its initializer; factories are used only for its dependencies
+ (Contract, Staff), which the test harness owns.
+ """
+
+ def test_str_includes_amount_and_date(self):
+ contract = ContractFactory()
+ disbursement = Disbursement(
+ contract=contract,
+ amount=Decimal("53.17"),
+ date=datetime.date(2525, 7, 19),
+ )
+ disbursement.save()
+ rendered = str(disbursement)
+ self.assertIn("53.17", rendered)
+ self.assertIn("2525-07-19", rendered)
+
+ def test_negative_amount_allowed_for_repayment(self):
+ disbursement = Disbursement(
+ contract=ContractFactory(),
+ amount=Decimal("-61.13"),
+ date=datetime.date(2527, 11, 23),
+ )
+ disbursement.save()
+ disbursement.refresh_from_db()
+ self.assertEqual(disbursement.amount, Decimal("-61.13"))
+
+ def test_create_emits_wagtail_create_log_entry(self):
+ staff = StaffFactory()
+ contract = ContractFactory()
+ disbursement = Disbursement(
+ contract=contract,
+ amount=Decimal("67.19"),
+ date=datetime.date(2529, 3, 17),
+ updated_by=staff,
+ )
+ disbursement.save()
+ entries = ModelLogEntry.objects.filter(
+ content_type=ContentType.objects.get_for_model(Disbursement),
+ object_id=disbursement.pk,
+ )
+ self.assertTrue(entries.exists())
+ self.assertEqual(entries.first().action, "wagtail.create")
+ self.assertEqual(entries.first().user, staff)
+
+ def test_edit_emits_wagtail_edit_log_entry(self):
+ staff = StaffFactory()
+ disbursement = Disbursement(
+ contract=ContractFactory(),
+ amount=Decimal("71.23"),
+ date=datetime.date(2537, 1, 7),
+ updated_by=staff,
+ )
+ disbursement.save()
+ disbursement.amount = Decimal("73.29")
+ disbursement.updated_by = staff
+ disbursement.save()
+ entries = ModelLogEntry.objects.filter(
+ content_type=ContentType.objects.get_for_model(Disbursement),
+ object_id=disbursement.pk,
+ )
+ # create then edit each emit an entry; assert both are present and the
+ # edit is the most recent (ModelLogEntry defaults to newest-first).
+ self.assertEqual(entries.count(), 2)
+ self.assertEqual(
+ set(entries.values_list("action", flat=True)),
+ {"wagtail.create", "wagtail.edit"},
+ )
+ self.assertEqual(entries.first().action, "wagtail.edit")
+
+ def test_contract_disbursements_related_name(self):
+ contract = ContractFactory()
+ Disbursement(
+ contract=contract, amount=Decimal("79.31"), date=datetime.date(2539, 4, 3)
+ ).save()
+ Disbursement(
+ contract=contract,
+ amount=Decimal("-83.37"),
+ date=datetime.date(2539, 4, 3),
+ ).save()
+ # Sum() over the related manager nets negatives (repayments) correctly.
+ total = sum(d.amount for d in contract.disbursements.all())
+ self.assertEqual(total, Decimal("-4.06"))
+
+ def test_updated_by_changes_on_edit_while_created_by_does_not(self):
+ creator = StaffFactory()
+ editor = StaffFactory()
+ disbursement = Disbursement(
+ contract=ContractFactory(),
+ amount=Decimal("89.41"),
+ date=datetime.date(2531, 5, 13),
+ created_by=creator,
+ updated_by=creator,
+ )
+ disbursement.save()
+ disbursement.amount = Decimal("97.11")
+ disbursement.updated_by = editor
+ disbursement.save()
+ disbursement.refresh_from_db()
+ self.assertEqual(disbursement.created_by, creator)
+ self.assertEqual(disbursement.updated_by, editor)
+
+ def test_notes_field_persisted(self):
+ disbursement = Disbursement(
+ contract=ContractFactory(),
+ amount=Decimal("101.03"),
+ date=datetime.date(2533, 9, 29),
+ notes="Wire transfer for the reporting period.",
+ )
+ disbursement.save()
+ disbursement.refresh_from_db()
+ self.assertEqual(disbursement.notes, "Wire transfer for the reporting period.")
+
+
+@override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+class TestProjectDisbursementTotal(TestCase):
+ def test_sums_disbursements_across_contracts(self):
+ project = ProjectFactory()
+ first_contract = ContractFactory(project=project)
+ second_contract = ContractFactory(project=project)
+ Disbursement(
+ contract=first_contract,
+ amount=Decimal("83.29"),
+ date=datetime.date(2541, 6, 11),
+ ).save()
+ Disbursement(
+ contract=second_contract,
+ amount=Decimal("97.31"),
+ date=datetime.date(2543, 8, 13),
+ ).save()
+ summed = Project.objects.for_table().get(pk=project.pk)
+ self.assertEqual(summed.disbursement_total, Decimal("180.60"))
+ self.assertIsNone(summed.disbursement_currency_code)
+
+ def test_no_disbursements_shows_no_total(self):
+ project = ProjectFactory()
+ summed = Project.objects.for_table().get(pk=project.pk)
+ self.assertIsNone(summed.disbursement_total)
+ self.assertIsNone(summed.disbursement_currency_count)
+ self.assertIsNone(summed.disbursement_currency_code)
+ # Absent, not zero: 0.00 would read as a real summed number.
+ self.assertEqual(
+ get_disbursement_total_cell(summed, TrimmedDecimalInput().format_value), "—"
+ )
+
+ def test_zero_sum_disbursements_show_zero_not_dash(self):
+ project = ProjectFactory()
+ contract = ContractFactory(project=project)
+ Disbursement(
+ contract=contract,
+ amount=Decimal("41.17"),
+ date=datetime.date(2585, 2, 27),
+ ).save()
+ Disbursement(
+ contract=contract,
+ amount=Decimal("-41.17"),
+ date=datetime.date(2587, 4, 29),
+ ).save()
+ summed = Project.objects.for_table().get(pk=project.pk)
+ self.assertEqual(summed.disbursement_total, Decimal("0"))
+ self.assertEqual(
+ get_disbursement_total_cell(summed, TrimmedDecimalInput().format_value),
+ "0.00",
+ )
+
+ def test_repayment_negative_amounts_net_out(self):
+ project = ProjectFactory()
+ contract = ContractFactory(project=project)
+ Disbursement(
+ contract=contract,
+ amount=Decimal("43.07"),
+ date=datetime.date(2545, 10, 17),
+ ).save()
+ Disbursement(
+ contract=contract,
+ amount=Decimal("-29.03"),
+ date=datetime.date(2547, 12, 19),
+ ).save()
+ summed = Project.objects.for_table().get(pk=project.pk)
+ self.assertEqual(summed.disbursement_total, Decimal("14.04"))
+
+ def test_totals_not_multiplied_by_invoice_and_disbursement_joins(self):
+ project = ProjectFactory()
+ first_contract = ContractFactory(project=project)
+ second_contract = ContractFactory(project=project)
+ Disbursement(
+ contract=first_contract,
+ amount=Decimal("23.09"),
+ date=datetime.date(2549, 2, 21),
+ ).save()
+ Disbursement(
+ contract=second_contract,
+ amount=Decimal("31.37"),
+ date=datetime.date(2551, 4, 23),
+ ).save()
+ InvoiceFactory(project=project, paid_value=Decimal("19.07"))
+ InvoiceFactory(project=project, paid_value=Decimal("29.17"))
+ summed = Project.objects.for_table().get(pk=project.pk)
+ self.assertEqual(summed.disbursement_total, Decimal("54.46"))
+ self.assertEqual(summed.amount_paid, Decimal("48.24"))
+
+ def test_single_currency_code_annotated(self):
+ project = ProjectFactory()
+ contract = ContractFactory(project=project, currency="XTS")
+ Disbursement(
+ contract=contract,
+ amount=Decimal("113.11"),
+ date=datetime.date(2575, 4, 29),
+ ).save()
+ summed = Project.objects.for_table().get(pk=project.pk)
+ self.assertEqual(summed.disbursement_total, Decimal("113.11"))
+ self.assertEqual(summed.disbursement_currency_count, 1)
+ self.assertEqual(summed.disbursement_currency_code, "XTS")
+
+ def test_mixed_currencies_have_no_single_total(self):
+ project = ProjectFactory()
+ coded = ContractFactory(project=project, currency="XTS")
+ other = ContractFactory(project=project, currency="QZC")
+ Disbursement(
+ contract=coded,
+ amount=Decimal("107.89"),
+ date=datetime.date(2577, 6, 23),
+ ).save()
+ Disbursement(
+ contract=other,
+ amount=Decimal("109.83"),
+ date=datetime.date(2579, 8, 31),
+ ).save()
+ summed = Project.objects.for_table().get(pk=project.pk)
+ self.assertIsNone(summed.disbursement_total)
+ self.assertEqual(summed.disbursement_currency_count, 2)
diff --git a/hypha/apply/projects/tests/test_templatetags.py b/hypha/apply/projects/tests/test_templatetags.py
index e33773acc7..9622239579 100644
--- a/hypha/apply/projects/tests/test_templatetags.py
+++ b/hypha/apply/projects/tests/test_templatetags.py
@@ -1,4 +1,4 @@
-from django.test import TestCase
+from django.test import TestCase, override_settings
from hypha.apply.users.tests.factories import (
ApplicantFactory,
@@ -22,8 +22,13 @@
INTERNAL_APPROVAL,
INVOICING_AND_REPORTING,
)
+from ..payments import PaymentsFlow
from ..templatetags.contract_tools import user_can_upload_contract
from ..templatetags.invoice_tools import can_change_status, can_delete, can_edit
+from ..templatetags.project_tags import (
+ project_can_have_disbursements_section,
+ project_can_have_invoices_section,
+)
from .factories import ContractFactory, InvoiceFactory, ProjectFactory
@@ -261,3 +266,60 @@ def test_applicant_and_staff_cant_edit_in_decline(self):
self.assertFalse(can_edit(invoice, applicant))
self.assertFalse(can_edit(invoice, staff))
+
+
+class TestPaymentsFlowEnum(TestCase):
+ def test_members_equal_their_string_values(self):
+ # PaymentsFlow subclasses str so members compare equal to plain
+ # strings, which keeps the {% if %} comparisons in templates simple.
+ self.assertEqual(PaymentsFlow.INVOICING, "INVOICING")
+ self.assertEqual(PaymentsFlow.DISBURSEMENTS, "DISBURSEMENTS")
+ self.assertEqual(PaymentsFlow.DISABLED, "DISABLED")
+
+ def test_three_valid_values(self):
+ self.assertEqual(
+ {m.value for m in PaymentsFlow},
+ {"INVOICING", "DISBURSEMENTS", "DISABLED"},
+ )
+
+
+@override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+class TestCanHaveDisbursementsSection(TestCase):
+ def test_shown_under_disbursements(self):
+ project = ProjectFactory()
+ self.assertTrue(project_can_have_disbursements_section(project))
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="INVOICING")
+ def test_hidden_under_invoicing(self):
+ project = ProjectFactory()
+ self.assertFalse(project_can_have_disbursements_section(project))
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISABLED")
+ def test_hidden_under_disabled(self):
+ project = ProjectFactory()
+ self.assertFalse(project_can_have_disbursements_section(project))
+
+
+@override_settings(PROJECTS_PAYMENTS_FLOW="INVOICING")
+class TestCanHaveInvoicesSection(TestCase):
+ def test_shown_under_invoicing_when_in_scope(self):
+ staff = StaffFactory()
+ project = ProjectFactory(status=INVOICING_AND_REPORTING)
+ self.assertTrue(project_can_have_invoices_section(project, staff))
+
+ def test_hidden_when_project_not_in_scope(self):
+ staff = StaffFactory()
+ project = ProjectFactory(status=CONTRACTING)
+ self.assertFalse(project_can_have_invoices_section(project, staff))
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+ def test_hidden_under_disbursements(self):
+ staff = StaffFactory()
+ project = ProjectFactory(status=INVOICING_AND_REPORTING)
+ self.assertFalse(project_can_have_invoices_section(project, staff))
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISABLED")
+ def test_hidden_under_disabled(self):
+ staff = StaffFactory()
+ project = ProjectFactory(status=INVOICING_AND_REPORTING)
+ self.assertFalse(project_can_have_invoices_section(project, staff))
diff --git a/hypha/apply/projects/tests/test_views.py b/hypha/apply/projects/tests/test_views.py
index 1ed2f0ba39..0d15125987 100644
--- a/hypha/apply/projects/tests/test_views.py
+++ b/hypha/apply/projects/tests/test_views.py
@@ -1,11 +1,15 @@
+import csv
+import datetime
+import decimal
import json
-from io import BytesIO
+from io import BytesIO, StringIO
from django.conf import settings
-from django.contrib.auth.models import AnonymousUser
+from django.contrib.auth.models import AnonymousUser, Group
from django.core.exceptions import PermissionDenied
from django.test import RequestFactory, TestCase, override_settings
from django.urls import reverse
+from django.utils import translation
from hypha.apply.activity.adapters.activity_feed import ActivityAdapter
from hypha.apply.activity.options import MESSAGES
@@ -14,7 +18,12 @@
ApplicationSubmissionFactory,
LabSubmissionFactory,
)
-from hypha.apply.projects.utils import get_invoice_status_display_value
+from hypha.apply.projects.utils import (
+ format_local_minute,
+ get_contract_approved_amounts_cell,
+ get_invoice_status_display_value,
+)
+from hypha.apply.users.roles import TEAMADMIN_GROUP_NAME
from hypha.apply.users.tests.factories import (
ApplicantFactory,
ApproverFactory,
@@ -29,6 +38,9 @@
from hypha.home.factories import ApplySiteFactory
from ..forms import SetPendingForm
+from ..forms.project import TrimmedDecimalInput, trimmed_decimal_string
+from ..models import Project
+from ..models.disbursement import Disbursement
from ..models.invoice import CHANGES_REQUESTED_BY_STAFF, DECLINED, SUBMITTED
from ..models.project import (
APPROVE,
@@ -42,6 +54,7 @@
from ..views.project import ContractsMixin, ProjectDetailApprovalView
from .factories import (
ContractFactory,
+ DisbursementFactory,
DocumentCategoryFactory,
InvoiceFactory,
PacketFileFactory,
@@ -378,6 +391,18 @@ def test_applicant_can_see_lead(self):
response = self.get_page(project, view_name="project_lead")
self.assertContains(response, str(lead))
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+ def test_applicant_can_see_disbursement_notes(self):
+ project = ProjectFactory(user=self.user)
+ DisbursementFactory(
+ contract__project=project,
+ amount=decimal.Decimal("251.47"),
+ notes="Prime tranche sent by wire.",
+ )
+ response = self.get_page(project)
+ self.assertContains(response, "Prime tranche sent by wire.")
+ self.assertNotContains(response, "visible to staff and finance only")
+
class TestStaffProjectDetailView(BaseProjectDetailTestCase):
user_factory = StaffFactory
@@ -403,6 +428,17 @@ def test_sibling_projects_listed_in_tab_dropdown(self):
self.assertContains(response, "Hardware bucket")
self.assertContains(response, "Travel bucket")
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+ def test_staff_can_see_disbursement_notes(self):
+ project = ProjectFactory()
+ DisbursementFactory(
+ contract__project=project,
+ amount=decimal.Decimal("257.17"),
+ notes="Second tranche sent by wire.",
+ )
+ response = self.get_page(project)
+ self.assertContains(response, "Second tranche sent by wire.")
+
class TestFinanceProjectDetailView(BaseProjectDetailTestCase):
user_factory = FinanceFactory
@@ -1339,58 +1375,1548 @@ def test_applicants_cannot_access_project_list_page(self):
response = self.client.get(url, follow=True)
self.assertEqual(response.status_code, 403)
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+ def test_contract_approved_amounts_sum_shown_in_project_list(self):
+ project = ProjectFactory(status=INVOICING_AND_REPORTING)
+ ContractFactory(
+ project=project, currency="XTS", amount_approved=decimal.Decimal("421")
+ )
+ ContractFactory(
+ project=project, currency="XTS", amount_approved=decimal.Decimal("571")
+ )
-class TestStaffProjectDetailDownloadView(BaseViewTestCase):
- base_view_name = "download"
- url_name = "funds:projects:{}"
- user_factory = StaffFactory
+ self.client.force_login(StaffFactory())
- def get_kwargs(self, instance):
- return {
- "pk": instance.pk,
- }
+ url = reverse("apply:projects:all")
- def test_can_access_pdf(self):
- project = ProjectFactory()
- response = self.get_page(project, url_kwargs={"export_type": "pdf"})
+ response = self.client.get(url, follow=True)
self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "992.00 XTS")
- def test_can_access_docx(self):
- project = ProjectFactory()
- response = self.get_page(project, url_kwargs={"export_type": "docx"})
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+ def test_contract_approved_amounts_sum_without_currency_code(self):
+ project = ProjectFactory(status=INVOICING_AND_REPORTING)
+ ContractFactory(project=project, amount_approved=decimal.Decimal("421"))
+ ContractFactory(project=project, amount_approved=decimal.Decimal("571"))
+
+ self.client.force_login(StaffFactory())
+
+ url = reverse("apply:projects:all")
+
+ response = self.client.get(url, follow=True)
self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "992.00")
- def test_response_object_is_pdf(self):
- project = ProjectFactory()
- response = self.get_page(project, url_kwargs={"export_type": "pdf"})
- self.assertIn(
- ".pdf", response.headers["content-disposition"].split("filename=")[1]
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+ def test_contract_approved_amounts_absent_without_contracts(self):
+ ProjectFactory(status=INVOICING_AND_REPORTING)
+
+ self.client.force_login(StaffFactory())
+
+ url = reverse("apply:projects:all")
+
+ response = self.client.get(url, follow=True)
+ self.assertEqual(response.status_code, 200)
+ table = response.context["table"]
+ self.assertEqual(table.rows[0].get_cell("contract_approved_total"), "—")
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+ def test_contract_approved_amounts_zero_sum_shown(self):
+ project = ProjectFactory(status=INVOICING_AND_REPORTING)
+ ContractFactory(
+ project=project, currency="XTS", amount_approved=decimal.Decimal("0")
)
- def test_response_object_is_docx(self):
- project = ProjectFactory()
- response = self.get_page(project, url_kwargs={"export_type": "docx"})
- self.assertIn(
- ".docx", response.headers["content-disposition"].split("filename=")[1]
+ self.client.force_login(StaffFactory())
+
+ url = reverse("apply:projects:all")
+
+ response = self.client.get(url, follow=True)
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "0.00 XTS")
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+ def test_contract_approved_amounts_not_summed_across_currencies(self):
+ project = ProjectFactory(status=INVOICING_AND_REPORTING)
+ ContractFactory(
+ project=project, currency="XTS", amount_approved=decimal.Decimal("421")
+ )
+ ContractFactory(
+ project=project, currency="QZC", amount_approved=decimal.Decimal("571")
)
+ self.client.force_login(StaffFactory())
-class ApplicantStaffProjectDetailDownloadView(BaseViewTestCase):
- base_view_name = "download"
- url_name = "funds:projects:{}"
- user_factory = ApplicantFactory
+ url = reverse("apply:projects:all")
- def get_kwargs(self, instance):
- return {
- "pk": instance.pk,
- }
+ response = self.client.get(url, follow=True)
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Multiple currencies")
+ self.assertNotContains(response, "992.00")
- def test_cant_access_pdf(self):
- project = ProjectFactory()
- response = self.get_page(project, url_kwargs={"export_type": "pdf"})
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+ def test_contract_approved_amounts_not_summed_when_currency_code_missing(self):
+ project = ProjectFactory(status=INVOICING_AND_REPORTING)
+ ContractFactory(
+ project=project, currency="XTS", amount_approved=decimal.Decimal("421")
+ )
+ ContractFactory(project=project, amount_approved=decimal.Decimal("571"))
+
+ self.client.force_login(StaffFactory())
+
+ url = reverse("apply:projects:all")
+
+ response = self.client.get(url, follow=True)
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Multiple currencies")
+ self.assertNotContains(response, "992.00")
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+ def test_contract_approved_amounts_column_ordering(self):
+ with_total = ProjectFactory(status=INVOICING_AND_REPORTING)
+ ContractFactory(
+ project=with_total,
+ currency="XTS",
+ amount_approved=decimal.Decimal("571"),
+ )
+ without_total = ProjectFactory(status=INVOICING_AND_REPORTING)
+
+ self.client.force_login(StaffFactory())
+
+ url = reverse("apply:projects:all")
+
+ response = self.client.get(f"{url}?sort=contract_approved_total")
+ self.assertEqual(response.status_code, 200)
+ table = response.context["table"]
+ # Absent totals are NULL: last ascending, first descending.
+ self.assertEqual(
+ [row.record.pk for row in table.rows],
+ [with_total.pk, without_total.pk],
+ )
+
+ response = self.client.get(f"{url}?sort=-contract_approved_total")
+ self.assertEqual(response.status_code, 200)
+ table = response.context["table"]
+ self.assertEqual(
+ [row.record.pk for row in table.rows],
+ [without_total.pk, with_total.pk],
+ )
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+ def test_contract_approved_amounts_right_justified(self):
+ ProjectFactory(status=INVOICING_AND_REPORTING)
+
+ self.client.force_login(StaffFactory())
+
+ url = reverse("apply:projects:all")
+
+ response = self.client.get(url, follow=True)
+ self.assertEqual(response.status_code, 200)
+ column = response.context["table"].columns["contract_approved_total"]
+ self.assertEqual(column.attrs["td"]["align"], "right")
+ self.assertEqual(column.attrs["th"]["align"], "right")
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+ def test_contract_approved_total_not_inflated_by_invoices(self):
+ project = ProjectFactory(status=INVOICING_AND_REPORTING)
+ ContractFactory(
+ project=project, currency="XTS", amount_approved=decimal.Decimal("173")
+ )
+ ContractFactory(
+ project=project, currency="XTS", amount_approved=decimal.Decimal("179")
+ )
+ InvoiceFactory(project=project)
+ InvoiceFactory(project=project)
+
+ self.client.force_login(StaffFactory())
+
+ url = reverse("apply:projects:all")
+
+ response = self.client.get(url, follow=True)
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "352.00 XTS")
+ self.assertNotContains(response, "704.00")
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+ def test_contract_approved_amounts_csv_value_locale_neutral(self):
+ project = ProjectFactory(status=INVOICING_AND_REPORTING)
+ ContractFactory(
+ project=project, currency="XTS", amount_approved=decimal.Decimal("421")
+ )
+ ContractFactory(
+ project=project, currency="XTS", amount_approved=decimal.Decimal("571")
+ )
+ project_row = Project.objects.for_table().get(pk=project.pk)
+
+ with translation.override("cs"):
+ self.assertEqual(
+ get_contract_approved_amounts_cell(project_row, trimmed_decimal_string),
+ "992.00 XTS",
+ )
+ self.assertEqual(
+ get_contract_approved_amounts_cell(
+ project_row, TrimmedDecimalInput().format_value
+ ),
+ "992,00 XTS",
+ )
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+ def test_contract_approved_amounts_included_in_csv_export(self):
+ project = ProjectFactory(status=INVOICING_AND_REPORTING)
+ ContractFactory(
+ project=project, currency="XTS", amount_approved=decimal.Decimal("421")
+ )
+ ContractFactory(
+ project=project, currency="XTS", amount_approved=decimal.Decimal("571")
+ )
+
+ self.client.force_login(StaffFactory())
+
+ url = reverse("apply:projects:all")
+
+ response = self.client.get(f"{url}?format=csv")
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Contracts total")
+ self.assertContains(response, "992.00 XTS")
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="INVOICING")
+ def test_contract_total_column_absent_outside_disbursements_flow(self):
+ project = ProjectFactory(status=INVOICING_AND_REPORTING)
+ ContractFactory(
+ project=project, currency="XTS", amount_approved=decimal.Decimal("421")
+ )
+
+ self.client.force_login(StaffFactory())
+
+ url = reverse("apply:projects:all")
+
+ response = self.client.get(url, follow=True)
+ self.assertEqual(response.status_code, 200)
+ self.assertNotContains(response, "Contracts total")
+ self.assertNotContains(response, "421.00 XTS")
+
+ response = self.client.get(f"{url}?format=csv")
+ self.assertEqual(response.status_code, 200)
+ self.assertNotContains(response, "Contracts total")
+ self.assertNotContains(response, "421.00 XTS")
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+ def test_csv_export_values_match_columns_with_contracts_flow(self):
+ project = ProjectFactory(
+ status=INVOICING_AND_REPORTING, proposed_end=datetime.date(2573, 6, 18)
+ )
+ first_contract = ContractFactory(
+ project=project, currency="XTS", amount_approved=decimal.Decimal("421")
+ )
+ ContractFactory(
+ project=project, currency="XTS", amount_approved=decimal.Decimal("571")
+ )
+ Disbursement(
+ contract=first_contract,
+ amount=decimal.Decimal("269.03"),
+ date=datetime.date(2585, 4, 27),
+ ).save()
+ Disbursement(
+ contract=first_contract,
+ amount=decimal.Decimal("271.01"),
+ date=datetime.date(2587, 6, 29),
+ ).save()
+
+ self.client.force_login(StaffFactory())
+
+ url = reverse("apply:projects:all")
+
+ response = self.client.get(f"{url}?format=csv")
+ self.assertEqual(response.status_code, 200)
+
+ rows = list(csv.reader(StringIO(response.content.decode())))
+ self.assertEqual(len(rows), 2)
+ header, values = rows[0], dict(zip(*rows, strict=False))
+
+ self.assertEqual(
+ header,
+ [
+ "Submission #",
+ "Title",
+ "Organization",
+ "Status",
+ "Lead",
+ "Fund",
+ "Round",
+ "Reporting",
+ "Last payment request",
+ "Contracts total",
+ "Disbursed total",
+ "End Date",
+ ],
+ )
+ self.assertEqual(values["Submission #"], str(project.submission.id))
+ self.assertEqual(values["Title"], project.title)
+ self.assertEqual(values["Contracts total"], "992.00 XTS")
+ self.assertEqual(values["Disbursed total"], "540.04 XTS")
+ self.assertEqual(values["End Date"], "2573-06-18")
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="INVOICING")
+ def test_csv_export_values_match_columns_outside_contracts_flow(self):
+ project = ProjectFactory(
+ status=INVOICING_AND_REPORTING, proposed_end=datetime.date(2577, 8, 25)
+ )
+ ContractFactory(
+ project=project, currency="XTS", amount_approved=decimal.Decimal("421")
+ )
+
+ self.client.force_login(StaffFactory())
+
+ url = reverse("apply:projects:all")
+
+ response = self.client.get(f"{url}?format=csv")
+ self.assertEqual(response.status_code, 200)
+
+ rows = list(csv.reader(StringIO(response.content.decode())))
+ self.assertEqual(len(rows), 2)
+ header, values = rows[0], dict(zip(*rows, strict=False))
+
+ self.assertEqual(
+ header,
+ [
+ "Submission #",
+ "Title",
+ "Organization",
+ "Status",
+ "Lead",
+ "Fund",
+ "Round",
+ "Reporting",
+ "Last payment request",
+ "End Date",
+ ],
+ )
+ self.assertNotIn("Contracts total", header)
+ self.assertNotIn("Disbursed total", header)
+ self.assertEqual(values["Submission #"], str(project.submission.id))
+ self.assertEqual(values["Title"], project.title)
+ self.assertEqual(values["End Date"], "2577-08-25")
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+ def test_disbursement_total_rendered_in_table(self):
+ project = ProjectFactory(status=INVOICING_AND_REPORTING)
+ contract = ContractFactory(project=project)
+ Disbursement(
+ contract=contract,
+ amount=decimal.Decimal("211.43"),
+ date=datetime.date(2553, 6, 27),
+ ).save()
+
+ self.client.force_login(StaffFactory())
+
+ response = self.client.get(reverse("apply:projects:all"), follow=True)
+
+ self.assertContains(response, "211.43")
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+ def test_disbursement_totals_sum_shown_with_single_currency_code(self):
+ project = ProjectFactory(status=INVOICING_AND_REPORTING)
+ first_contract = ContractFactory(project=project, currency="XTS")
+ second_contract = ContractFactory(project=project, currency="XTS")
+ Disbursement(
+ contract=first_contract,
+ amount=decimal.Decimal("191.39"),
+ date=datetime.date(2559, 12, 31),
+ ).save()
+ Disbursement(
+ contract=second_contract,
+ amount=decimal.Decimal("199.37"),
+ date=datetime.date(2561, 2, 27),
+ ).save()
+
+ self.client.force_login(StaffFactory())
+
+ url = reverse("apply:projects:all")
+
+ response = self.client.get(url, follow=True)
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "390.76 XTS")
+ self.assertContains(response, '| ')
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+ def test_disbursement_totals_sum_without_currency_code(self):
+ project = ProjectFactory(status=INVOICING_AND_REPORTING)
+ first_contract = ContractFactory(project=project)
+ second_contract = ContractFactory(project=project)
+ Disbursement(
+ contract=first_contract,
+ amount=decimal.Decimal("223.43"),
+ date=datetime.date(2567, 8, 27),
+ ).save()
+ Disbursement(
+ contract=second_contract,
+ amount=decimal.Decimal("227.41"),
+ date=datetime.date(2569, 10, 23),
+ ).save()
+
+ self.client.force_login(StaffFactory())
+
+ url = reverse("apply:projects:all")
+
+ response = self.client.get(url, follow=True)
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "450.84")
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+ def test_disbursement_totals_not_summed_across_currencies(self):
+ project = ProjectFactory(status=INVOICING_AND_REPORTING)
+ coded = ContractFactory(project=project, currency="XTS")
+ other = ContractFactory(project=project, currency="QZC")
+ Disbursement(
+ contract=coded,
+ amount=decimal.Decimal("233.33"),
+ date=datetime.date(2563, 4, 23),
+ ).save()
+ Disbursement(
+ contract=other,
+ amount=decimal.Decimal("239.31"),
+ date=datetime.date(2565, 6, 29),
+ ).save()
+
+ self.client.force_login(StaffFactory())
+
+ url = reverse("apply:projects:all")
+
+ response = self.client.get(url, follow=True)
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Multiple currencies")
+ self.assertNotContains(response, "472.64")
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+ def test_disbursement_totals_not_summed_when_currency_code_missing(self):
+ project = ProjectFactory(status=INVOICING_AND_REPORTING)
+ coded = ContractFactory(project=project, currency="XTS")
+ uncoded = ContractFactory(project=project)
+ Disbursement(
+ contract=coded,
+ amount=decimal.Decimal("241.79"),
+ date=datetime.date(2581, 10, 27),
+ ).save()
+ Disbursement(
+ contract=uncoded,
+ amount=decimal.Decimal("251.71"),
+ date=datetime.date(2583, 12, 29),
+ ).save()
+
+ self.client.force_login(StaffFactory())
+
+ url = reverse("apply:projects:all")
+
+ response = self.client.get(url, follow=True)
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Multiple currencies")
+ self.assertNotContains(response, "493.50")
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+ def test_disbursement_total_included_in_csv_export(self):
+ project = ProjectFactory(status=INVOICING_AND_REPORTING)
+ first_contract = ContractFactory(project=project, currency="XTS")
+ second_contract = ContractFactory(project=project, currency="XTS")
+ Disbursement(
+ contract=first_contract,
+ amount=decimal.Decimal("257.17"),
+ date=datetime.date(2571, 12, 27),
+ ).save()
+ Disbursement(
+ contract=second_contract,
+ amount=decimal.Decimal("263.21"),
+ date=datetime.date(2573, 2, 23),
+ ).save()
+
+ self.client.force_login(StaffFactory())
+
+ url = reverse("apply:projects:all")
+
+ response = self.client.get(f"{url}?format=csv")
+ self.assertEqual(response.status_code, 200)
+
+ rows = list(csv.reader(StringIO(response.content.decode())))
+ self.assertEqual(len(rows), 2)
+ header, values = rows[0], dict(zip(*rows, strict=False))
+
+ self.assertEqual(
+ header,
+ [
+ "Submission #",
+ "Title",
+ "Organization",
+ "Status",
+ "Lead",
+ "Fund",
+ "Round",
+ "Reporting",
+ "Last payment request",
+ "Contracts total",
+ "Disbursed total",
+ "End Date",
+ ],
+ )
+ self.assertEqual(values["Contracts total"], "—")
+ self.assertEqual(values["Disbursed total"], "520.38 XTS")
+
+
+@override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+class TestDisbursementListView(TestCase):
+ """The admin ledger of every disbursement across all projects.
+
+ Admin-only (Staff Admin group or superuser) and gated on the
+ DISBURSEMENTS payments flow; the date filter targets the disbursement's
+ own date, not the date it was entered.
+
+ """
+
+ def make_staff_admin(self):
+ # Staff Admin requires the Staff group too (see the role's help text).
+ user = StaffFactory()
+ admin_group, _ = Group.objects.get_or_create(name=TEAMADMIN_GROUP_NAME)
+ user.groups.add(admin_group)
+ return user
+
+ def test_staff_admin_can_access(self):
+ DisbursementFactory()
+ self.client.force_login(self.make_staff_admin())
+
+ response = self.client.get(reverse("apply:projects:disbursements"), follow=True)
+ self.assertEqual(response.status_code, 200)
+
+ def test_superuser_can_access(self):
+ DisbursementFactory()
+ self.client.force_login(SuperUserFactory())
+
+ response = self.client.get(reverse("apply:projects:disbursements"), follow=True)
+ self.assertEqual(response.status_code, 200)
+
+ def test_staff_cannot_access(self):
+ DisbursementFactory()
+ self.client.force_login(StaffFactory())
+
+ response = self.client.get(reverse("apply:projects:disbursements"), follow=True)
self.assertEqual(response.status_code, 403)
- def test_cant_access_docx(self):
- project = ProjectFactory()
- response = self.get_page(project, url_kwargs={"export_type": "docx"})
+ def test_finance_cannot_access(self):
+ DisbursementFactory()
+ self.client.force_login(FinanceFactory())
+
+ response = self.client.get(reverse("apply:projects:disbursements"), follow=True)
+ self.assertEqual(response.status_code, 403)
+
+ def test_applicant_cannot_access(self):
+ DisbursementFactory()
+ self.client.force_login(UserFactory())
+
+ response = self.client.get(reverse("apply:projects:disbursements"), follow=True)
+ self.assertEqual(response.status_code, 403)
+
+ def test_not_found_when_payments_flow_is_not_disbursements(self):
+ DisbursementFactory()
+ self.client.force_login(SuperUserFactory())
+
+ with override_settings(PROJECTS_PAYMENTS_FLOW="INVOICING"):
+ response = self.client.get(
+ reverse("apply:projects:disbursements"), follow=True
+ )
+ self.assertEqual(response.status_code, 404)
+
+ def test_date_filter_uses_disbursement_date(self):
+ may_disbursement = DisbursementFactory(
+ date=datetime.date(2543, 5, 23), amount=decimal.Decimal("101.03")
+ )
+ june_disbursement = DisbursementFactory(
+ date=datetime.date(2543, 6, 19), amount=decimal.Decimal("97.01")
+ )
+ self.client.force_login(self.make_staff_admin())
+
+ response = self.client.get(
+ reverse("apply:projects:disbursements"),
+ {"date_after": "2543-06-01", "date_before": "2543-06-30"},
+ )
+ self.assertEqual(list(response.context["object_list"]), [june_disbursement])
+ self.assertNotIn(may_disbursement, response.context["object_list"])
+
+ def test_currency_filter(self):
+ usd_disbursement = DisbursementFactory(contract=ContractFactory(currency="USD"))
+ DisbursementFactory(contract=ContractFactory(currency="EUR"))
+ self.client.force_login(self.make_staff_admin())
+
+ response = self.client.get(
+ reverse("apply:projects:disbursements"), {"currency": "USD"}
+ )
+ self.assertEqual(list(response.context["object_list"]), [usd_disbursement])
+
+ def test_currency_filter_can_match_uncoded_contracts(self):
+ uncoded_disbursement = DisbursementFactory(contract=ContractFactory())
+ DisbursementFactory(contract=ContractFactory(currency="EUR"))
+ self.client.force_login(self.make_staff_admin())
+
+ response = self.client.get(
+ reverse("apply:projects:disbursements"), {"currency": "none"}
+ )
+ self.assertEqual(list(response.context["object_list"]), [uncoded_disbursement])
+
+ def test_project_status_filter(self):
+ contracting_disbursement = DisbursementFactory(
+ contract=ContractFactory(project=ProjectFactory(status=CONTRACTING)),
+ )
+ DisbursementFactory(
+ contract=ContractFactory(
+ project=ProjectFactory(status=INVOICING_AND_REPORTING)
+ ),
+ )
+ self.client.force_login(self.make_staff_admin())
+
+ response = self.client.get(
+ reverse("apply:projects:disbursements"), {"project_status": CONTRACTING}
+ )
+ self.assertEqual(
+ list(response.context["object_list"]), [contracting_disbursement]
+ )
+
+ def test_total_is_numeric_for_a_single_currency(self):
+ contract = ContractFactory(currency="USD")
+ DisbursementFactory(contract=contract, amount=decimal.Decimal("101.03"))
+ DisbursementFactory(contract=contract, amount=decimal.Decimal("97.01"))
+ self.client.force_login(self.make_staff_admin())
+
+ response = self.client.get(reverse("apply:projects:disbursements"))
+ self.assertEqual(response.context["disbursements_total"], "198.04 USD")
+ self.assertIn(
+ "Total disbursed (2 records matching this filter): 198.04 USD",
+ response.content.decode("utf-8"),
+ )
+
+ def test_total_elaborates_each_currency(self):
+ DisbursementFactory(
+ contract=ContractFactory(currency="USD"), amount=decimal.Decimal("499.03")
+ )
+ DisbursementFactory(
+ contract=ContractFactory(currency="MXN"), amount=decimal.Decimal("809.03")
+ )
+ DisbursementFactory(
+ contract=ContractFactory(currency="EUR"), amount=decimal.Decimal("1009.03")
+ )
+ self.client.force_login(self.make_staff_admin())
+
+ response = self.client.get(reverse("apply:projects:disbursements"))
+ self.assertEqual(
+ response.context["disbursements_total"],
+ "1009.03 EUR, 809.03 MXN, 499.03 USD",
+ )
+ self.assertIn(
+ "Total disbursed (3 records matching this filter): "
+ "1009.03 EUR, 809.03 MXN, 499.03 USD",
+ response.content.decode("utf-8"),
+ )
+
+ def test_total_lists_uncoded_amounts_last(self):
+ coded = ContractFactory(currency="USD")
+ DisbursementFactory(contract=coded, amount=decimal.Decimal("499.03"))
+ uncoded = ContractFactory()
+ DisbursementFactory(contract=uncoded, amount=decimal.Decimal("251.03"))
+ self.client.force_login(self.make_staff_admin())
+
+ response = self.client.get(reverse("apply:projects:disbursements"))
+ self.assertEqual(response.context["disbursements_total"], "499.03 USD, 251.03")
+
+ def test_total_is_numeric_without_code_when_no_currency(self):
+ contract = ContractFactory()
+ DisbursementFactory(contract=contract, amount=decimal.Decimal("101.03"))
+ DisbursementFactory(contract=contract, amount=decimal.Decimal("97.01"))
+ self.client.force_login(self.make_staff_admin())
+
+ response = self.client.get(reverse("apply:projects:disbursements"))
+ self.assertEqual(response.context["disbursements_total"], "198.04")
+ self.assertIn(
+ "Total disbursed (2 records matching this filter): 198.04",
+ response.content.decode("utf-8"),
+ )
+
+ def test_csv_export_includes_iso_dates_and_amounts(self):
+ contract = ContractFactory(
+ currency="USD",
+ amount_requested=decimal.Decimal("250.5"),
+ amount_approved=decimal.Decimal("249.99"),
+ )
+ DisbursementFactory(
+ contract=contract,
+ amount=decimal.Decimal("101.03"),
+ date=datetime.date(2543, 5, 23),
+ notes="wire #d7b9",
+ )
+ self.client.force_login(self.make_staff_admin())
+
+ response = self.client.get(
+ reverse("apply:projects:disbursements"), {"format": "csv"}
+ )
+ self.assertEqual(response.headers["Content-Type"], "text/csv")
+ content = response.content.decode("utf-8")
+ self.assertIn("2543-05-23", content)
+ self.assertIn(f"#{contract.pk}", content)
+ self.assertIn("250.50", content)
+ self.assertIn("101.03", content)
+ self.assertIn("USD", content)
+ self.assertIn("wire #d7b9", content)
+
+ def test_csv_export_respects_filters(self):
+ kept = DisbursementFactory(contract=ContractFactory(currency="USD"))
+ excluded = DisbursementFactory(contract=ContractFactory(currency="EUR"))
+ self.client.force_login(self.make_staff_admin())
+
+ response = self.client.get(
+ reverse("apply:projects:disbursements"),
+ {"currency": "USD", "format": "csv"},
+ )
+ content = response.content.decode("utf-8")
+ self.assertIn(f"#{kept.contract.pk}", content)
+ self.assertNotIn(f"#{excluded.contract.pk}", content)
+
+ def test_table_leads_with_project_then_dates(self):
+ DisbursementFactory()
+ self.client.force_login(self.make_staff_admin())
+
+ response = self.client.get(reverse("apply:projects:disbursements"))
+ self.assertEqual(
+ list(response.context["table"].columns.names())[:3],
+ ["project", "date", "created_at"],
+ )
+ self.assertIn("Amount disbursed", response.content.decode("utf-8"))
+
+ def test_entry_date_renders_minute_precision_locally(self):
+ DisbursementFactory()
+ self.client.force_login(self.make_staff_admin())
+
+ response = self.client.get(reverse("apply:projects:disbursements"))
+ self.assertRegex(
+ response.content.decode("utf-8"), r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}(?![:\d])"
+ )
+
+ def test_entry_date_tooltip_shows_edit_when_minutes_differ(self):
+ disbursement = DisbursementFactory()
+ # queryset.update bypasses auto_now, so both timestamps can be pinned.
+ Disbursement.objects.filter(pk=disbursement.pk).update(
+ created_at=datetime.datetime(
+ 2543, 5, 23, 14, 32, 0, tzinfo=datetime.timezone.utc
+ ),
+ updated_at=datetime.datetime(
+ 2543, 5, 23, 14, 40, 0, tzinfo=datetime.timezone.utc
+ ),
+ )
+ self.client.force_login(self.make_staff_admin())
+
+ response = self.client.get(reverse("apply:projects:disbursements"))
+ content = response.content.decode("utf-8")
+ self.assertIn("2543-05-23 14:32*", content)
+ self.assertIn('title="Edited 2543-05-23 14:40"', content)
+
+ def test_entry_date_has_no_tooltip_when_minutes_match(self):
+ DisbursementFactory()
+ self.client.force_login(self.make_staff_admin())
+
+ response = self.client.get(reverse("apply:projects:disbursements"))
+ self.assertNotIn('title="Edited', response.content.decode("utf-8"))
+
+ def test_empty_state_shows_message(self):
+ self.client.force_login(self.make_staff_admin())
+
+ response = self.client.get(reverse("apply:projects:disbursements"))
+ content = response.content.decode("utf-8")
+ self.assertIn("No Disbursements available.", content)
+ self.assertNotIn("disbursements-table", content)
+
+ def test_export_link_visible(self):
+ DisbursementFactory()
+ self.client.force_login(self.make_staff_admin())
+
+ response = self.client.get(reverse("apply:projects:disbursements"))
+ self.assertIn(
+ 'data-tippy-content="Export as CSV"', response.content.decode("utf-8")
+ )
+
+ def test_csv_export_follows_table_sort(self):
+ DisbursementFactory(
+ date=datetime.date(2543, 6, 19), amount=decimal.Decimal("101.03")
+ )
+ DisbursementFactory(
+ date=datetime.date(2543, 5, 23), amount=decimal.Decimal("97.01")
+ )
+ self.client.force_login(self.make_staff_admin())
+
+ response = self.client.get(
+ reverse("apply:projects:disbursements"), {"format": "csv"}
+ )
+ content = response.content.decode("utf-8")
+ self.assertLess(content.index("101.03"), content.index("97.01"))
+
+ response = self.client.get(
+ reverse("apply:projects:disbursements"), {"sort": "amount", "format": "csv"}
+ )
+ content = response.content.decode("utf-8")
+ self.assertLess(content.index("97.01"), content.index("101.03"))
+
+ def test_format_local_minute_rounds_to_nearest_minute(self):
+ self.assertEqual(
+ format_local_minute(datetime.datetime(2543, 5, 23, 14, 32, 29)),
+ "2543-05-23 14:32",
+ )
+ self.assertEqual(
+ format_local_minute(datetime.datetime(2543, 5, 23, 14, 32, 31)),
+ "2543-05-23 14:33",
+ )
+ self.assertEqual(
+ format_local_minute(
+ datetime.datetime(2543, 5, 23, 14, 32, 59, tzinfo=datetime.timezone.utc)
+ ),
+ "2543-05-23 14:33",
+ )
+
+ def test_csv_leads_with_submission_number_then_project(self):
+ disbursement = DisbursementFactory()
+ project = disbursement.contract.project
+ self.client.force_login(self.make_staff_admin())
+
+ response = self.client.get(
+ reverse("apply:projects:disbursements"), {"format": "csv"}
+ )
+ rows = list(csv.reader(StringIO(response.content.decode("utf-8"))))
+ self.assertEqual(
+ rows[0],
+ [
+ "Submission #",
+ "Project",
+ "Disbursement date",
+ "Date recorded",
+ "Updated at",
+ "Contract",
+ "Contract requested",
+ "Contract approved",
+ "Currency",
+ "Amount disbursed",
+ "Notes",
+ ],
+ )
+ self.assertEqual(rows[1][:2], [str(project.submission_id), project.title])
+ self.assertEqual(rows[1][2], "2543-05-23")
+ self.assertIn("T", rows[1][3])
+ self.assertIn("T", rows[1][4])
+
+
+class TestStaffProjectDetailDownloadView(BaseViewTestCase):
+ base_view_name = "download"
+ url_name = "funds:projects:{}"
+ user_factory = StaffFactory
+
+ def get_kwargs(self, instance):
+ return {
+ "pk": instance.pk,
+ }
+
+ def test_can_access_pdf(self):
+ project = ProjectFactory()
+ response = self.get_page(project, url_kwargs={"export_type": "pdf"})
+ self.assertEqual(response.status_code, 200)
+
+ def test_can_access_docx(self):
+ project = ProjectFactory()
+ response = self.get_page(project, url_kwargs={"export_type": "docx"})
+ self.assertEqual(response.status_code, 200)
+
+ def test_response_object_is_pdf(self):
+ project = ProjectFactory()
+ response = self.get_page(project, url_kwargs={"export_type": "pdf"})
+ self.assertIn(
+ ".pdf", response.headers["content-disposition"].split("filename=")[1]
+ )
+
+ def test_response_object_is_docx(self):
+ project = ProjectFactory()
+ response = self.get_page(project, url_kwargs={"export_type": "docx"})
+ self.assertIn(
+ ".docx", response.headers["content-disposition"].split("filename=")[1]
+ )
+
+
+class ApplicantStaffProjectDetailDownloadView(BaseViewTestCase):
+ base_view_name = "download"
+ url_name = "funds:projects:{}"
+ user_factory = ApplicantFactory
+
+ def get_kwargs(self, instance):
+ return {
+ "pk": instance.pk,
+ }
+
+ def test_cant_access_pdf(self):
+ project = ProjectFactory()
+ response = self.get_page(project, url_kwargs={"export_type": "pdf"})
+ self.assertEqual(response.status_code, 403)
+
+ def test_cant_access_docx(self):
+ project = ProjectFactory()
+ response = self.get_page(project, url_kwargs={"export_type": "docx"})
+ self.assertEqual(response.status_code, 403)
+
+
+class TestStaffCreateDisbursementView(BaseViewTestCase):
+ """Disbursement create/edit/delete views. The disbursement FK->Contract,
+ so routes are scoped under the project's contract (pk = project pk,
+ contract_pk, disbursement_pk). Only staff/finance may record disbursements.
+
+ """
+
+ base_view_name = "disbursement"
+ url_name = "funds:projects:{}"
+ user_factory = StaffFactory
+
+ def get_kwargs(self, instance):
+ # instance is a Contract
+ return {"pk": instance.project.pk, "contract_pk": instance.pk}
+
+ def test_can_get(self):
+ contract = ContractFactory()
+ response = self.get_page(contract)
+ self.assertEqual(response.status_code, 200)
+
+ def test_warns_for_unsigned_contract(self):
+ # A disbursement against a not-yet-countersigned contract shows a
+ # warning so the user knows they are disbursing toward an unsigned
+ # contract.
+ contract = ContractFactory(signed_by_applicant=False)
+ response = self.get_page(contract)
+ self.assertContains(response, "has not been countersigned")
+
+ def test_no_warning_for_countersigned_contract(self):
+ contract = ContractFactory(signed_by_applicant=True)
+ response = self.get_page(contract)
+ self.assertNotContains(response, "has not been countersigned")
+
+ def test_amount_label_shows_contract_currency(self):
+ contract = ContractFactory(currency="USD")
+ response = self.get_page(contract)
+ self.assertContains(response, "Amount (USD)")
+
+ def test_amount_label_omits_missing_currency(self):
+ # Contracts may have no currency (old data, other payments flows),
+ # so the label must not show an empty parenthetical.
+ contract = ContractFactory(currency=None)
+ response = self.get_page(contract)
+ self.assertNotContains(response, "Amount (")
+
+ def test_amount_prepopulates_from_contract_approved_amount(self):
+ contract = ContractFactory(amount_approved=decimal.Decimal("283.29"))
+ response = self.get_page(contract)
+ self.assertContains(response, 'value="283.29"')
+
+ def test_amount_prepopulates_from_project_value_without_contract_amount(self):
+ # Contracts recorded before per-contract amounts existed carry no
+ # approved amount; fall back to the project's awarded value.
+ project = ProjectFactory(value=decimal.Decimal("277"))
+ contract = ContractFactory(project=project, amount_approved=None)
+ response = self.get_page(contract)
+ self.assertContains(response, 'value="277.00"')
+
+ def test_amount_prepopulates_net_of_existing_disbursements(self):
+ contract = ContractFactory(amount_approved=decimal.Decimal("283.29"))
+ DisbursementFactory(contract=contract, amount=decimal.Decimal("31.11"))
+ response = self.get_page(contract)
+ self.assertContains(response, 'value="252.18"')
+
+ def test_amount_prepopulates_net_of_repayments(self):
+ # Repayments are negative, so they net the other way.
+ contract = ContractFactory(amount_approved=decimal.Decimal("283.29"))
+ DisbursementFactory(contract=contract, amount=decimal.Decimal("-17.13"))
+ response = self.get_page(contract)
+ self.assertContains(response, 'value="300.42"')
+
+ def test_can_create(self):
+ contract = ContractFactory()
+ url = self.url(contract)
+ response = self.client.post(
+ url,
+ {"amount": "263.53", "date": "2541-03-07", "notes": "first tranche"},
+ )
+ self.assertEqual(response.status_code, 302)
+ # Saving returns to the project page (where the Add button is), not the
+ # contract PDF.
+ self.assertEqual(
+ response["Location"],
+ contract.project.get_absolute_url(),
+ )
+ self.assertEqual(contract.disbursements.count(), 1)
+ disbursement = contract.disbursements.get()
+ self.assertEqual(disbursement.amount, decimal.Decimal("263.53"))
+ self.assertEqual(disbursement.created_by, self.user)
+ self.assertEqual(disbursement.updated_by, self.user)
+
+ def test_can_create_negative_repayment(self):
+ contract = ContractFactory()
+ url = self.url(contract)
+ response = self.client.post(
+ url,
+ {"amount": "-269.71", "date": "2545-09-19", "notes": "repayment"},
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(
+ contract.disbursements.get().amount, decimal.Decimal("-269.71")
+ )
+
+ def test_create_records_event(self):
+ from hypha.apply.activity.models import Event
+
+ contract = ContractFactory()
+ self.client.post(
+ self.url(contract),
+ {"amount": "271.83", "date": "2547-11-29", "notes": ""},
+ )
+ # Event.source is a GenericForeignKey, so filter by object_id.
+ self.assertTrue(
+ Event.objects.filter(
+ type="CREATE_DISBURSEMENT", object_id=contract.project.id
+ ).exists()
+ )
+
+
+class TestApplicantCreateDisbursementView(BaseViewTestCase):
+ base_view_name = "disbursement"
+ url_name = "funds:projects:{}"
+ user_factory = ApplicantFactory
+
+ def get_kwargs(self, instance):
+ return {"pk": instance.project.pk, "contract_pk": instance.pk}
+
+ def test_cannot_get(self):
+ contract = ContractFactory()
+ response = self.get_page(contract)
+ self.assertEqual(response.status_code, 403)
+
+
+class TestFinanceCreateDisbursementView(BaseViewTestCase):
+ base_view_name = "disbursement"
+ url_name = "funds:projects:{}"
+ user_factory = FinanceFactory
+
+ def get_kwargs(self, instance):
+ return {"pk": instance.project.pk, "contract_pk": instance.pk}
+
+ def test_can_get(self):
+ contract = ContractFactory()
+ response = self.get_page(contract)
+ self.assertEqual(response.status_code, 200)
+
+ def test_can_create(self):
+ contract = ContractFactory()
+ response = self.client.post(
+ self.url(contract),
+ {"amount": "277.89", "date": "2549-01-13", "notes": ""},
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response["Location"], contract.project.get_absolute_url())
+ self.assertEqual(contract.disbursements.count(), 1)
+ self.assertEqual(contract.disbursements.get().created_by, self.user)
+
+
+class TestStaffEditDisbursementView(BaseViewTestCase):
+ base_view_name = "disbursement-edit"
+ url_name = "funds:projects:{}"
+ user_factory = StaffFactory
+
+ def get_kwargs(self, instance):
+ # instance is a Disbursement
+ return {
+ "pk": instance.contract.project.pk,
+ "contract_pk": instance.contract.pk,
+ "disbursement_pk": instance.pk,
+ }
+
+ def test_can_get(self):
+ disbursement = DisbursementFactory()
+ response = self.get_page(disbursement)
+ self.assertEqual(response.status_code, 200)
+
+ def test_amount_label_shows_contract_currency(self):
+ contract = ContractFactory(currency="EUR")
+ disbursement = DisbursementFactory(contract=contract)
+ response = self.get_page(disbursement)
+ self.assertContains(response, "Amount (EUR)")
+
+ def test_edit_shows_instance_amount_not_contract_award(self):
+ # The full-award prepopulation applies on create only; editing an
+ # existing disbursement must show its recorded amount.
+ contract = ContractFactory(amount_approved=decimal.Decimal("281.83"))
+ disbursement = DisbursementFactory(
+ contract=contract, amount=decimal.Decimal("311.13")
+ )
+ response = self.get_page(disbursement)
+ self.assertContains(response, 'value="311.13"')
+ self.assertNotContains(response, 'value="281.83"')
+
+ def test_can_edit(self):
+ disbursement = DisbursementFactory(amount=decimal.Decimal("281.97"))
+ url = self.url(disbursement)
+ response = self.client.post(
+ url,
+ {
+ "amount": "293.59",
+ "date": disbursement.date.isoformat(),
+ "notes": "updated",
+ },
+ )
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(
+ response["Location"],
+ disbursement.contract.project.get_absolute_url(),
+ )
+ disbursement.refresh_from_db()
+ self.assertEqual(disbursement.amount, decimal.Decimal("293.59"))
+ self.assertEqual(disbursement.updated_by, self.user)
+
+ def test_edit_records_event(self):
+ from hypha.apply.activity.models import Event
+
+ disbursement = DisbursementFactory()
+ self.client.post(
+ self.url(disbursement),
+ {
+ "amount": "307.61",
+ "date": disbursement.date.isoformat(),
+ "notes": "",
+ },
+ )
+ self.assertTrue(
+ Event.objects.filter(
+ type="UPDATE_DISBURSEMENT",
+ object_id=disbursement.contract.project.id,
+ ).exists()
+ )
+
+
+class TestStaffDeleteDisbursementView(BaseViewTestCase):
+ base_view_name = "disbursement-delete"
+ url_name = "funds:projects:{}"
+ user_factory = StaffFactory
+
+ def get_kwargs(self, instance):
+ return {
+ "pk": instance.contract.project.pk,
+ "contract_pk": instance.contract.pk,
+ "disbursement_pk": instance.pk,
+ }
+
+ def test_can_get(self):
+ disbursement = DisbursementFactory()
+ response = self.get_page(disbursement)
+ self.assertEqual(response.status_code, 200)
+
+ def test_can_delete(self):
+ disbursement = DisbursementFactory()
+ contract = disbursement.contract
+ response = self.client.post(self.url(disbursement), {})
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(
+ response["Location"],
+ contract.project.get_absolute_url(),
+ )
+ self.assertFalse(contract.disbursements.filter(pk=disbursement.pk).exists())
+
+ def test_delete_records_event(self):
+ from hypha.apply.activity.models import Event
+
+ disbursement = DisbursementFactory()
+ contract = disbursement.contract
+ self.client.post(self.url(disbursement), {})
+ self.assertTrue(
+ Event.objects.filter(
+ type="DELETE_DISBURSEMENT", object_id=contract.project.id
+ ).exists()
+ )
+
+
+class TestApplicantDeleteDisbursementView(BaseViewTestCase):
+ base_view_name = "disbursement-delete"
+ url_name = "funds:projects:{}"
+ user_factory = ApplicantFactory
+
+ def get_kwargs(self, instance):
+ return {
+ "pk": instance.contract.project.pk,
+ "contract_pk": instance.contract.pk,
+ "disbursement_pk": instance.pk,
+ }
+
+ def test_cannot_get(self):
+ disbursement = DisbursementFactory()
+ response = self.get_page(disbursement)
+ self.assertEqual(response.status_code, 403)
+
+
+class TestFinanceEditDisbursementView(BaseViewTestCase):
+ base_view_name = "disbursement-edit"
+ url_name = "funds:projects:{}"
+ user_factory = FinanceFactory
+
+ def get_kwargs(self, instance):
+ return {
+ "pk": instance.contract.project.pk,
+ "contract_pk": instance.contract.pk,
+ "disbursement_pk": instance.pk,
+ }
+
+ def test_can_get(self):
+ disbursement = DisbursementFactory()
+ response = self.get_page(disbursement)
+ self.assertEqual(response.status_code, 200)
+
+
+class TestFinanceDeleteDisbursementView(BaseViewTestCase):
+ base_view_name = "disbursement-delete"
+ url_name = "funds:projects:{}"
+ user_factory = FinanceFactory
+
+ def get_kwargs(self, instance):
+ return {
+ "pk": instance.contract.project.pk,
+ "contract_pk": instance.contract.pk,
+ "disbursement_pk": instance.pk,
+ }
+
+ def test_can_get(self):
+ disbursement = DisbursementFactory()
+ response = self.get_page(disbursement)
+ self.assertEqual(response.status_code, 200)
+
+
+class TestDisbursementFormWidget(TestCase):
+ def test_amount_uses_trimmed_decimal_input(self):
+ from ..forms import DisbursementForm
+ from ..forms.project import TrimmedDecimalInput
+
+ form = DisbursementForm()
+ self.assertIsInstance(form.fields["amount"].widget, TrimmedDecimalInput)
+
+ def test_negatives_allowed(self):
+ from ..forms import DisbursementForm
+
+ form = DisbursementForm(
+ data={"amount": "-311.13", "date": "2551-03-17", "notes": ""}
+ )
+ self.assertTrue(form.is_valid(), form.errors)
+
+ def test_amount_input_pattern_allows_negative(self):
+ # The disbursement amount widget accepts a leading minus so
+ # repayments can be recorded; the shared number widget stays
+ # positive-only for the rest of the codebase.
+ from ..forms import DisbursementForm
+ from ..forms.disbursement import TrimmedSignedDecimalInput
+
+ form = DisbursementForm()
+ self.assertIsInstance(form.fields["amount"].widget, TrimmedSignedDecimalInput)
+ self.assertIn('pattern="-?[0-9.]*"', str(form["amount"]))
+
+ def test_exposes_expected_fields(self):
+ from ..forms import DisbursementForm
+
+ form = DisbursementForm()
+ self.assertEqual(set(form.fields), {"amount", "date", "notes"})
+
+ def test_date_defaults_to_today(self):
+ from django.utils import timezone
+
+ from ..forms import DisbursementForm
+
+ form = DisbursementForm()
+ self.assertEqual(form.fields["date"].initial, timezone.localdate())
+
+
+class TestDisbursementActivityMessage(TestCase):
+ def test_create_message_includes_amount_not_note(self):
+ from hypha.apply.activity.adapters.activity_feed import ActivityAdapter
+ from hypha.apply.activity.options import MESSAGES
+
+ from ..models import Disbursement
+
+ disbursement = Disbursement(
+ amount=decimal.Decimal("271.83"), notes="secret note"
+ )
+ message = ActivityAdapter().message(
+ MESSAGES.CREATE_DISBURSEMENT, disbursement=disbursement
+ )
+ self.assertIn("271.83", message)
+ self.assertNotIn("secret note", message)
+
+
+@override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+class TestStaffDisbursementsSection(BaseProjectDetailTestCase):
+ user_factory = StaffFactory
+
+ def test_section_not_shown_without_contract(self):
+ project = ProjectFactory()
+ response = self.get_page(project)
+ self.assertNotContains(response, 'id="disbursements"')
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="INVOICING")
+ def test_section_hidden_when_invoicing(self):
+ project = ProjectFactory()
+ ContractFactory(project=project)
+ response = self.get_page(project)
+ self.assertNotContains(response, 'id="disbursements"')
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="DISABLED")
+ def test_section_hidden_when_disabled(self):
+ project = ProjectFactory()
+ ContractFactory(project=project)
+ response = self.get_page(project)
+ self.assertNotContains(response, 'id="disbursements"')
+
+ def test_section_shown_with_contract(self):
+ project = ProjectFactory()
+ ContractFactory(project=project)
+ response = self.get_page(project)
+ self.assertContains(response, 'id="disbursements"')
+
+ def test_add_button_shown_to_staff(self):
+ project = ProjectFactory()
+ ContractFactory(project=project)
+ response = self.get_page(project)
+ self.assertContains(response, "Add Disbursement")
+
+ def test_add_contract_button_shown_to_staff(self):
+ project = ProjectFactory()
+ ContractFactory(project=project)
+ response = self.get_page(project)
+ self.assertContains(response, "Add Contract")
+
+ def test_section_header_is_contracts_and_disbursements(self):
+ project = ProjectFactory()
+ ContractFactory(project=project)
+ response = self.get_page(project)
+ self.assertContains(response, "Contracts and Disbursements")
+
+ def test_contract_link_includes_contract_word(self):
+ # The link text reads "Contract #65", not just "#65", so the
+ # destination is clear out of context (screen readers, copy-paste).
+ project = ProjectFactory()
+ contract = ContractFactory(project=project)
+ response = self.get_page(project)
+ self.assertContains(response, f"Contract #{contract.pk}")
+
+ def test_disbursements_labelled_with_count(self):
+ project = ProjectFactory()
+ contract = ContractFactory(project=project)
+ DisbursementFactory(contract=contract)
+ DisbursementFactory(contract=contract)
+ response = self.get_page(project)
+ self.assertContains(response, "2 Disbursements:")
+
+ def test_single_disbursement_label_is_singular(self):
+ project = ProjectFactory()
+ contract = ContractFactory(project=project)
+ DisbursementFactory(contract=contract)
+ response = self.get_page(project)
+ self.assertContains(response, "1 Disbursement:")
+ self.assertNotContains(response, "1 Disbursements:")
+
+ def test_date_column_is_disbursement_date(self):
+ # The per-contract table matches the All Disbursements table, where
+ # the first column is likewise "Disbursement date".
+ project = ProjectFactory()
+ contract = ContractFactory(project=project)
+ DisbursementFactory(contract=contract)
+ response = self.get_page(project)
+ self.assertContains(response, "Disbursement date")
+
+ def test_contract_approved_amount_shown(self):
+ from decimal import Decimal
+
+ project = ProjectFactory()
+ ContractFactory(project=project, amount_approved=Decimal("99.50"))
+ response = self.get_page(project)
+ self.assertContains(response, "Approved: 99.50")
+
+ def test_edit_and_delete_links_shown_for_existing_disbursement(self):
+ from django.urls import reverse
+
+ from hypha.apply.projects.models import Disbursement
+
+ project = ProjectFactory()
+ contract = ContractFactory(project=project)
+ disbursement = DisbursementFactory(contract=contract)
+ response = self.get_page(project)
+ # The URL name is "disbursement-edit"/"disbursement-delete", but the
+ # rendered href is the resolved path (.../disbursements//edit/),
+ # so assert on the reversed URLs rather than the hyphenated name.
+ edit_url = reverse(
+ "funds:projects:disbursement-edit",
+ kwargs={
+ "pk": project.pk,
+ "contract_pk": contract.pk,
+ "disbursement_pk": disbursement.pk,
+ },
+ )
+ delete_url = reverse(
+ "funds:projects:disbursement-delete",
+ kwargs={
+ "pk": project.pk,
+ "contract_pk": contract.pk,
+ "disbursement_pk": disbursement.pk,
+ },
+ )
+ self.assertContains(response, edit_url)
+ self.assertContains(response, delete_url)
+ # Sanity: the Disbursement row is the one we created.
+ self.assertEqual(Disbursement.objects.filter(contract=contract).count(), 1)
+
+ def test_empty_state_message(self):
+ project = ProjectFactory()
+ ContractFactory(project=project)
+ response = self.get_page(project)
+ self.assertContains(response, "No disbursements yet.")
+
+ def test_amount_displayed_with_minimum_two_decimals(self):
+ from decimal import Decimal
+
+ project = ProjectFactory()
+ contract = ContractFactory(project=project)
+ DisbursementFactory(contract=contract, amount=Decimal("99.5"))
+ response = self.get_page(project)
+ # 99.5 is padded to "99.50" to match the contract amount rendering.
+ self.assertContains(response, "99.50")
+
+ def test_amount_display_keeps_extra_precision(self):
+ from decimal import Decimal
+
+ project = ProjectFactory()
+ contract = ContractFactory(project=project)
+ DisbursementFactory(contract=contract, amount=Decimal("99.123"))
+ response = self.get_page(project)
+ # More than two decimal places are preserved, not truncated.
+ self.assertContains(response, "99.123")
+
+ def test_contracting_section_hidden_under_disbursements(self):
+ # The disbursements section already lists the contracts, so the
+ # contracting documents section is hidden under DISBURSEMENTS to
+ # avoid duplication.
+ from hypha.apply.projects.models.project import INVOICING_AND_REPORTING
+
+ project = ProjectFactory(status=INVOICING_AND_REPORTING)
+ ContractFactory(project=project)
+ response = self.get_page(project)
+ self.assertNotContains(response, 'id="contract-documents-section"')
+
+ @override_settings(PROJECTS_PAYMENTS_FLOW="INVOICING")
+ def test_contracting_section_shown_under_invoicing(self):
+ from hypha.apply.projects.models.project import INVOICING_AND_REPORTING
+
+ project = ProjectFactory(status=INVOICING_AND_REPORTING)
+ ContractFactory(project=project)
+ response = self.get_page(project)
+ self.assertContains(response, 'id="contract-documents-section"')
+
+ def test_contracting_section_shown_during_contracting_under_disbursements(self):
+ # The first contract still goes through the contracting flow, so the
+ # contracting section shows during the Contracting stage even under
+ # DISBURSEMENTS (a contract may already be uploaded, awaiting signoff).
+ from hypha.apply.projects.models.project import CONTRACTING
+
+ project = ProjectFactory(status=CONTRACTING)
+ ContractFactory(project=project)
+ response = self.get_page(project)
+ self.assertContains(response, 'id="contract-documents-section"')
+
+
+@override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+class TestApplicantDisbursementsSection(BaseProjectDetailTestCase):
+ user_factory = ApplicantFactory
+
+ def test_section_shown_read_only_with_contract(self):
+ project = ProjectFactory(user=self.user)
+ ContractFactory(project=project)
+ response = self.get_page(project)
+ self.assertContains(response, 'id="disbursements"')
+
+ def test_add_button_hidden_from_applicant(self):
+ project = ProjectFactory(user=self.user)
+ ContractFactory(project=project)
+ response = self.get_page(project)
+ self.assertNotContains(response, "Add Disbursement")
+
+ def test_edit_delete_hidden_from_applicant(self):
+ project = ProjectFactory(user=self.user)
+ contract = ContractFactory(project=project)
+ DisbursementFactory(contract=contract)
+ response = self.get_page(project)
+ self.assertNotContains(response, "disbursement-edit")
+ self.assertNotContains(response, "disbursement-delete")
+
+ def test_add_contract_button_hidden_from_applicant(self):
+ project = ProjectFactory(user=self.user)
+ ContractFactory(project=project)
+ response = self.get_page(project)
+ self.assertNotContains(response, "Add Contract")
+
+
+@override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+class TestFinanceDisbursementsSection(BaseProjectDetailTestCase):
+ user_factory = FinanceFactory
+
+ def test_add_disbursement_button_shown_to_finance(self):
+ # Finance may record disbursements (matches staff_or_finance_required).
+ project = ProjectFactory()
+ ContractFactory(project=project)
+ response = self.get_page(project)
+ self.assertContains(response, "Add Disbursement")
+
+ def test_add_contract_button_shown_to_finance(self):
+ project = ProjectFactory()
+ ContractFactory(project=project)
+ response = self.get_page(project)
+ self.assertContains(response, "Add Contract")
+
+
+@override_settings(PROJECTS_PAYMENTS_FLOW="DISBURSEMENTS")
+class TestCreateContractView(BaseViewTestCase):
+ base_view_name = "contract_add"
+ url_name = "funds:projects:{}"
+ user_factory = StaffFactory
+
+ def get_kwargs(self, instance):
+ return {"pk": instance.id}
+
+ def test_get_form(self):
+ project = ProjectFactory()
+ ContractFactory(project=project)
+ response = self.get_page(project)
+ self.assertEqual(response.status_code, 200)
+
+ def test_post_creates_additional_contract(self):
+ from django.core.files.uploadedfile import SimpleUploadedFile
+
+ project = ProjectFactory(status=INVOICING_AND_REPORTING)
+ ContractFactory(project=project)
+ before = project.contracts.count()
+
+ response = self.post_page(
+ project,
+ {"file": SimpleUploadedFile("contract.pdf", b"contract-bytes")},
+ )
+ self.assertEqual(response.status_code, 200)
+ project.refresh_from_db()
+ # A new contract is attached; the project stage is unchanged (no
+ # transition: the first contract already moved the project on).
+ self.assertEqual(project.contracts.count(), before + 1)
+ self.assertEqual(project.status, INVOICING_AND_REPORTING)
+
+ def test_applicant_forbidden(self):
+ from django.core.files.uploadedfile import SimpleUploadedFile
+
+ applicant = ApplicantFactory()
+ self.client.force_login(applicant)
+ project = ProjectFactory(status=INVOICING_AND_REPORTING, user=applicant)
+ ContractFactory(project=project)
+ response = self.post_page(
+ project,
+ {"file": SimpleUploadedFile("contract.pdf", b"contract-bytes")},
+ )
self.assertEqual(response.status_code, 403)
diff --git a/hypha/apply/projects/urls.py b/hypha/apply/projects/urls.py
index 9181eaa217..1d81461c44 100644
--- a/hypha/apply/projects/urls.py
+++ b/hypha/apply/projects/urls.py
@@ -16,8 +16,13 @@
ChangeProjectstatusView,
ContractDocumentPrivateMediaView,
ContractPrivateMediaView,
+ CreateContractView,
+ CreateDisbursementView,
CreateInvoiceView,
+ DeleteDisbursementView,
DeleteInvoiceView,
+ DisbursementListView,
+ EditDisbursementView,
EditInvoiceView,
InvoiceListView,
InvoicePrivateMedia,
@@ -67,6 +72,7 @@
path("all/", ProjectListView.as_view(), name="all"),
path("reports/", include("hypha.apply.projects.reports.urls"), name="reports"),
path("invoices/", InvoiceListView.as_view(), name="invoices"),
+ path("disbursements/", DisbursementListView.as_view(), name="disbursements"),
path(
"invoices/export-status/",
invoice_export_status,
@@ -221,6 +227,11 @@
UploadContractView.as_view(),
name="contract_upload",
),
+ path(
+ "contract/add/",
+ CreateContractView.as_view(),
+ name="contract_add",
+ ),
path(
"partial/contract/documents/",
partial_contracting_documents,
@@ -261,6 +272,28 @@
ApproveContractView.as_view(),
name="contract_approve",
),
+ path(
+ "contract//disbursement/",
+ CreateDisbursementView.as_view(),
+ name="disbursement",
+ ),
+ path(
+ "contract//disbursements//",
+ include(
+ [
+ path(
+ "edit/",
+ EditDisbursementView.as_view(),
+ name="disbursement-edit",
+ ),
+ path(
+ "delete/",
+ DeleteDisbursementView.as_view(),
+ name="disbursement-delete",
+ ),
+ ]
+ ),
+ ),
path(
"download//",
ProjectDetailDownloadView.as_view(),
diff --git a/hypha/apply/projects/utils.py b/hypha/apply/projects/utils.py
index 8520ababeb..6367c542da 100644
--- a/hypha/apply/projects/utils.py
+++ b/hypha/apply/projects/utils.py
@@ -1,6 +1,9 @@
import csv
import io
+from datetime import timedelta
+from django.conf import settings
+from django.utils import timezone
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django_file_form.uploaded_file import PlaceholderUploadedFile
@@ -35,6 +38,7 @@
PROJECT_STATUS_CHOICES,
PAFReviewersRole,
)
+from .payments import PaymentsFlow
PROJECT_TITLE_TEMPLATE = "{text} #{application_id}"
@@ -185,3 +189,195 @@ def get_placeholder_file(initial_file):
PlaceholderUploadedFile(f.filename, size=f.size, file_id=f.name)
for f in initial_file
]
+
+
+def get_contract_approved_amounts_cell(project, format_amount):
+ """Render a project's contracts total cell.
+
+ Returns "Multiple currencies" when the amounts span more than one
+ currency code, an em dash when no approved amount is entered
+ (absence is not zero), and otherwise the formatted total with its
+ currency code. format_amount renders the Decimal total: locale-aware
+ for the table, locale-neutral for the CSV export.
+ """
+ if project.contract_approved_currency_count > 1:
+ return _("Multiple currencies")
+ total = project.contract_approved_total
+ if total is None:
+ return "—"
+ amount = format_amount(total)
+ if project.contract_approved_currency_code:
+ return f"{amount} {project.contract_approved_currency_code}"
+ return amount
+
+
+def get_disbursement_total_cell(project, format_amount):
+ """Render a project's disbursed total cell.
+
+ Returns "Multiple currencies" when the disbursements span more than
+ one currency code (a coded amount beside an uncoded one counts as
+ multiple), an em dash when a project has no disbursements (absence is
+ not zero), and otherwise the formatted total with its currency code.
+ format_amount renders the Decimal total: locale-aware for the table,
+ locale-neutral for the CSV export.
+ """
+ if project.disbursement_currency_count is None:
+ return "—"
+ total = project.disbursement_total
+ if total is None:
+ return _("Multiple currencies")
+ amount = format_amount(total)
+ if project.disbursement_currency_code:
+ return f"{amount} {project.disbursement_currency_code}"
+ return amount
+
+
+def export_projects_to_csv(projects_list, csv_stream):
+ # Lazy import: forms.project depends on modules that import this file.
+ from .forms.project import trimmed_decimal_string
+
+ include_disbursements_columns = (
+ settings.PROJECTS_PAYMENTS_FLOW == PaymentsFlow.DISBURSEMENTS
+ )
+
+ header_row = [
+ "Submission #",
+ "Title",
+ "Organization",
+ "Status",
+ "Lead",
+ "Fund",
+ "Round",
+ "Reporting",
+ "Last payment request",
+ ]
+ if include_disbursements_columns:
+ header_row.append("Contracts total")
+ header_row.append("Disbursed total")
+ header_row.append("End Date")
+ data_list = []
+
+ for project in projects_list:
+ reporting_status = ""
+ if hasattr(project, "report_config"):
+ if project.report_config.is_up_to_date():
+ reporting_status = "Up to date"
+ else:
+ reporting_status = (
+ f"{project.report_config.outstanding_reports()} outstanding"
+ )
+
+ values = {
+ "Submission #": project.submission.id,
+ "Title": project.title,
+ "Organization": project.submission.form_data.get("organization_name", ""),
+ "Status": project.get_status_display(),
+ "Lead": project.lead,
+ "Fund": project.submission.page,
+ "Round": project.submission.round,
+ "Reporting": reporting_status,
+ "Last payment request": project.last_payment_request,
+ }
+ if include_disbursements_columns:
+ values["Contracts total"] = get_contract_approved_amounts_cell(
+ project, trimmed_decimal_string
+ )
+ values["Disbursed total"] = get_disbursement_total_cell(
+ project, trimmed_decimal_string
+ )
+ values["End Date"] = project.end_date
+ data_list.append(values)
+ # Columns will be in header_row order with values matched by key in data_list (order within each notwithstanding).
+ writer = csv.DictWriter(csv_stream, fieldnames=header_row, restval="")
+ writer.writeheader()
+ for data in data_list:
+ writer.writerow(data)
+
+
+def format_trimmed_amount(value):
+ """Trimmed decimal string for table/CSV amounts; em dash when absent.
+
+ Mirrors the amount input so ledger values never gain or lose precision.
+
+ """
+ if value is None:
+ return "—"
+ # Lazy import: forms.project depends on modules that import this file.
+ from .forms.project import TrimmedDecimalInput
+
+ return TrimmedDecimalInput().format_value(value)
+
+
+def format_local_minute(value):
+ """Compact table display for entry datetimes: the active timezone (the
+ user's zone when a deployment activates one, else the server's), rounded
+ to the nearest minute, space separator, no zone suffix.
+
+ The CSV export keeps strict RFC 3339 (T separator, full precision).
+
+ """
+ if timezone.is_aware(value):
+ rounded = timezone.localtime(value).replace(tzinfo=None)
+ else:
+ rounded = value.replace(tzinfo=None)
+ if rounded.second >= 30:
+ rounded += timedelta(minutes=1)
+ return rounded.replace(second=0, microsecond=0).strftime("%Y-%m-%d %H:%M")
+
+
+def export_disbursements_to_csv(disbursements, csv_stream):
+ """Export disbursements (typically the filtered ledger queryset) to CSV.
+
+ One row per disbursement; the contract's requested/approved amounts
+ repeat on each of its rows, mirroring the table. The row leads with
+ submission # and project. Amounts are bare and trimmed with the currency
+ in its own column; dates are strict RFC 3339 (T separator, full
+ precision), including the full updated_at; absent contract amounts
+ export as empty strings.
+
+ """
+ header_row = [
+ "Submission #",
+ "Project",
+ "Disbursement date",
+ "Date recorded",
+ "Updated at",
+ "Contract",
+ "Contract requested",
+ "Contract approved",
+ "Currency",
+ "Amount disbursed",
+ "Notes",
+ ]
+ data_list = []
+
+ for disbursement in disbursements:
+ contract = disbursement.contract
+ project = contract.project
+ values = {
+ header_row[0]: project.submission_id,
+ header_row[1]: project.title,
+ header_row[2]: disbursement.date.isoformat(),
+ header_row[3]: disbursement.created_at.isoformat(),
+ header_row[4]: disbursement.updated_at.isoformat(),
+ header_row[5]: f"#{contract.pk}",
+ header_row[6]: (
+ format_trimmed_amount(contract.amount_requested)
+ if contract.amount_requested is not None
+ else ""
+ ),
+ header_row[7]: (
+ format_trimmed_amount(contract.amount_approved)
+ if contract.amount_approved is not None
+ else ""
+ ),
+ header_row[8]: contract.currency or "",
+ header_row[9]: format_trimmed_amount(disbursement.amount),
+ header_row[10]: disbursement.notes,
+ }
+ data_list.append(values)
+ # Columns will be in header_row order with values matched by key in data_list (order within each notwithstanding).
+ writer = csv.DictWriter(csv_stream, fieldnames=header_row, restval="")
+ writer.writeheader()
+ for data in data_list:
+ writer.writerow(data)
diff --git a/hypha/apply/projects/views/__init__.py b/hypha/apply/projects/views/__init__.py
index 318de5e34c..08fe9d06d0 100644
--- a/hypha/apply/projects/views/__init__.py
+++ b/hypha/apply/projects/views/__init__.py
@@ -1,3 +1,9 @@
+from .disbursement import (
+ CreateDisbursementView,
+ DeleteDisbursementView,
+ DisbursementListView,
+ EditDisbursementView,
+)
from .invoice import (
BatchUpdateInvoiceStatusView,
ChangeInvoiceStatusView,
@@ -21,6 +27,7 @@
ChangeProjectstatusView,
ContractDocumentPrivateMediaView,
ContractPrivateMediaView,
+ CreateContractView,
ProjectDetailApprovalView,
ProjectDetailDownloadView,
ProjectDetailView,
@@ -92,6 +99,7 @@
"UpdateLeadView",
"ApproveContractView",
"UploadContractView",
+ "CreateContractView",
"ContractDocumentPrivateMediaView",
"BaseProjectDetailView",
"AdminProjectDetailView",
@@ -116,4 +124,8 @@
"TagInvoiceView",
"invoice_export_status",
"invoice_export_download",
+ "CreateDisbursementView",
+ "EditDisbursementView",
+ "DeleteDisbursementView",
+ "DisbursementListView",
]
diff --git a/hypha/apply/projects/views/disbursement.py b/hypha/apply/projects/views/disbursement.py
new file mode 100644
index 0000000000..1acc8ac64e
--- /dev/null
+++ b/hypha/apply/projects/views/disbursement.py
@@ -0,0 +1,226 @@
+"""Create, update, and delete views for Disbursement (staff/finance only).
+
+Disbursements are entered in a modal on the project page; on save the user is
+returned to the project page (see ``get_success_url``). An activity/messenger
+notice is emitted for each create/update/delete; the amount is included in the
+notice, the note is not.
+
+"""
+
+from django.conf import settings
+from django.contrib.messages.views import SuccessMessageMixin
+from django.db import transaction
+from django.db.models import F, Sum
+from django.http import Http404, HttpResponse
+from django.shortcuts import get_object_or_404
+from django.utils.decorators import method_decorator
+from django.utils.translation import gettext as _
+from django.views.generic import CreateView, DeleteView, UpdateView
+from django_filters.views import FilterView
+from django_tables2 import SingleTableMixin
+
+from hypha.apply.activity.messaging import MESSAGES, messenger
+from hypha.apply.users.decorators import (
+ staff_admin_required,
+ staff_or_finance_required,
+)
+
+from ..filters import DisbursementListFilter
+from ..forms import DisbursementForm
+from ..models import Disbursement, Project
+from ..payments import PaymentsFlow
+from ..tables import AdminDisbursementListTable
+from ..utils import export_disbursements_to_csv, format_trimmed_amount
+
+
+@method_decorator(staff_or_finance_required, name="dispatch")
+class CreateDisbursementView(SuccessMessageMixin, CreateView):
+ model = Disbursement
+ form_class = DisbursementForm
+ template_name = "application_projects/modals/disbursement_form.html"
+ success_message = _("Disbursement added")
+
+ def dispatch(self, request, *args, **kwargs):
+ self.project = get_object_or_404(Project, pk=kwargs["pk"])
+ self.contract = get_object_or_404(
+ self.project.contracts.all(), pk=kwargs["contract_pk"]
+ )
+ return super().dispatch(request, *args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ return super().get_context_data(
+ contract=self.contract,
+ project=self.project,
+ value=_("Save"),
+ **kwargs,
+ )
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs["contract"] = self.contract
+ return kwargs
+
+ def form_valid(self, form):
+ form.instance.contract = self.contract
+ form.instance.created_by = self.request.user
+ form.instance.updated_by = self.request.user
+ response = super().form_valid(form)
+ messenger(
+ MESSAGES.CREATE_DISBURSEMENT,
+ request=self.request,
+ user=self.request.user,
+ source=self.project,
+ related=self.object,
+ )
+ return response
+
+ def get_success_url(self):
+ return self.project.get_absolute_url()
+
+
+@method_decorator(staff_or_finance_required, name="dispatch")
+class EditDisbursementView(SuccessMessageMixin, UpdateView):
+ model = Disbursement
+ form_class = DisbursementForm
+ template_name = "application_projects/modals/disbursement_form.html"
+ success_message = _("Disbursement updated")
+
+ def dispatch(self, request, *args, **kwargs):
+ self.project = get_object_or_404(Project, pk=kwargs["pk"])
+ self.contract = get_object_or_404(
+ self.project.contracts.all(), pk=kwargs["contract_pk"]
+ )
+ return super().dispatch(request, *args, **kwargs)
+
+ def get_object(self, queryset=None):
+ return get_object_or_404(
+ self.contract.disbursements.all(), pk=self.kwargs["disbursement_pk"]
+ )
+
+ def get_context_data(self, **kwargs):
+ return super().get_context_data(
+ contract=self.contract,
+ project=self.project,
+ value=_("Update"),
+ **kwargs,
+ )
+
+ def get_form_kwargs(self):
+ kwargs = super().get_form_kwargs()
+ kwargs["contract"] = self.contract
+ return kwargs
+
+ def form_valid(self, form):
+ form.instance.updated_by = self.request.user
+ response = super().form_valid(form)
+ messenger(
+ MESSAGES.UPDATE_DISBURSEMENT,
+ request=self.request,
+ user=self.request.user,
+ source=self.project,
+ related=self.object,
+ )
+ return response
+
+ def get_success_url(self):
+ return self.project.get_absolute_url()
+
+
+@method_decorator(staff_or_finance_required, name="dispatch")
+class DeleteDisbursementView(DeleteView):
+ model = Disbursement
+ template_name = "application_projects/modals/disbursement_confirm_delete.html"
+
+ def dispatch(self, request, *args, **kwargs):
+ self.project = get_object_or_404(Project, pk=kwargs["pk"])
+ self.contract = get_object_or_404(
+ self.project.contracts.all(), pk=kwargs["contract_pk"]
+ )
+ return super().dispatch(request, *args, **kwargs)
+
+ def get_object(self, queryset=None):
+ return get_object_or_404(
+ self.contract.disbursements.all(), pk=self.kwargs["disbursement_pk"]
+ )
+
+ @transaction.atomic
+ def form_valid(self, form):
+ # Capture before super().form_valid() deletes the instance; the
+ # activity notice includes the amount, which is still on the object.
+ disbursement = self.object
+ response = super().form_valid(form)
+ messenger(
+ MESSAGES.DELETE_DISBURSEMENT,
+ request=self.request,
+ user=self.request.user,
+ source=self.project,
+ related=disbursement,
+ )
+ return response
+
+ def get_success_url(self):
+ return self.project.get_absolute_url()
+
+
+@method_decorator(staff_admin_required, name="dispatch")
+class DisbursementListView(SingleTableMixin, FilterView):
+ """The admin ledger: every disbursement across every project.
+
+ Admin-only (Staff Admin group or superuser) because it exposes every
+ project's money movement in one place. Gated on the DISBURSEMENTS
+ payments flow, like the per-project disbursements section. The table
+ filters on the disbursement's own date, not the date it was entered, so
+ financial reports can be generated for particular periods; ``?format=csv``
+ exports the filtered rows.
+
+ """
+
+ filterset_class = DisbursementListFilter
+ queryset = Disbursement.objects.select_related("contract__project__submission")
+ table_class = AdminDisbursementListTable
+ template_name = "application_projects/disbursement_list.html"
+
+ def dispatch(self, request, *args, **kwargs):
+ if settings.PROJECTS_PAYMENTS_FLOW != PaymentsFlow.DISBURSEMENTS:
+ raise Http404("Disbursements are not the active payments flow")
+ return super().dispatch(request, *args, **kwargs)
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ # The CSV response discards page context, so skip the totals query.
+ if self.request.GET.get("format") != "csv":
+ context["disbursements_total"] = self.get_total_display()
+ return context
+
+ def get_total_display(self):
+ """Amount/currency pairs summed over the filtered rows, one pair per
+ currency (uncoded rows form their own pair without a code), e.g.
+ "499.03 USD, 809.03 MXN". Value only: the template renders it as
+ "Total disbursed (N records matching this filter)" between the
+ filters and the table. Table rows and per-project displays keep a
+ single numeric value ("Multiple currencies" when needed); only this
+ page summary elaborates every pair.
+
+ """
+ currency_groups = (
+ self.object_list.order_by()
+ .values("contract__currency")
+ .annotate(total=Sum("amount"))
+ .order_by(F("contract__currency").asc(nulls_last=True))
+ )
+ pairs = []
+ for group in currency_groups:
+ amount = format_trimmed_amount(group["total"])
+ currency = group["contract__currency"]
+ pairs.append(f"{amount} {currency}" if currency else amount)
+ return ", ".join(pairs)
+
+ def render_to_response(self, context, **response_kwargs):
+ if self.request.GET.get("format") == "csv":
+ response = HttpResponse(content_type="text/csv")
+ response["Content-Disposition"] = 'attachment; filename="disbursements.csv"'
+ # table.data.data carries the table's applied ordering (e.g.
+ # ?sort=), so the export matches what the user sees.
+ export_disbursements_to_csv(context["table"].data.data, response)
+ return response
+ return super().render_to_response(context, **response_kwargs)
diff --git a/hypha/apply/projects/views/project.py b/hypha/apply/projects/views/project.py
index 704b405ffd..512e6de558 100644
--- a/hypha/apply/projects/views/project.py
+++ b/hypha/apply/projects/views/project.py
@@ -8,10 +8,17 @@
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import UserPassesTestMixin
from django.contrib.auth.models import Group
+from django.contrib.messages.views import SuccessMessageMixin
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction
from django.db.models import Q
-from django.http import FileResponse, Http404, HttpResponse, HttpResponseRedirect
+from django.http import (
+ FileResponse,
+ Http404,
+ HttpResponse,
+ HttpResponseForbidden,
+ HttpResponseRedirect,
+)
from django.shortcuts import get_object_or_404, render
from django.template.loader import get_template
from django.urls import reverse
@@ -62,6 +69,7 @@
)
from hypha.apply.users.decorators import (
staff_or_finance_or_contracting_required,
+ staff_or_finance_required,
staff_required,
)
from hypha.apply.users.roles import CONTRACTING_GROUP_NAME
@@ -82,6 +90,7 @@
ProjectSOWForm,
SetPendingForm,
SkipPAFApprovalProcessForm,
+ StaffUploadContractForm,
SubmitContractDocumentsForm,
UpdateProjectContractNumberForm,
UpdateProjectDatesForm,
@@ -113,6 +122,7 @@
from ..reports.views import ReportingMixin
from ..tables import ProjectsListTable
from ..utils import (
+ export_projects_to_csv,
get_paf_status_display,
get_placeholder_file,
get_project_status_choices,
@@ -617,7 +627,7 @@ def get_context_data(self, **kwargs):
@method_decorator(staff_required, name="dispatch")
-class ApproveContractView(ProjectByIdMixin, View):
+class ApproveContractView(ProjectByIdMixin, UpdateView):
form_class = ApproveContractForm
model = Contract
template_name = "application_projects/modals/approve_contract.html"
@@ -724,80 +734,81 @@ def dispatch(self, request, *args, **kwargs):
)
return super().dispatch(request, *args, **kwargs)
- def get(self, *args, **kwargs):
- form = self.get_form()
+ def get(self, request, *args, **kwargs):
+ # Get the existing contract if it exists
+ existing_contract = self.project.contracts.order_by("created_at").first()
+
+ # Pass instance explicitly. This is the KEY to making edits work with FileFormMixin.
+ form = self.form_class(instance=existing_contract, user=request.user)
+
return render(
- self.request,
+ request,
self.template_name,
context={
"form": form,
- "user": self.request.user,
- "value": _("Upload") if self.request.user.is_applicant else _("Submit"),
+ "user": request.user,
+ "value": _("Upload") if request.user.is_applicant else _("Submit"),
"object": self.project,
},
)
- def get_form_kwargs(self):
- kwargs = super().get_form_kwargs()
- kwargs.pop("instance")
- kwargs.pop("user")
- return kwargs
+ def post(self, request, *args, **kwargs):
+ old_stage = self.project.status
- def get_form(self, *args, **kwargs):
- form = self.form_class(*args, **kwargs)
- if self.request.user.is_applicant:
- form.fields.pop("signed_and_approved")
- return form
+ # 1. Determine the contract instance
+ existing_contract = self.project.contracts.order_by("created_at").first()
- def post(self, *args, **kwargs):
- form = self.get_form(self.request.POST)
+ # 2. Instantiate form with POST data AND the instance
+ # This allows ModelForm to:
+ # a) Validate against the existing DB state
+ # b) Populate initial data for FileFormMixin
+ # c) Set form.instance to the existing object for editing
+ form = self.form_class(
+ request.POST, instance=existing_contract, user=request.user
+ )
if form.is_valid():
- if self.project.contracts.exists():
- form.instance = self.project.contracts.order_by("created_at").first()
-
+ # 3. Assign common fields
form.instance.project = self.project
- if self.request.user.is_applicant:
+ # 4. Handle user-specific logic
+ if request.user.is_applicant:
form.instance.signed_by_applicant = True
form.instance.uploaded_by_applicant_at = timezone.now()
- messages.success(self.request, _("Countersigned contract uploaded"))
- elif self.request.user.is_contracting or self.request.user.is_apply_staff:
+ messages.success(request, _("Countersigned contract uploaded"))
+ else:
form.instance.uploaded_by_contractor_at = timezone.now()
- messages.success(self.request, _("Signed contract uploaded"))
-
- form.save()
+ messages.success(request, _("Signed contract uploaded"))
- contract_signed_and_approved = form.cleaned_data.get("signed_and_approved")
- if contract_signed_and_approved:
- form.instance.approver = self.request.user
+ # 5. Handle approval logic
+ if form.cleaned_data.get("signed_and_approved"):
+ form.instance.approver = request.user
form.instance.approved_at = timezone.now()
- form.instance.signed_and_approved = contract_signed_and_approved
+ form.instance.signed_and_approved = True
form.instance.signed_by_applicant = True
- form.instance.save(
- update_fields=[
- "approver",
- "approved_at",
- "signed_and_approved",
- "signed_by_applicant",
- ]
- )
+ # 6. Save ONCE.
+ # Django ModelForm.save() automatically maps cleaned_data to instance
+ # and saves if commit=True (default).
+ form.save()
+
+ # 7. Handle Project status transition
+ if form.cleaned_data.get("signed_and_approved"):
self.project.status = INVOICING_AND_REPORTING
self.project.save(update_fields=["status"])
- old_stage = CONTRACTING
if settings.PROJECTS_START_AFTER_CONTRACTING:
self.project.proposed_start = datetime.date.today()
- self.project.save()
+ self.project.save(update_fields=["proposed_start"])
messenger(
MESSAGES.PROJECT_TRANSITION,
- request=self.request,
- user=self.request.user,
+ request=request,
+ user=request.user,
source=self.project,
related=old_stage,
)
+
# remove Project waiting contract task for contracting/staff group
if settings.STAFF_UPLOAD_CONTRACT:
remove_tasks_for_user(
@@ -818,11 +829,11 @@ def post(self, *args, **kwargs):
related_obj=self.project,
)
else:
- if self.request.user != self.project.user:
+ if request.user != self.project.user:
messenger(
MESSAGES.UPLOAD_CONTRACT,
- request=self.request,
- user=self.request.user,
+ request=request,
+ user=request.user,
source=self.project,
related=form.instance,
)
@@ -849,13 +860,14 @@ def post(self, *args, **kwargs):
)
return HttpResponseClientRefresh()
+ # If invalid, re-render with errors
return render(
- self.request,
+ request,
self.template_name,
context={
"form": form,
- "user": self.request.user,
- "value": _("Upload") if self.request.user.is_applicant else _("Submit"),
+ "user": request.user,
+ "value": _("Upload") if request.user.is_applicant else _("Submit"),
"object": self.project,
},
)
@@ -994,6 +1006,36 @@ def post(self, *args, **kwargs):
)
+@method_decorator(staff_or_finance_required, name="dispatch")
+class CreateContractView(SuccessMessageMixin, ProjectByIdMixin, CreateView):
+ """Add a further contract from the Contracts and Disbursements section
+ (DISBURSEMENTS model). The first contract still goes through the
+ contracting flow, so this view performs no stage transition.
+
+ """
+
+ form_class = StaffUploadContractForm
+ model = Contract
+ template_name = "application_projects/modals/contract_form.html"
+ success_message = _("Contract added")
+
+ def dispatch(self, request, *args, **kwargs):
+ self.project = self.get_object()
+ if has_object_permission("add_contract", request.user, obj=self.project):
+ return super().dispatch(request, *args, **kwargs)
+ return HttpResponseForbidden()
+
+ def form_valid(self, form):
+ form.instance.project = self.project
+ return super().form_valid(form)
+
+ def get_context_data(self, **kwargs):
+ return super().get_context_data(project=self.project, value=_("Save"), **kwargs)
+
+ def get_success_url(self):
+ return self.project.get_absolute_url()
+
+
@method_decorator(login_required, name="dispatch")
class UploadContractDocumentView(ProjectByIdMixin, View):
form_class = UploadContractDocumentForm
@@ -2401,12 +2443,25 @@ def post(self, request, *args, **kwargs):
@method_decorator(staff_or_finance_or_contracting_required, name="dispatch")
class ProjectListView(SingleTableMixin, FilterView):
filterset_class = ProjectListFilter
- queryset = Project.objects.for_table()
table_class = ProjectsListTable
template_name = "application_projects/project_list.html"
excluded_fields = settings.PROJECTS_TABLE_EXCLUDED_FIELDS
+ def get_queryset(self):
+ # Built per request so the contracts total follows the active
+ # PROJECTS_PAYMENTS_FLOW instead of the import-time setting.
+ return Project.objects.for_table()
+
+ def render_to_response(self, context, **response_kwargs):
+ if self.request.GET.get("format") == "csv":
+ response = HttpResponse(content_type="text/csv")
+ response["Content-Disposition"] = 'attachment; filename="projects.csv"'
+ export_projects_to_csv(context["object_list"], response)
+ return response
+ else:
+ return super().render_to_response(context, **response_kwargs)
+
def get_filterset_kwargs(self, filterset_class, **kwargs):
new_kwargs = super().get_filterset_kwargs(filterset_class)
new_kwargs["exclude"] = self.excluded_fields
diff --git a/hypha/core/navigation.py b/hypha/core/navigation.py
index ebd84a65eb..9fbcbc172a 100644
--- a/hypha/core/navigation.py
+++ b/hypha/core/navigation.py
@@ -8,6 +8,8 @@
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
+from hypha.apply.projects.payments import PaymentsFlow
+
logger = logging.getLogger(__name__)
@@ -47,6 +49,34 @@ def _calculate_is_active(
def get_primary_navigation_items(request):
+ projects_sub_items = [
+ {
+ "title": _("All Projects"),
+ "url": reverse_lazy("apply:projects:all"),
+ "permission_method": "hypha.apply.users.decorators.is_apply_staff_or_finance_or_contracting_or_reviewer",
+ },
+ {
+ "title": _("Reporting"),
+ "url": reverse_lazy("apply:projects:reporting"),
+ "permission_method": "hypha.apply.users.decorators.is_apply_staff_or_finance_or_contracting_or_reviewer",
+ },
+ {
+ "title": _("Reports"),
+ "url": reverse_lazy("apply:projects:reports:submitted"),
+ "permission_method": "hypha.apply.users.decorators.is_apply_staff_or_finance_or_contracting_or_reviewer",
+ },
+ ]
+ # The disbursements ledger exists only under the DISBURSEMENTS payments
+ # flow and is admin-only, like the page itself.
+ if settings.PROJECTS_PAYMENTS_FLOW == PaymentsFlow.DISBURSEMENTS:
+ projects_sub_items.append(
+ {
+ "title": _("All Disbursements"),
+ "url": reverse_lazy("apply:projects:disbursements"),
+ "permission_method": "hypha.apply.users.decorators.is_apply_staff_admin",
+ }
+ )
+
DEFAULT_NAV_ITEMS = [
{
"title": _("My Dashboard"),
@@ -97,23 +127,7 @@ def get_primary_navigation_items(request):
"url": reverse_lazy("apply:projects:all"),
"permission_method": "hypha.apply.users.decorators.is_apply_staff_or_finance_or_contracting",
"active_url_regex": r"(.*)/projects?/",
- "sub_items": [
- {
- "title": _("All Projects"),
- "url": reverse_lazy("apply:projects:all"),
- "permission_method": "hypha.apply.users.decorators.is_apply_staff_or_finance_or_contracting",
- },
- {
- "title": _("Invoices"),
- "url": reverse_lazy("apply:projects:invoices"),
- "permission_method": "hypha.apply.users.decorators.is_apply_staff_or_finance",
- },
- {
- "title": _("Reports"),
- "url": reverse_lazy("apply:projects:reports:submitted"),
- "permission_method": "hypha.apply.users.decorators.is_apply_staff_or_finance",
- },
- ],
+ "sub_items": projects_sub_items,
},
]
diff --git a/hypha/settings/base.py b/hypha/settings/base.py
index 9fd3f5e489..0110af7c96 100644
--- a/hypha/settings/base.py
+++ b/hypha/settings/base.py
@@ -114,6 +114,15 @@
# Allow a submission to have multiple projects associated.
PROJECTS_ALLOW_MULTIPLE = env.bool("PROJECTS_ALLOW_MULTIPLE", False)
+# Which payments flow a deployment uses: "INVOICING" (upstream's approval-based
+# invoice system, the default), "DISBURSEMENTS" (the simplified per-contract
+# disbursement ledger), or "DISABLED" (neither). The project detail page gates
+# the invoices section on INVOICING and the disbursements section on
+# DISBURSEMENTS; DISABLED shows neither. The ARDC fork sets DISBURSEMENTS in
+# hypha/settings/ardc.py. Valid values are defined in
+# hypha.apply.projects.payments.PaymentsFlow.
+PROJECTS_PAYMENTS_FLOW = env.str("PROJECTS_PAYMENTS_FLOW", "INVOICING")
+
# Default status for projects, must be a string literal of "draft" (default), "contracting", "invoicing_and_reporting" or "closing"
# Will be used for auto-create or be the default selection in the project creation form
PROJECTS_DEFAULT_STATUS = env.str("PROJECTS_DEFAULT_STATUS", "draft")
diff --git a/hypha/templates/django/forms/widgets/number_signed.html b/hypha/templates/django/forms/widgets/number_signed.html
new file mode 100644
index 0000000000..ad9c4c4e4f
--- /dev/null
+++ b/hypha/templates/django/forms/widgets/number_signed.html
@@ -0,0 +1,27 @@
+{% load i18n %}
+{% comment %}
+Signed number input: like number.html but allows an optional leading minus
+so disbursements can record repayments. Scoped to the disbursement amount;
+the shared number.html stays positive-only for the rest of the codebase.
+{% endcomment %}
+
|