diff --git a/CHANGELOG.md b/CHANGELOG.md index 6034ceb73..d8354d358 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## Unreleased ### Added + - Add potential funding (`PotentialFunding`) ingestion and expose it on Appeal/AppealHistory - EAP: add RCRC Climate Centre to registration and lift activity limit - Use APP_subtype instead of APD_TYP_Id in ingest_appeals - DREF: add automated DREF Final Report alert emails and cron job diff --git a/api/admin.py b/api/admin.py index d2c375150..350a6a1d6 100644 --- a/api/admin.py +++ b/api/admin.py @@ -840,22 +840,28 @@ def confirm_events(self, request, queryset): confirm_events.short_description = "Confirm emergencies as correct" def save_model(self, request, obj, form, change): - previous_amount_funded = None + previous_funding = None if change: - previous_amount_funded = models.Appeal.objects.filter(pk=obj.pk).values_list("amount_funded", flat=True).first() + previous_funding = ( + models.Appeal.objects.filter(pk=obj.pk).values_list("amount_funded", "amount_potential_funding").first() + ) if obj.country: obj.region = obj.country.region super().save_model(request, obj, form, change) - if not change or previous_amount_funded == obj.amount_funded: + if not change or previous_funding == (obj.amount_funded, obj.amount_potential_funding): return latest_history = models.AppealHistory.objects.filter(appeal=obj).order_by("id").last() # Avoid duplicates if another save flow already created this exact savepoint. - if latest_history and latest_history.amount_funded == obj.amount_funded: + if ( + latest_history + and latest_history.amount_funded == obj.amount_funded + and latest_history.amount_potential_funding == obj.amount_potential_funding + ): return now = timezone.now() @@ -868,6 +874,7 @@ def save_model(self, request, obj, form, change): num_beneficiaries=obj.num_beneficiaries, amount_requested=obj.amount_requested, amount_funded=obj.amount_funded, + amount_potential_funding=obj.amount_potential_funding, valid_from=now, valid_to=timezone.make_aware(datetime(2200, 1, 1)), start_date=obj.start_date, diff --git a/api/factories/event.py b/api/factories/event.py index c868ff5de..3097776ae 100644 --- a/api/factories/event.py +++ b/api/factories/event.py @@ -112,6 +112,7 @@ class AppealFactory(factory.django.DjangoModelFactory): num_beneficiaries = fuzzy.FuzzyInteger(0) amount_requested = fuzzy.FuzzyInteger(0) amount_funded = fuzzy.FuzzyInteger(0) + amount_potential_funding = fuzzy.FuzzyInteger(0) event = factory.SubFactory(EventFactory) country = factory.SubFactory(CountryFactory) @@ -129,6 +130,7 @@ class AppealHistoryFactory(factory.django.DjangoModelFactory): num_beneficiaries = fuzzy.FuzzyInteger(0) amount_requested = fuzzy.FuzzyInteger(0) amount_funded = fuzzy.FuzzyInteger(0) + amount_potential_funding = fuzzy.FuzzyInteger(0) appeal = factory.SubFactory(AppealFactory) country = factory.SubFactory(CountryFactory) atype = factory.SubFactory(AppealTypeFactory) diff --git a/api/management/commands/ingest_appeals.py b/api/management/commands/ingest_appeals.py index 0fa6c4e6b..02d7398e3 100644 --- a/api/management/commands/ingest_appeals.py +++ b/api/management/commands/ingest_appeals.py @@ -281,7 +281,7 @@ def parse_appeal_record(self, r, **options): # instead of detail1["APD_TYP_Id"]: atype = atypes.get(r.get("APP_subtype"), AppealType.DREF) - amount_funded = triggering_amount = 0 + amount_funded = amount_potential_funding = triggering_amount = 0 for detl in details: if self.parse_date(detl["APD_endDate"]) < end_date: continue @@ -290,10 +290,12 @@ def parse_appeal_record(self, r, **options): triggering_ts = self.parse_unix_timestamp(triggering_ts) triggering_amount += triggering_ts if triggering_ts else 0 if atype == AppealType.DREF: - # appeals are always fully-funded amount_funded += detl["APD_amountCHF"] if detl["APD_amountCHF"] else 0 + # DREF appeals are always fully-funded, we skip potential funding for them else: amount_funded += detl["ContributionAmount"] if detl["ContributionAmount"] else 0 + # PotentialFunding may be absent on some upstream records, so fetch it defensively. + amount_potential_funding += detl.get("PotentialFunding") or 0 # for new, open appeals, if we have a country, try to guess what emergency it belongs to. # only consider emergencies within the past 90 days @@ -328,6 +330,7 @@ def parse_appeal_record(self, r, **options): "num_beneficiaries": detail1["APD_noBeneficiaries"], "amount_requested": detail1["APD_amountCHF"], "amount_funded": amount_funded, + "amount_potential_funding": amount_potential_funding, "real_data_update": modify_time, "triggering_amount": float(int(triggering_amount) % 10**10), # to avoid overflow } diff --git a/api/migrations/0002_appeal_amount_potential_funding.py b/api/migrations/0002_appeal_amount_potential_funding.py new file mode 100644 index 000000000..a519040fd --- /dev/null +++ b/api/migrations/0002_appeal_amount_potential_funding.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0001_squashed_0232'), + ] + + operations = [ + migrations.AddField( + model_name='appeal', + name='amount_potential_funding', + field=models.FloatField(default=0.0, verbose_name='amount potential funding'), + ), + migrations.AddField( + model_name='appealhistory', + name='amount_potential_funding', + field=models.FloatField(default=0.0, verbose_name='amount potential funding'), + ), + ] diff --git a/api/models.py b/api/models.py index e93ad93e6..65bdcd71e 100644 --- a/api/models.py +++ b/api/models.py @@ -1977,6 +1977,7 @@ class Appeal(models.Model): num_beneficiaries = models.IntegerField(verbose_name=_("number of beneficiaries"), default=0) amount_requested = models.FloatField(verbose_name=_("amount requested"), default=0.00) amount_funded = models.FloatField(verbose_name=_("amount funded"), default=0.00) + amount_potential_funding = models.FloatField(verbose_name=_("amount potential funding"), default=0.00) start_date = models.DateTimeField(verbose_name=_("start date"), null=True) end_date = models.DateTimeField(verbose_name=_("end date"), null=True) @@ -2089,6 +2090,7 @@ class AppealHistory(models.Model): num_beneficiaries = models.IntegerField(verbose_name=_("number of beneficiaries"), default=0) amount_requested = models.FloatField(verbose_name=_("amount requested"), default=0.00) amount_funded = models.FloatField(verbose_name=_("amount funded"), default=0.00) + amount_potential_funding = models.FloatField(verbose_name=_("amount potential funding"), default=0.00) valid_from = models.DateTimeField(verbose_name=_("valid_from"), null=True) valid_to = models.DateTimeField(verbose_name=_("valid_to"), null=True) aid = models.CharField(verbose_name=_("appeal ID"), max_length=20) diff --git a/api/receivers.py b/api/receivers.py index ae59de648..5c54103ee 100644 --- a/api/receivers.py +++ b/api/receivers.py @@ -218,7 +218,7 @@ def add_update_appeal_history(sender, instance, created, **kwargs): fields_watched = [ field.name for field in AppealHistory._meta.get_fields() - if field.name not in ["id", "appeal", "valid_from", "valid_to", "aid", "amount_funded"] + if field.name not in ["id", "appeal", "valid_from", "valid_to", "aid", "amount_funded", "amount_potential_funding"] ] now = timezone.now() changed = False @@ -229,6 +229,7 @@ def add_update_appeal_history(sender, instance, created, **kwargs): num_beneficiaries=instance.num_beneficiaries, amount_requested=instance.amount_requested, amount_funded=instance.amount_funded, + amount_potential_funding=instance.amount_potential_funding, valid_from=now, # TODO: use coalesce to fill valid_to instead of defining here. valid_to=datetime(2200, 1, 1, tzinfo=datetime_timezone.utc), @@ -261,6 +262,7 @@ def add_update_appeal_history(sender, instance, created, **kwargs): num_beneficiaries=instance.num_beneficiaries, amount_requested=instance.amount_requested, amount_funded=instance.amount_funded, + amount_potential_funding=instance.amount_potential_funding, valid_from=now, valid_to=datetime(2200, 1, 1, tzinfo=datetime_timezone.utc), start_date=instance.start_date, @@ -290,6 +292,7 @@ def add_update_appeal_history(sender, instance, created, **kwargs): num_beneficiaries=instance.num_beneficiaries, amount_requested=instance.amount_requested, amount_funded=instance.amount_funded, + amount_potential_funding=instance.amount_potential_funding, valid_from=now, # TODO: use coalesce to fill valid_to instead of defining here. valid_to=datetime(2200, 1, 1, tzinfo=datetime_timezone.utc), diff --git a/api/serializers.py b/api/serializers.py index 81198c90e..860cbd6ba 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -871,6 +871,7 @@ class Meta: "amount_requested", "code", "amount_funded", + "amount_potential_funding", "status", "status_display", "start_date", @@ -1527,6 +1528,7 @@ class Meta: "num_beneficiaries", "amount_requested", "amount_funded", + "amount_potential_funding", "start_date", "end_date", "real_data_update", @@ -1570,6 +1572,7 @@ class Meta: "num_beneficiaries", "amount_requested", "amount_funded", + "amount_potential_funding", "start_date", "end_date", "real_data_update", @@ -1611,6 +1614,7 @@ class Meta: "num_beneficiaries", "amount_requested", "amount_funded", + "amount_potential_funding", "start_date", "end_date", "real_data_update", @@ -1670,6 +1674,7 @@ class Meta: "num_beneficiaries", "amount_requested", "amount_funded", + "amount_potential_funding", "start_date", "end_date", "real_data_update", diff --git a/api/test_models.py b/api/test_models.py index 0f87a0f6e..f6dbb1a74 100644 --- a/api/test_models.py +++ b/api/test_models.py @@ -77,7 +77,15 @@ def setUp(self): event = models.Event.objects.create(name="associated event", summary="foo") country = models.Country.objects.create(name="country") models.Appeal.objects.create( - aid="test1", name="appeal", atype=1, code="abc", needs_confirmation=True, event=event, country=country + aid="test1", + name="appeal", + atype=1, + code="abc", + needs_confirmation=True, + event=event, + country=country, + amount_funded=1000, + amount_potential_funding=2000, ) def test_unconfirmed_event(self): @@ -85,6 +93,8 @@ def test_unconfirmed_event(self): response = dict(dict(response.data)["results"][0]) self.assertIsNone(response["event"]) self.assertIsNotNone(response["country"]) + self.assertEqual(response["amount_funded"], 1000) + self.assertEqual(response["amount_potential_funding"], 2000) def test_history_not_duplicated_when_same_aid_is_shared(self): country = models.Country.objects.create(name="history-country") @@ -102,6 +112,20 @@ def test_history_not_duplicated_when_same_aid_is_shared(self): final_history_count = models.AppealHistory.objects.filter(appeal=appeal1).count() self.assertEqual(final_history_count, initial_history_count) + # amount_funded/amount_potential_funding are excluded from the watched fields (like amount_funded + # already was), so changing them alone should not create a new history row either. The existing + # (now stale) history row is intentionally left untouched in this case. + appeal1.amount_funded = 500 + appeal1.amount_potential_funding = 750 + appeal1.save(update_fields=["amount_funded", "amount_potential_funding"]) + + after_funding_update_count = models.AppealHistory.objects.filter(appeal=appeal1).count() + self.assertEqual(after_funding_update_count, initial_history_count) + + latest_history = models.AppealHistory.objects.filter(appeal=appeal1).order_by("id").last() + self.assertEqual(latest_history.amount_funded, 0) + self.assertEqual(latest_history.amount_potential_funding, 0) + class FieldReportTest(TestCase): diff --git a/assets b/assets index 4707718e3..0b08873b3 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 4707718e306fdca2c2f051463171641e46ee5ed2 +Subproject commit 0b08873b3ff418e78312f46d0eb87d307717a43f