diff --git a/apps/baseline/migrations/0019_alter_foodpurchase_times_per_year_and_more.py b/apps/baseline/migrations/0019_alter_foodpurchase_times_per_year_and_more.py new file mode 100644 index 0000000..336712a --- /dev/null +++ b/apps/baseline/migrations/0019_alter_foodpurchase_times_per_year_and_more.py @@ -0,0 +1,103 @@ +# Generated by Django 5.1.1 on 2024-11-29 01:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("baseline", "0018_alter_livelihoodactivity_price_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="foodpurchase", + name="times_per_year", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="Number of times in a year that the purchase is made", + null=True, + verbose_name="Times per year", + ), + ), + migrations.AlterField( + model_name="foodpurchase", + name="unit_multiple", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="Multiple of the unit of measure in a single purchase", + null=True, + verbose_name="Unit Multiple", + ), + ), + migrations.AlterField( + model_name="othercashincome", + name="times_per_year", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="Number of times in a year that the income is received", + null=True, + verbose_name="Times per year", + ), + ), + migrations.AlterField( + model_name="otherpurchase", + name="times_per_year", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="Number of times in a year that the product is purchased", + null=True, + verbose_name="Times per year", + ), + ), + migrations.AlterField( + model_name="paymentinkind", + name="months_per_year", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="Number of months in a year that the labor is performed", + null=True, + verbose_name="Months per year", + ), + ), + migrations.AlterField( + model_name="paymentinkind", + name="people_per_household", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="Number of household members who perform the labor", + null=True, + verbose_name="People per household", + ), + ), + migrations.AlterField( + model_name="paymentinkind", + name="times_per_year", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="Number of times in a year that the labor is performed", + null=True, + verbose_name="Times per year", + ), + ), + migrations.AlterField( + model_name="reliefgiftother", + name="times_per_year", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="Number of times in a year that the item is received", + null=True, + verbose_name="Times per year", + ), + ), + migrations.AlterField( + model_name="reliefgiftother", + name="unit_multiple", + field=models.PositiveSmallIntegerField( + blank=True, + help_text="Multiple of the unit of measure received each time", + null=True, + verbose_name="Unit Multiple", + ), + ), + ] diff --git a/apps/baseline/models.py b/apps/baseline/models.py index 92a8f67..1e7f780 100644 --- a/apps/baseline/models.py +++ b/apps/baseline/models.py @@ -1553,7 +1553,10 @@ class FoodPurchase(LivelihoodActivity): # unit_multiple has names like wt_of_measure in the BSS. unit_multiple = models.PositiveSmallIntegerField( - verbose_name=_("Unit Multiple"), help_text=_("Multiple of the unit of measure in a single purchase") + blank=True, + null=True, + verbose_name=_("Unit Multiple"), + help_text=_("Multiple of the unit of measure in a single purchase"), ) # This is a float field because data may be captured as "once per week", # which equates to "52 per year", which is "4.33 per month". @@ -1565,17 +1568,25 @@ class FoodPurchase(LivelihoodActivity): help_text=_("Number of months in a year that the product is purchased"), ) times_per_year = models.PositiveSmallIntegerField( + blank=True, + null=True, verbose_name=_("Times per year"), help_text=_("Number of times in a year that the purchase is made"), ) def validate_quantity_produced(self): - if self.quantity_produced != self.unit_multiple * self.times_per_month * self.months_per_year: - raise ValidationError( - _( - "Quantity produced for a Food Purchase must be purchase amount * purchases per month * months per year" # NOQA: E501 + if ( + self.quantity_produced is not None + and self.unit_multiple is not None + and self.times_per_month is not None + and self.months_per_year is not None + ): + if self.quantity_produced != self.unit_multiple * self.times_per_month * self.months_per_year: + raise ValidationError( + _( + "Quantity produced for a Food Purchase must be purchase amount * purchases per month * months per year" # NOQA: E501 + ) ) - ) class Meta: verbose_name = LivelihoodStrategyType.FOOD_PURCHASE.label @@ -1598,15 +1609,23 @@ class PaymentInKind(LivelihoodActivity): help_text=_("Amount of item received each time the labor is performed"), ) people_per_household = models.PositiveSmallIntegerField( - verbose_name=_("People per household"), help_text=_("Number of household members who perform the labor") + blank=True, + null=True, + verbose_name=_("People per household"), + help_text=_("Number of household members who perform the labor"), ) # This is a float field because data may be captured as "once per week", # which equates to "52 per year", which is "4.33 per month". times_per_month = models.FloatField(blank=True, null=True, verbose_name=_("Labor per month")) months_per_year = models.PositiveSmallIntegerField( - verbose_name=_("Months per year"), help_text=_("Number of months in a year that the labor is performed") + blank=True, + null=True, + verbose_name=_("Months per year"), + help_text=_("Number of months in a year that the labor is performed"), ) times_per_year = models.PositiveSmallIntegerField( + blank=True, + null=True, verbose_name=_("Times per year"), help_text=_("Number of times in a year that the labor is performed"), ) @@ -1621,15 +1640,21 @@ def clean(self): def validate_quantity_produced(self): if ( - self.quantity_produced - and self.quantity_produced - != self.payment_per_time * self.people_per_household * self.times_per_month * self.months_per_year + self.quantity_produced is not None + and self.payment_per_time is not None + and self.people_per_household is not None + and self.times_per_month is not None + and self.months_per_year is not None ): - raise ValidationError( - _( - "Quantity produced for Payment In Kind must be payment per time * number of people * labor per month * months per year" # NOQA: E501 + if ( + self.quantity_produced + != self.payment_per_time * self.people_per_household * self.times_per_month * self.months_per_year + ): + raise ValidationError( + _( + "Quantity produced for Payment In Kind must be payment per time * number of people * labor per month * months per year" # NOQA: E501 + ) ) - ) class Meta: verbose_name = LivelihoodStrategyType.PAYMENT_IN_KIND.label @@ -1647,7 +1672,10 @@ class ReliefGiftOther(LivelihoodActivity): # Production calculation /validation is `unit_of_measure * unit_multiple * times_per_year` # Also used for the number of children receiving school meals. unit_multiple = models.PositiveSmallIntegerField( - verbose_name=_("Unit Multiple"), help_text=_("Multiple of the unit of measure received each time") + blank=True, + null=True, + verbose_name=_("Unit Multiple"), + help_text=_("Multiple of the unit of measure received each time"), ) # This is a float field because data may be captured as "once per week", # which equates to "52 per year", which is "4.33 per month". @@ -1661,14 +1689,18 @@ class ReliefGiftOther(LivelihoodActivity): help_text=_("Number of months in a year that the item is received"), ) times_per_year = models.PositiveSmallIntegerField( - verbose_name=_("Times per year"), help_text=_("Number of times in a year that the item is received") + blank=True, + null=True, + verbose_name=_("Times per year"), + help_text=_("Number of times in a year that the item is received"), ) def validate_quantity_produced(self): - if self.quantity_produced != self.unit_multiple * self.times_per_year: - raise ValidationError( - _("Quantity produced for Relief, Gifts, Other must be amount received * times per year") - ) + if self.quantity_produced is not None and self.unit_multiple is not None and self.times_per_year is not None: + if self.quantity_produced != self.unit_multiple * self.times_per_year: + raise ValidationError( + _("Quantity produced for Relief, Gifts, Other must be amount received * times per year") + ) class Meta: verbose_name = LivelihoodStrategyType.RELIEF_GIFT_OTHER.label @@ -1752,6 +1784,8 @@ class OtherCashIncome(LivelihoodActivity): help_text=_("Number of months in a year that the labor is performed"), ) times_per_year = models.PositiveSmallIntegerField( + blank=True, + null=True, verbose_name=_("Times per year"), help_text=_("Number of times in a year that the income is received"), ) @@ -1766,19 +1800,24 @@ def clean(self): def validate_income(self): if ( - self.people_per_household - and self.income - != self.payment_per_time * self.people_per_household * self.times_per_month * self.months_per_year + self.people_per_household is not None + and self.income is not None + and self.payment_per_time is not None + and self.times_per_month is not None + and self.months_per_year is not None ): - raise ValidationError( - _( - "Quantity produced for Other Cash Income must be payment per time * number of people * labor per month * months per year" # NOQA: E501 + if ( + self.income + != self.payment_per_time * self.people_per_household * self.times_per_month * self.months_per_year + ): + raise ValidationError( + _( + "Quantity produced for Other Cash Income must be payment per time * number of people * labor per month * months per year" # NOQA: E501 + ) ) - ) - if self.income != self.payment_per_time * self.times_per_year: - raise ValidationError( - _("Quantity produced for Other Cash Income must be payment per time * times per year") - ) + if self.income is not None and self.payment_per_time is not None and self.times_per_year is not None: + if self.income != self.payment_per_time * self.times_per_year: + raise ValidationError(_("Income for 'Other Cash Income' must be payment per time * times per year")) def calculate_fields(self): self.times_per_year = self.people_per_household * self.times_per_month * self.months_per_year @@ -1817,21 +1856,40 @@ class OtherPurchase(LivelihoodActivity): help_text=_("Number of months in a year that the product is purchased"), ) times_per_year = models.PositiveSmallIntegerField( + blank=True, + null=True, verbose_name=_("Times per year"), help_text=_("Number of times in a year that the product is purchased"), ) def validate_expenditure(self): - if ( - self.times_per_month - and self.months_per_year - and self.times_per_month * self.months_per_year != self.times_per_year - ): - raise ValidationError(_("Times per year must be times per month * months per year")) - if self.expenditure != self.price * self.unit_multiple * self.times_per_year: - raise ValidationError( - _("Expenditure for Other Purchases must be price * unit multiple * purchases per year") - ) + errors = [] + if self.times_per_month is not None and self.months_per_year is not None: + expected_times_per_year = self.times_per_month * self.months_per_year + if self.times_per_year is not None and self.times_per_year != expected_times_per_year: + errors.append( + _( + "Times per year must be times per month * months per year. Expected: %(expected)s, Found: %(found)s" + ) + % { + "expected": expected_times_per_year, + "found": self.times_per_year, + } + ) + if self.price is not None and self.unit_multiple is not None and self.times_per_year is not None: + expected_expenditure = self.price * self.unit_multiple * self.times_per_year + if self.expenditure is not None and self.expenditure != expected_expenditure: + errors.append( + _( + "Expenditure for Other Purchases must be price * unit multiple * purchases per year. Expected: %(expected)s, Found: %(found)s" + ) + % { + "expected": expected_expenditure, + "found": self.expenditure, + } + ) + if errors: + raise ValidationError(errors) class Meta: verbose_name = LivelihoodStrategyType.OTHER_PURCHASE.label diff --git a/apps/baseline/tests/test_models.py b/apps/baseline/tests/test_models.py index 11a38c8..3c6e20e 100644 --- a/apps/baseline/tests/test_models.py +++ b/apps/baseline/tests/test_models.py @@ -1,7 +1,14 @@ from django.core.exceptions import ValidationError from django.test import TestCase -from baseline.models import WealthGroupCharacteristicValue +from baseline.models import ( + FoodPurchase, + OtherCashIncome, + OtherPurchase, + PaymentInKind, + ReliefGiftOther, + WealthGroupCharacteristicValue, +) from common.tests.factories import ClassifiedProductFactory from common.utils import conditional_logging @@ -118,3 +125,227 @@ def test_get_by_natural_key_for_a_baseline_wealth_group(self): full_name="", ) self.assertEqual(instance, wealth_group_characteristic_value) + + +class FoodPurchaseTestCase(TestCase): + @classmethod + def setUpTestData(cls): + # Create different instances without saving + cls.foodpurchase1 = FoodPurchase( + unit_multiple=None, + times_per_month=None, + months_per_year=None, + quantity_produced=None, + ) + cls.foodpurchase2 = FoodPurchase( + unit_multiple=2, + times_per_month=5, + months_per_year=12, + quantity_produced=120, + ) + # Incorrect: 2 * 5 * 12 = 120 + cls.foodpurchase3 = FoodPurchase( + unit_multiple=2, + times_per_month=5, + months_per_year=12, + quantity_produced=100, + ) + + def test_validate_quantity_produced(self): + """ + Test validate_quantity_produced method + """ + # Missing data should not raise ValidationError + self.foodpurchase1.validate_quantity_produced() + # Expected consistant values, should not raise + self.foodpurchase2.validate_quantity_produced() + # Incorrect: 2 * 5 * 12 = 120 + with conditional_logging(): + self.assertRaises(ValidationError, self.foodpurchase3.validate_quantity_produced) + + +class PaymentInKindTestCase(TestCase): + @classmethod + def setUpTestData(cls): + # Create different instances without saving + cls.paymentinkind1 = PaymentInKind( + payment_per_time=None, + people_per_household=None, + times_per_month=None, + months_per_year=None, + quantity_produced=None, + ) + cls.paymentinkind2 = PaymentInKind( + payment_per_time=10, + people_per_household=2, + times_per_month=5, + months_per_year=12, + quantity_produced=1200, # 10 * 2 * 5 * 12 = 1200 + ) + cls.paymentinkind3 = PaymentInKind( + payment_per_time=10, + people_per_household=2, + times_per_month=5, + months_per_year=12, + quantity_produced=1000, # Incorrect: should be 1200 + ) + + def test_validate_quantity_produced(self): + """ + Test validate_quantity_produced method + """ + # Missing data should not raise ValidationError + self.paymentinkind1.validate_quantity_produced() + + # Expected consistent values, should not raise ValidationError + self.paymentinkind2.validate_quantity_produced() + + # Incorrect: 10 * 2 * 5 * 12 = 1200 + with self.assertRaises(ValidationError): + self.paymentinkind3.validate_quantity_produced() + + def test_payment_per_time_required(self): + """ + Test that clean enforces payment_per_time when required. + """ + instance = PaymentInKind( + payment_per_time=None, + people_per_household=2, + times_per_month=5, + months_per_year=12, + ) + with self.assertRaises(ValidationError): + instance.clean() + + +class ReliefGiftOtherTestCase(TestCase): + @classmethod + def setUpTestData(cls): + # Create different instances without saving + cls.reliefgift1 = ReliefGiftOther( + unit_multiple=None, + times_per_year=None, + quantity_produced=None, + ) + cls.reliefgift2 = ReliefGiftOther( + unit_multiple=10, + times_per_year=12, + quantity_produced=120, # 10 * 12 = 120 + ) + cls.reliefgift3 = ReliefGiftOther( + unit_multiple=10, + times_per_year=12, + quantity_produced=100, # Incorrect: should be 10 * 12 = 120 + ) + + def test_validate_quantity_produced(self): + """ + Test validate_quantity_produced method + """ + # Missing data should not raise ValidationError + self.reliefgift1.validate_quantity_produced() + + # Expected consistent values, should not raise ValidationError + self.reliefgift2.validate_quantity_produced() + + # Incorrect: 10 * 12 = 120 + with self.assertRaises(ValidationError): + self.reliefgift3.validate_quantity_produced() + + +class OtherCashIncomeTestCase(TestCase): + @classmethod + def setUpTestData(cls): + # Create instances for testing + cls.othercashincome1 = OtherCashIncome( + payment_per_time=None, + people_per_household=None, + times_per_month=None, + months_per_year=None, + income=None, + ) + cls.othercashincome2 = OtherCashIncome( + payment_per_time=100, + people_per_household=2, + times_per_month=5, + months_per_year=12, + income=12000, # 100 * 2 * 5 * 12 = 12000 + ) + cls.othercashincome3 = OtherCashIncome( + payment_per_time=100, + people_per_household=2, + times_per_month=5, + months_per_year=12, + income=10000, # Incorrect: should be 12000 + ) + cls.othercashincome4 = OtherCashIncome( + payment_per_time=100, + times_per_year=24, + income=2400, # 100 * 24 = 2400 + ) + cls.othercashincome5 = OtherCashIncome( + payment_per_time=100, + times_per_year=24, + income=2000, # Incorrect: should be 2400 + ) + + def test_validate_income(self): + # Missing data should not raise ValidationError + self.othercashincome1.validate_income() + # Correct data should not raise ValidationError + self.othercashincome2.validate_income() + self.othercashincome4.validate_income() + # Incorrect data should raise ValidationError. + with self.assertRaises(ValidationError): + self.othercashincome3.validate_income() + with self.assertRaises(ValidationError): + self.othercashincome5.validate_income() + + +class OtherPurchaseTestCase(TestCase): + @classmethod + def setUpTestData(cls): + # Create instances for testing + cls.otherpurchase1 = OtherPurchase( + unit_multiple=None, + times_per_month=None, + months_per_year=None, + times_per_year=None, + price=None, + expenditure=None, + ) + cls.otherpurchase2 = OtherPurchase( + unit_multiple=2, + times_per_month=5, + months_per_year=12, + times_per_year=60, # 5 * 12 = 60 + price=10, + expenditure=1200, # 10 * 2 * 60 = 1200 + ) + cls.otherpurchase3 = OtherPurchase( + unit_multiple=2, + times_per_month=5, + months_per_year=12, + times_per_year=50, # Incorrect: should be 5 * 12 = 60 + price=10, + expenditure=1200, + ) + cls.otherpurchase4 = OtherPurchase( + unit_multiple=2, + times_per_month=5, + months_per_year=12, + times_per_year=60, + price=10, + expenditure=1000, # Incorrect: should be 10 * 2 * 60 = 1200 + ) + + def test_validate_expenditure(self): + # Missing data should not raise ValidationError + self.otherpurchase1.validate_expenditure() + # Correct data should not raise ValidationError + self.otherpurchase2.validate_expenditure() + # Incorrect data should raise ValidationError + with self.assertRaises(ValidationError): + self.otherpurchase3.validate_expenditure() + with self.assertRaises(ValidationError): + self.otherpurchase4.validate_expenditure()