From 91867bf16d0df108849db6eecd626a4d28679ca0 Mon Sep 17 00:00:00 2001 From: Thomas Koopman Date: Tue, 25 Feb 2025 12:05:53 +0100 Subject: [PATCH 1/5] Add budget preapproved field and payment status choice "requested" --- ...002_invoice_budget_preapproved_and_more.py | 35 +++++++++++++++++++ app/grandchallenge/invoices/models.py | 7 ++++ 2 files changed, 42 insertions(+) create mode 100644 app/grandchallenge/invoices/migrations/0002_invoice_budget_preapproved_and_more.py diff --git a/app/grandchallenge/invoices/migrations/0002_invoice_budget_preapproved_and_more.py b/app/grandchallenge/invoices/migrations/0002_invoice_budget_preapproved_and_more.py new file mode 100644 index 0000000000..b7c6e2a0cb --- /dev/null +++ b/app/grandchallenge/invoices/migrations/0002_invoice_budget_preapproved_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.19 on 2025-02-25 11:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("invoices", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="invoice", + name="budget_preapproved", + field=models.BooleanField( + default=False, + help_text="Preapproval will make the budget immediately available, regardless of the payment status, but only if there is another invoice in the paid state.", + ), + ), + migrations.AlterField( + model_name="invoice", + name="payment_status", + field=models.CharField( + choices=[ + ("INITIALIZED", "Initialized"), + ("REQUESTED", "Invoice Requested"), + ("ISSUED", "Issued"), + ("COMPLIMENTARY", "Complimentary"), + ("PAID", "Paid"), + ], + default="INITIALIZED", + max_length=13, + ), + ), + ] diff --git a/app/grandchallenge/invoices/models.py b/app/grandchallenge/invoices/models.py index a5e69f7310..8d40ddeacd 100644 --- a/app/grandchallenge/invoices/models.py +++ b/app/grandchallenge/invoices/models.py @@ -3,6 +3,7 @@ class PaymentStatusChoices(models.TextChoices): INITIALIZED = "INITIALIZED", "Initialized" + REQUESTED = "REQUESTED", "Invoice Requested" ISSUED = "ISSUED", "Issued" COMPLIMENTARY = "COMPLIMENTARY", "Complimentary" PAID = "PAID", "Paid" @@ -76,3 +77,9 @@ class Invoice(models.Model): choices=PaymentStatusChoices.choices, default=PaymentStatusChoices.INITIALIZED, ) + budget_preapproved = models.BooleanField( + default=False, + help_text="Preapproval will make the budget immediately available, " + "regardless of the payment status, but only if there is another " + "invoice in the paid state.", + ) From 98b356b7369236dca1a657f44a68441b4d887e39 Mon Sep 17 00:00:00 2001 From: Thomas Koopman Date: Tue, 25 Feb 2025 14:09:55 +0100 Subject: [PATCH 2/5] Split up existing test --- app/tests/invoices_tests/test_models.py | 128 +++++++++++++++++------- 1 file changed, 91 insertions(+), 37 deletions(-) diff --git a/app/tests/invoices_tests/test_models.py b/app/tests/invoices_tests/test_models.py index c8b9e388e3..ba2d077c75 100644 --- a/app/tests/invoices_tests/test_models.py +++ b/app/tests/invoices_tests/test_models.py @@ -7,97 +7,151 @@ from tests.invoices_tests.factories import InvoiceFactory -@pytest.mark.flaky(reruns=3) @pytest.mark.django_db -def test_approved_compute_costs_euro_millicents(): - c1, c2, c3, c4 = ChallengeFactory.create_batch(4) +def test_approved_compute_costs_euro_millicents_no_invoices(): + ChallengeFactory() + expected_budget = 0 + + challenge = Challenge.objects.with_available_compute().first() + assert ( + challenge.approved_compute_costs_euro_millicents + == expected_budget * 1000 * 100 + ) + + +@pytest.mark.django_db +def test_approved_compute_costs_euro_millicents_paid_invoice(): + challenge = ChallengeFactory() + expected_budget = 1 InvoiceFactory( - challenge=c1, + challenge=challenge, support_costs_euros=0, - compute_costs_euros=1, + compute_costs_euros=expected_budget, storage_costs_euros=0, payment_status=PaymentStatusChoices.PAID, ) + challenge = Challenge.objects.with_available_compute().first() + assert ( + challenge.approved_compute_costs_euro_millicents + == expected_budget * 1000 * 100 + ) + + +@pytest.mark.django_db +def test_approved_compute_costs_euro_millicents_complimentary_invoices(): + challenge = ChallengeFactory() + expected_budget = 20 + InvoiceFactory( - challenge=c2, + challenge=challenge, support_costs_euros=0, compute_costs_euros=10, storage_costs_euros=0, payment_status=PaymentStatusChoices.COMPLIMENTARY, ) InvoiceFactory( - challenge=c2, + challenge=challenge, support_costs_euros=0, compute_costs_euros=10, storage_costs_euros=0, payment_status=PaymentStatusChoices.COMPLIMENTARY, ) + challenge = Challenge.objects.with_available_compute().first() + assert ( + challenge.approved_compute_costs_euro_millicents + == expected_budget * 1000 * 100 + ) + + +@pytest.mark.django_db +def test_approved_compute_costs_euro_millicents_filter_invoices(): + challenge = ChallengeFactory() + expected_budget = 20 + InvoiceFactory( - challenge=c3, + challenge=challenge, support_costs_euros=50, compute_costs_euros=0, storage_costs_euros=0, payment_status=PaymentStatusChoices.PAID, ) InvoiceFactory( - challenge=c3, + challenge=challenge, support_costs_euros=50, compute_costs_euros=10, storage_costs_euros=0, payment_status=PaymentStatusChoices.ISSUED, ) InvoiceFactory( - challenge=c3, + challenge=challenge, support_costs_euros=50, compute_costs_euros=10, storage_costs_euros=0, payment_status=PaymentStatusChoices.INITIALIZED, ) InvoiceFactory( - challenge=c3, + challenge=challenge, support_costs_euros=50, - compute_costs_euros=30, + compute_costs_euros=expected_budget, storage_costs_euros=0, payment_status=PaymentStatusChoices.PAID, ) InvoiceFactory( - challenge=c3, + challenge=challenge, support_costs_euros=50, compute_costs_euros=30, storage_costs_euros=0, payment_status=PaymentStatusChoices.ISSUED, ) InvoiceFactory( - challenge=c3, + challenge=challenge, support_costs_euros=50, compute_costs_euros=0, storage_costs_euros=0, payment_status=PaymentStatusChoices.COMPLIMENTARY, ) - p2a = PhaseFactory(challenge=c2) - SubmissionFactory(phase=p2a) - p2b = PhaseFactory(challenge=c2) - SubmissionFactory(phase=p2b) - s2 = SubmissionFactory(phase=p2b) - - p4 = PhaseFactory(challenge=c4) - s4 = SubmissionFactory(phase=p4) - - for challenge, expected_budget, expected_datetime in zip( - Challenge.objects.filter(pk__in=[c1.pk, c2.pk, c3.pk, c4.pk]) - .order_by("short_name") - .with_available_compute() - .with_most_recent_submission_datetime(), - [1, 20, 30, 0], - [None, s2.created, None, s4.created], - strict=True, - ): - assert ( - challenge.approved_compute_costs_euro_millicents - == expected_budget * 1000 * 100 - ) - assert challenge.most_recent_submission_datetime == expected_datetime + challenge = Challenge.objects.with_available_compute().first() + assert ( + challenge.approved_compute_costs_euro_millicents + == expected_budget * 1000 * 100 + ) + + +@pytest.mark.django_db +def test_most_recent_submission_datetime_no_submissions(): + ChallengeFactory() + + challenge = ( + Challenge.objects.with_most_recent_submission_datetime().first() + ) + assert challenge.most_recent_submission_datetime is None + + +@pytest.mark.django_db +def test_most_recent_submission_datetime_single_submission(): + submission = SubmissionFactory() + + challenge = ( + Challenge.objects.with_most_recent_submission_datetime().first() + ) + assert challenge.most_recent_submission_datetime == submission.created + + +@pytest.mark.django_db +def test_most_recent_submission_datetime_multiple_submissions(): + challenge = ChallengeFactory() + + phase1 = PhaseFactory(challenge=challenge) + SubmissionFactory(phase=phase1) + phase2 = PhaseFactory(challenge=challenge) + SubmissionFactory(phase=phase2) + last_submission = SubmissionFactory(phase=phase2) + + challenge = ( + Challenge.objects.with_most_recent_submission_datetime().first() + ) + assert challenge.most_recent_submission_datetime == last_submission.created From 6df51b8d8c1815c231d61b656f24828667995de1 Mon Sep 17 00:00:00 2001 From: Thomas Koopman Date: Tue, 25 Feb 2025 14:29:41 +0100 Subject: [PATCH 3/5] Add tests for budget_preapproved --- app/tests/invoices_tests/test_models.py | 106 ++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/app/tests/invoices_tests/test_models.py b/app/tests/invoices_tests/test_models.py index ba2d077c75..afc6300280 100644 --- a/app/tests/invoices_tests/test_models.py +++ b/app/tests/invoices_tests/test_models.py @@ -121,6 +121,112 @@ def test_approved_compute_costs_euro_millicents_filter_invoices(): ) +@pytest.mark.django_db +def test_approved_compute_costs_budget_preapproved_no_paid_invoices(): + InvoiceFactory( + support_costs_euros=0, + compute_costs_euros=1, + storage_costs_euros=0, + payment_status=PaymentStatusChoices.INITIALIZED, + budget_preapproved=True, + ) + + challenge = Challenge.objects.with_available_compute().first() + assert challenge.approved_compute_costs_euro_millicents == 0 + + +@pytest.mark.django_db +def test_approved_compute_costs_budget_preapproved_only_complimentary_invoice(): + challenge = ChallengeFactory() + InvoiceFactory( + challenge=challenge, + support_costs_euros=0, + compute_costs_euros=1, + storage_costs_euros=0, + payment_status=PaymentStatusChoices.INITIALIZED, + budget_preapproved=True, + ) + InvoiceFactory( + challenge=challenge, + support_costs_euros=0, + compute_costs_euros=1, + storage_costs_euros=0, + payment_status=PaymentStatusChoices.COMPLIMENTARY, + ) + + challenge = Challenge.objects.with_available_compute().first() + assert challenge.approved_compute_costs_euro_millicents == 1 * 1000 * 100 + + +@pytest.mark.django_db +def test_approved_compute_costs_budget_preapproved_with_paid_invoice(): + challenge = ChallengeFactory() + InvoiceFactory( + challenge=challenge, + support_costs_euros=0, + compute_costs_euros=1, + storage_costs_euros=0, + payment_status=PaymentStatusChoices.INITIALIZED, + budget_preapproved=True, + ) + InvoiceFactory( + challenge=challenge, + support_costs_euros=0, + compute_costs_euros=1, + storage_costs_euros=0, + payment_status=PaymentStatusChoices.PAID, + ) + + challenge = Challenge.objects.with_available_compute().first() + assert challenge.approved_compute_costs_euro_millicents == 2 * 1000 * 100 + + +@pytest.mark.django_db +def test_approved_compute_costs_budget_preapproved_and_complimentary(): + challenge = ChallengeFactory() + InvoiceFactory( + challenge=challenge, + support_costs_euros=0, + compute_costs_euros=1, + storage_costs_euros=0, + payment_status=PaymentStatusChoices.COMPLIMENTARY, + budget_preapproved=True, + ) + InvoiceFactory( + challenge=challenge, + support_costs_euros=0, + compute_costs_euros=1, + storage_costs_euros=0, + payment_status=PaymentStatusChoices.COMPLIMENTARY, + ) + + challenge = Challenge.objects.with_available_compute().first() + assert challenge.approved_compute_costs_euro_millicents == 2 * 1000 * 100 + + +@pytest.mark.django_db +def test_approved_compute_costs_budget_preapproved_and_paid(): + challenge = ChallengeFactory() + InvoiceFactory( + challenge=challenge, + support_costs_euros=0, + compute_costs_euros=1, + storage_costs_euros=0, + payment_status=PaymentStatusChoices.PAID, + budget_preapproved=True, + ) + InvoiceFactory( + challenge=challenge, + support_costs_euros=0, + compute_costs_euros=1, + storage_costs_euros=0, + payment_status=PaymentStatusChoices.PAID, + ) + + challenge = Challenge.objects.with_available_compute().first() + assert challenge.approved_compute_costs_euro_millicents == 2 * 1000 * 100 + + @pytest.mark.django_db def test_most_recent_submission_datetime_no_submissions(): ChallengeFactory() From 4b173c17074f5134043fbde9773cf2e420911498 Mon Sep 17 00:00:00 2001 From: Thomas Koopman Date: Tue, 25 Feb 2025 14:37:21 +0100 Subject: [PATCH 4/5] Include budget_preapproved in approved compute costs annotation --- app/grandchallenge/challenges/models.py | 49 +++++++++++++++++++++---- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/app/grandchallenge/challenges/models.py b/app/grandchallenge/challenges/models.py index 62cb9b8e81..979fd361c3 100644 --- a/app/grandchallenge/challenges/models.py +++ b/app/grandchallenge/challenges/models.py @@ -102,20 +102,56 @@ class ChallengeSet(models.QuerySet): def with_available_compute(self): return self.annotate( - approved_compute_costs_euro_millicents=( + complimentary_compute_costs=( Sum( "invoices__compute_costs_euros", filter=Q( - invoices__payment_status__in=[ - PaymentStatusChoices.COMPLIMENTARY, - PaymentStatusChoices.PAID, - ] + invoices__payment_status=PaymentStatusChoices.COMPLIMENTARY ), output_field=models.PositiveBigIntegerField(), default=0, ) + ), + paid_compute_costs=( + Sum( + "invoices__compute_costs_euros", + filter=Q( + invoices__payment_status=PaymentStatusChoices.PAID + ), + output_field=models.PositiveBigIntegerField(), + default=0, + ) + ), + preapproved_compute_costs_if_anything_paid=( + Case( + When( + paid_compute_costs__gt=0, + then=Sum( + "invoices__compute_costs_euros", + filter=Q(invoices__budget_preapproved=True) + & ~Q( + invoices__payment_status__in=[ + PaymentStatusChoices.PAID, + PaymentStatusChoices.COMPLIMENTARY, + ] + ), + output_field=models.PositiveBigIntegerField(), + default=0, + ), + ), + default=0, + output_field=models.PositiveBigIntegerField(), + ), + ), + approved_compute_costs_euro_millicents=ExpressionWrapper( + ( + F("complimentary_compute_costs") + + F("paid_compute_costs") + + F("preapproved_compute_costs_if_anything_paid") + ) * 1000 - * 100 + * 100, + output_field=models.PositiveBigIntegerField(), ), available_compute_euro_millicents=ExpressionWrapper( F("approved_compute_costs_euro_millicents") @@ -1503,7 +1539,6 @@ class TaskResponsiblePartyChoices(models.TextChoices): class OnboardingTaskQuerySet(models.QuerySet): def with_overdue_status(self): - _now = now() return self.annotate( is_overdue=Case( From 88391b7b7571cc13d615c30fe886d1b4e770762b Mon Sep 17 00:00:00 2001 From: Thomas Koopman Date: Tue, 25 Feb 2025 15:32:48 +0100 Subject: [PATCH 5/5] Fix non-expression issue in queryset annotation --- app/grandchallenge/challenges/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/grandchallenge/challenges/models.py b/app/grandchallenge/challenges/models.py index 979fd361c3..e171e834ed 100644 --- a/app/grandchallenge/challenges/models.py +++ b/app/grandchallenge/challenges/models.py @@ -141,7 +141,7 @@ def with_available_compute(self): ), default=0, output_field=models.PositiveBigIntegerField(), - ), + ) ), approved_compute_costs_euro_millicents=ExpressionWrapper( (