diff --git a/dref/admin.py b/dref/admin.py index 6fbef3135..cb2a117a9 100644 --- a/dref/admin.py +++ b/dref/admin.py @@ -11,6 +11,7 @@ IdentifiedNeed, NationalSocietyAction, PlannedIntervention, + ProposedAction, RiskSecurity, SourceInformation, ) @@ -86,6 +87,7 @@ class DrefAdmin(CompareVersionAdmin, TranslationAdmin, admin.ModelAdmin): "needs_identified", "planned_interventions", "risk_security", + "proposed_action", ) def get_queryset(self, request): @@ -216,3 +218,8 @@ def get_queryset(self, request): "dref__needs_identified", ) ) + + +@admin.register(ProposedAction) +class ProposedActionAdmin(ReadOnlyMixin, admin.ModelAdmin): + search_fields = ["action"] diff --git a/dref/enums.py b/dref/enums.py index 765b4b478..7036d9d3e 100644 --- a/dref/enums.py +++ b/dref/enums.py @@ -8,4 +8,5 @@ "dref_onset_type": models.Dref.OnsetType, "dref_disaster_category": models.Dref.DisasterCategory, "dref_status": models.Dref.Status, + "proposed_action": models.ProposedAction.Action, } diff --git a/dref/migrations/0076_dref_addressed_humanitarian_impacts_and_more.py b/dref/migrations/0076_dref_addressed_humanitarian_impacts_and_more.py new file mode 100644 index 000000000..f4132e368 --- /dev/null +++ b/dref/migrations/0076_dref_addressed_humanitarian_impacts_and_more.py @@ -0,0 +1,109 @@ +# Generated by Django 4.2.16 on 2025-01-22 06:19 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("deployments", "0090_sectortag_title_ar_sectortag_title_en_and_more"), + ("dref", "0075_alter_dref_budget_file_preview_alter_dreffile_file_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="dref", + name="addressed_humanitarian_impacts", + field=models.TextField( + blank=True, + help_text=" Which of the expected severe humanitarian impacts of the hazard are your actions addressing?", + null=True, + verbose_name="Addressed Humanitarian Impacts", + ), + ), + migrations.AddField( + model_name="dref", + name="contingency_plans_supporting_document", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="dref_contingency_plans_supporting_document", + to="dref.dreffile", + verbose_name="Contingency Plans Supporting Document", + ), + ), + migrations.AddField( + model_name="dref", + name="hazard_date_and_location", + field=models.TextField( + blank=True, + help_text="When and where is the hazard expected to happen?", + max_length=255, + null=True, + verbose_name="Hazard Date and Location", + ), + ), + migrations.AddField( + model_name="dref", + name="hazard_vulnerabilities_and_risks", + field=models.TextField( + blank=True, + help_text="Explain the underlying vulnerabilities and risks the hazard poses for at-risk communities?", + null=True, + verbose_name="Hazard Vulnerabilities and Risks", + ), + ), + migrations.AddField( + model_name="dref", + name="indirect_cost", + field=models.PositiveIntegerField(blank=True, null=True, verbose_name="Indirect Cost"), + ), + migrations.AddField( + model_name="dref", + name="scenario_analysis_supporting_document", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="dref_scenario_supporting_document", + to="dref.dreffile", + verbose_name="Scenario Analysis Supporting Document", + ), + ), + migrations.AddField( + model_name="dref", + name="sub_total", + field=models.PositiveIntegerField(blank=True, null=True, verbose_name="Sub total"), + ), + migrations.AddField( + model_name="dref", + name="surge_deployment_cost", + field=models.PositiveIntegerField(blank=True, null=True, verbose_name="Surge Deployment Cost"), + ), + migrations.AddField( + model_name="dref", + name="total", + field=models.PositiveIntegerField(blank=True, null=True, verbose_name="Total"), + ), + migrations.CreateModel( + name="ProposedAction", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "proposed_type", + models.PositiveIntegerField( + choices=[(1, "Early Actions"), (2, "Early Response")], verbose_name="dref proposed action" + ), + ), + ("budget", models.PositiveIntegerField(blank=True, null=True, verbose_name="Purpose Action Budgets")), + ("activity", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="deployments.sector")), + ], + ), + migrations.AddField( + model_name="dref", + name="proposed_action", + field=models.ManyToManyField(blank=True, to="dref.proposedaction", verbose_name="Proposed Action"), + ), + ] diff --git a/dref/models.py b/dref/models.py index f7023671a..fa16c4f38 100644 --- a/dref/models.py +++ b/dref/models.py @@ -12,6 +12,7 @@ from pdf2image import convert_from_bytes from api.models import Country, DisasterType, District, FieldReport +from deployments.models import Sector from main.fields import SecureFileField @@ -210,6 +211,22 @@ class SourceInformation(models.Model): source_link = models.CharField(verbose_name=_("Source Link"), null=True, blank=True, max_length=255) +class ProposedAction(models.Model): + class Action(models.IntegerChoices): + EARLY_ACTION = 1, _("Early Actions") + EARLY_RESPONSE = 2, _("Early Response") + + proposed_type = models.PositiveIntegerField( + choices=Action.choices, + verbose_name=_("dref proposed action"), + ) + activity = models.ForeignKey(Sector, on_delete=models.CASCADE) + budget = models.PositiveIntegerField(verbose_name=_("Purpose Action Budgets"), blank=True, null=True) + + def __str__(self) -> str: + return f"{self.get_proposed_type_display()}-{self.budget}" + + @reversion.register() class Dref(models.Model): class DrefType(models.IntegerChoices): @@ -589,6 +606,46 @@ class Status(models.IntegerChoices): __budget_file_id = None is_active = models.BooleanField(verbose_name=_("Is Active"), null=True, blank=True) source_information = models.ManyToManyField(SourceInformation, blank=True, verbose_name=_("Source Information")) + proposed_action = models.ManyToManyField(ProposedAction, verbose_name=_("Proposed Action"), blank=True) + sub_total = models.PositiveIntegerField(verbose_name=_("Sub total"), blank=True, null=True) + surge_deployment_cost = models.PositiveIntegerField(verbose_name=_("Surge Deployment Cost"), null=True, blank=True) + indirect_cost = models.PositiveIntegerField(verbose_name=_("Indirect Cost"), null=True, blank=True) + total = models.PositiveIntegerField(verbose_name=_("Total"), null=True, blank=True) + hazard_date_and_location = models.TextField( + verbose_name=_("Hazard Date and Location"), + max_length=255, + help_text=_("When and where is the hazard expected to happen?"), + null=True, + blank=True, + ) + hazard_vulnerabilities_and_risks = models.TextField( + verbose_name=_("Hazard Vulnerabilities and Risks"), + help_text=_("Explain the underlying vulnerabilities and risks the hazard poses for at-risk communities?"), + null=True, + blank=True, + ) + scenario_analysis_supporting_document = models.ForeignKey( + "DrefFile", + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name=_("Scenario Analysis Supporting Document"), + related_name="dref_scenario_supporting_document", + ) + contingency_plans_supporting_document = models.ForeignKey( + "DrefFile", + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name=_("Contingency Plans Supporting Document"), + related_name="dref_contingency_plans_supporting_document", + ) + addressed_humanitarian_impacts = models.TextField( + verbose_name=_("Addressed Humanitarian Impacts"), + help_text=_(" Which of the expected severe humanitarian impacts of the hazard are your actions addressing?"), + null=True, + blank=True, + ) class Meta: verbose_name = _("dref") diff --git a/dref/serializers.py b/dref/serializers.py index 994b5af4d..22e765531 100644 --- a/dref/serializers.py +++ b/dref/serializers.py @@ -16,6 +16,7 @@ MiniDistrictSerializer, UserNameSerializer, ) +from deployments.models import Sector from dref.models import ( Dref, DrefFile, @@ -25,6 +26,7 @@ NationalSocietyAction, PlannedIntervention, PlannedInterventionIndicators, + ProposedAction, RiskSecurity, SourceInformation, ) @@ -54,6 +56,17 @@ class Meta: fields = "__all__" +class ProposedActionSerializer(serializers.ModelSerializer): + + proposed_type_display = serializers.CharField(source="get_proposed_type_display", read_only=True) + activity = serializers.PrimaryKeyRelatedField(queryset=Sector.objects.all(), required=True) + budget = serializers.IntegerField(required=True) + + class Meta: + model = ProposedAction + fields = "__all__" + + class DrefFileInputSerializer(serializers.Serializer): file = serializers.ListField(child=serializers.FileField()) @@ -332,6 +345,10 @@ class Meta: class DrefSerializer(NestedUpdateMixin, NestedCreateMixin, ModelSerializer): + SUB_TOTAL = 75000 + SURGE_DEPLOYMENT_COST = 10000 + INDIRECT_COST_SURGE = 5800 + INDIRECT_COST_NO_SURGE = 5000 MAX_NUMBER_OF_IMAGES = 2 ALLOWED_BUDGET_FILE_EXTENSIONS = ["pdf"] ALLOWED_ASSESSMENT_REPORT_EXTENSIONS = ["pdf", "docx", "pptx"] @@ -371,10 +388,22 @@ class DrefSerializer(NestedUpdateMixin, NestedCreateMixin, ModelSerializer): modified_at = serializers.DateTimeField(required=False) dref_access_user_list = serializers.SerializerMethodField() source_information = SourceInformationSerializer(many=True, required=False) + scenario_analysis_supporting_document_details = DrefFileSerializer( + source="scenario_analysis_supporting_document", read_only=True, required=False, allow_null=True + ) + contingency_plans_supporting_document_details = DrefFileSerializer( + source="contingency_plans_supporting_document", read_only=True, required=False, allow_null=True + ) + + proposed_action = ProposedActionSerializer(many=True, required=False) class Meta: model = Dref - read_only_fields = ("modified_by", "created_by", "budget_file_preview") + read_only_fields = ( + "modified_by", + "created_by", + "budget_file_preview", + ) exclude = ( "cover_image", "event_map", @@ -428,6 +457,74 @@ def validate(self, data): raise serializers.ValidationError( gettext("Operation timeframe can't be greater than %s for assessment_report" % self.MAX_OPERATION_TIMEFRAME) ) + + # NOTE: Validation for type DREF Imminent + if data.get("type_of_dref") == Dref.DrefType.IMMINENT: + is_surge_personnel_deployed = data.get("is_surge_personnel_deployed") + sub_total = data.get("sub_total") + surge_deployment_cost = data.get("surge_deployment_cost") + indirect_cost = data.get("indirect_cost") + total = data.get("total") + proposed_actions = data.get("proposed_action", []) + + if not proposed_actions: + raise serializers.ValidationError( + {"proposed_action": gettext("Proposed Action is required for type DREF Imminent")} + ) + if not sub_total: + raise serializers.ValidationError({"sub_total": gettext("Sub-total is required for Imminent DREF")}) + if sub_total != self.SUB_TOTAL: + raise serializers.ValidationError( + {"sub_total": gettext("Sub-total should be equal to %s for Imminent DREF" % self.SUB_TOTAL)} + ) + if is_surge_personnel_deployed and not surge_deployment_cost: + raise serializers.ValidationError( + {"surge_deployment_cost": gettext("Surge Deployment is required for Imminent DREF")} + ) + if not indirect_cost: + raise serializers.ValidationError({"indirect_cost": gettext("Indirect Cost is required for Imminent DREF")}) + if not total: + raise serializers.ValidationError({"total": gettext("Total is required for Imminent DREF")}) + + proposed_budget = sum(action.get("budget", 0) for action in proposed_actions) + if proposed_budget != sub_total: + raise serializers.ValidationError("Sub-total should be equal to proposed budget") + + if is_surge_personnel_deployed: + if surge_deployment_cost != self.SURGE_DEPLOYMENT_COST: + raise serializers.ValidationError( + { + "surge_deployment_cost": gettext( + "Surge Deployment Cost should be equal to %s for Surge Personnel Deployed" + % self.SURGE_DEPLOYMENT_COST + ) + } + ) + if indirect_cost != self.INDIRECT_COST_SURGE: + raise serializers.ValidationError( + { + "indirect_cost": gettext( + "Indirect Cost should be equal to %s for Surge Personnel Deployed" % self.INDIRECT_COST_SURGE + ) + } + ) + expected_total = surge_deployment_cost + indirect_cost + sub_total + else: + if indirect_cost != self.INDIRECT_COST_NO_SURGE: + raise serializers.ValidationError( + { + "indirect_cost": gettext( + "Indirect Cost should be equal to %s for No Surge Personnel Deployed" + % self.INDIRECT_COST_NO_SURGE + ) + } + ) + expected_total = indirect_cost + sub_total + + if expected_total != total: + raise serializers.ValidationError( + {"total": gettext("Total should be equal to sum of Sub-total, Surge Deployment Cost and Indirect Cost")} + ) return data def validate_images(self, images): @@ -590,8 +687,18 @@ class DrefOperationalUpdateSerializer(NestedUpdateMixin, NestedCreateMixin, Mode class Meta: model = DrefOperationalUpdate - read_only_fields = ("operational_update_number", "modified_by", "created_by") - exclude = ("images", "photos", "event_map", "cover_image", "users") + read_only_fields = ( + "operational_update_number", + "modified_by", + "created_by", + ) + exclude = ( + "images", + "photos", + "event_map", + "cover_image", + "users", + ) def validate(self, data): dref = data.get("dref") @@ -904,7 +1011,11 @@ class DrefFinalReportSerializer(NestedUpdateMixin, NestedCreateMixin, ModelSeria class Meta: model = DrefFinalReport - read_only_fields = ("modified_by", "created_by", "financial_report_preview") + read_only_fields = ( + "modified_by", + "created_by", + "financial_report_preview", + ) exclude = ( "images", "photos", diff --git a/dref/test_views.py b/dref/test_views.py index f84c397ab..ee1c13cae 100644 --- a/dref/test_views.py +++ b/dref/test_views.py @@ -7,6 +7,7 @@ from django.contrib.contenttypes.models import ContentType from api.models import Country, DisasterType, District, Region, RegionName +from deployments.factories.project import SectorFactory from deployments.factories.user import UserFactory from dref.factories.dref import ( DrefFactory, @@ -14,7 +15,13 @@ DrefFinalReportFactory, DrefOperationalUpdateFactory, ) -from dref.models import Dref, DrefFile, DrefFinalReport, DrefOperationalUpdate +from dref.models import ( + Dref, + DrefFile, + DrefFinalReport, + DrefOperationalUpdate, + ProposedAction, +) from dref.tasks import send_dref_email from main.test_case import APITestCase @@ -1247,3 +1254,79 @@ def test_dref_share_users(self): self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data["results"]), 1) self.assertEqual(set(response.data["results"][0]["users"]), set([user2.id, user3.id, user4.id])) + + def test_dref_imminent(self): + old_count = Dref.objects.count() + sct_1 = SectorFactory( + title="shelter_housing_and_settlements", + ) + sct_2 = SectorFactory( + title="health", + ) + national_society = Country.objects.create(name="abc") + disaster_type = DisasterType.objects.create(name="disaster 1") + data = { + "title": "Dref test title", + "type_of_onset": Dref.OnsetType.SUDDEN.value, + "type_of_dref": Dref.DrefType.IMMINENT, + "disaster_category": Dref.DisasterCategory.YELLOW.value, + "status": Dref.Status.IN_PROGRESS.value, + "num_assisted": 5666, + "num_affected": 23, + "amount_requested": 127771111, + "women": 344444, + "men": 5666, + "girls": 22, + "boys": 344, + "appeal_manager_name": "Test Name", + "ifrc_emergency_email": "test@gmail.com", + "is_surge_personnel_deployed": False, + "originator_email": "test@gmail.com", + "national_society": national_society.id, + "disaster_type": disaster_type.id, + "planned_interventions": [ + { + "title": "shelter_housing_and_settlements", + "description": "matrix", + "budget": 23444, + "male": 12222, + "female": 2255, + "indicators": [ + { + "title": "test_title", + "actual": 21232, + "target": 44444, + } + ], + }, + ], + "proposed_action": [ + { + "proposed_type": ProposedAction.Action.EARLY_ACTION.value, + "activity": sct_1.id, + "budget": 70000, + }, + { + "proposed_type": ProposedAction.Action.EARLY_RESPONSE.value, + "activity": sct_2.id, + "budget": 5000, + }, + ], + "sub_total": 75000, + "indirect_cost": 5000, + "total": 80000, + } + url = "/api/v2/dref/" + self.client.force_authenticate(self.user) + response = self.client.post(url, data, format="json") + self.assert_201(response) + self.assertEqual(Dref.objects.count(), old_count + 1) + + # Checking for surge personnel deployed + data["is_surge_personnel_deployed"] = True + data["surge_deployment_cost"] = 10000 + data["indirect_cost"] = 5800 + data["total"] = 90800 + response = self.client.post(url, data, format="json") + self.assert_201(response) + self.assertEqual(Dref.objects.count(), old_count + 2)