Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
dc90d3d
feat(backend): Add abstract impact provider
michiwend Feb 18, 2026
09042da
feat(backend): Impact provider implementation for SwissTPH (WIP)
michiwend Feb 18, 2026
f0ce0ab
feat(backend): Impact provider implementation for IDM (WIP)
michiwend Feb 18, 2026
4d27951
feat(backend): Add a impact provider registry to resolve providers by…
michiwend Feb 18, 2026
48caefc
feat(backend): Add impact service layer
michiwend Feb 18, 2026
7f1b647
feat(backend): Add impact API layer
michiwend Feb 18, 2026
0ff0a36
test(backend): Basic tests for impact providers (WIP)
michiwend Feb 18, 2026
b928198
fix(backend): distinct naming for impact DB env vars
michiwend Feb 23, 2026
665d944
refactor(backend): make casing pythonic for SwissTPH model fields
michiwend Feb 23, 2026
721d229
refactor(backend): resolve impact provider in the view, not in serial…
michiwend Feb 23, 2026
476037a
fix(backend): scope scenarios to user's account in ImpactViewSet
michiwend Feb 23, 2026
0369354
test(backend): add tests for impact views
michiwend Feb 23, 2026
2997845
refactor(backend): fix casing for IDM model classes
michiwend Feb 23, 2026
e9deece
refactor(backend): remove deployed prefix from filters in idm provider
michiwend Feb 23, 2026
af7385d
fix(backend): catch un-mappable interventions
michiwend Feb 23, 2026
40245d9
refactor(backend): better naming in IDM provider
michiwend Feb 23, 2026
9d0e30e
refactor(backend): don't alias method in idm provider
michiwend Feb 23, 2026
9745feb
refactor(backend): rename ImpactMetrics sub classes to be more explicit
michiwend Feb 23, 2026
ac34793
refactor(backend): rename MetricWithCI to ImpactMetricWithConfidenceI…
michiwend Feb 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
Budget,
BudgetAssumptions,
BudgetSettings,
ImpactProviderConfig,
Intervention,
InterventionAssignment,
InterventionCategory,
Expand Down Expand Up @@ -109,3 +110,11 @@ class BudgetAssumptionsAdmin(admin.ModelAdmin):
list_display = ("id", "scenario", "intervention_code")
search_fields = ("id", "scenario", "intervention_code")
ordering = ("id",)


@admin.register(ImpactProviderConfig)
class ImpactProviderConfigAdmin(admin.ModelAdmin):
list_display = ("id", "account", "provider_key")
list_filter = ("provider_key",)
search_fields = ("account__name",)
ordering = ("account__name",)
57 changes: 57 additions & 0 deletions api/impact/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from rest_framework import serializers

from plugins.snt_malaria.models import Scenario


# -- Request serializers -----------------------------------------------------


class ImpactQuerySerializer(serializers.Serializer):
scenario_id = serializers.PrimaryKeyRelatedField(
queryset=Scenario.objects.none(),
source="scenario",
)
age_group = serializers.CharField()
year_from = serializers.IntegerField(required=False, allow_null=True, default=None)
year_to = serializers.IntegerField(required=False, allow_null=True, default=None)

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
user = self.context["request"].user
account = user.iaso_profile.account
self.fields["scenario_id"].queryset = Scenario.objects.filter(account=account)


# -- Response serializers ----------------------------------------------------


class MetricWithCISerializer(serializers.Serializer):
value = serializers.FloatField(allow_null=True)
lower = serializers.FloatField(allow_null=True)
upper = serializers.FloatField(allow_null=True)
Comment on lines +29 to +31
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use DecimalField instead?



class ImpactMetricsSerializer(serializers.Serializer):
number_cases = MetricWithCISerializer()
number_severe_cases = MetricWithCISerializer()
prevalence_rate = MetricWithCISerializer()
averted_cases = MetricWithCISerializer()
direct_deaths = MetricWithCISerializer()
cost = serializers.FloatField(allow_null=True)
cost_per_averted_case = MetricWithCISerializer()


class OrgUnitMetricsSerializer(ImpactMetricsSerializer):
org_unit_id = serializers.IntegerField()
org_unit_name = serializers.CharField()


class YearMetricsSerializer(ImpactMetricsSerializer):
year = serializers.IntegerField()
org_units = OrgUnitMetricsSerializer(many=True)


class ScenarioImpactSerializer(ImpactMetricsSerializer):
scenario_id = serializers.IntegerField()
by_year = YearMetricsSerializer(many=True)
org_units = OrgUnitMetricsSerializer(many=True)
55 changes: 55 additions & 0 deletions api/impact/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from rest_framework import viewsets
from rest_framework.exceptions import NotFound
from rest_framework.response import Response

from plugins.snt_malaria.providers.impact import get_provider_for_account
from plugins.snt_malaria.services.impact import ImpactService

from .serializers import ImpactQuerySerializer, ScenarioImpactSerializer


def _get_provider(request):
"""Resolve the ImpactProvider for the requesting user's account."""
account = request.user.iaso_profile.account
provider = get_provider_for_account(account)
if provider is None:
raise NotFound("No impact data provider configured for this account.")
return provider


class ImpactYearRangeViewSet(viewsets.ViewSet):
http_method_names = ["get", "options"]

def list(self, request):
provider = _get_provider(request)
min_year, max_year = provider.get_year_range()
return Response({"min_year": min_year, "max_year": max_year})


class ImpactAgeGroupsViewSet(viewsets.ViewSet):
http_method_names = ["get", "options"]

def list(self, request):
provider = _get_provider(request)
age_groups = provider.get_age_groups()
return Response({"age_groups": age_groups})


class ImpactViewSet(viewsets.ViewSet):
http_method_names = ["get", "options"]

def list(self, request):
provider = _get_provider(request)

serializer = ImpactQuerySerializer(data=request.query_params, context={"request": request})
serializer.is_valid(raise_exception=True)
data = serializer.validated_data

service = ImpactService(provider)
result = service.get_scenario_impact(
scenario=data["scenario"],
age_group=data["age_group"],
year_from=data["year_from"],
year_to=data["year_to"],
)
return Response(ScenarioImpactSerializer(result).data)
4 changes: 4 additions & 0 deletions api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from plugins.snt_malaria.api.budget.views import BudgetViewSet
from plugins.snt_malaria.api.budget_assumptions.views import BudgetAssumptionsViewSet
from plugins.snt_malaria.api.budget_settings.views import BudgetSettingsViewSet
from plugins.snt_malaria.api.impact.views import ImpactAgeGroupsViewSet, ImpactViewSet, ImpactYearRangeViewSet

from .intervention_assignments.views import InterventionAssignmentViewSet
from .intervention_categories.views import InterventionCategoryViewSet
Expand Down Expand Up @@ -30,3 +31,6 @@
router.register(r"snt_malaria/budgets", BudgetViewSet, basename="budgets")
router.register(r"snt_malaria/budget_settings", BudgetSettingsViewSet, basename="budget_settings")
router.register(r"snt_malaria/budget_assumptions", BudgetAssumptionsViewSet, basename="budget_assumptions")
router.register(r"snt_malaria/impact", ImpactViewSet, basename="impact")
router.register(r"snt_malaria/impact_year_range", ImpactYearRangeViewSet, basename="impact_year_range")
router.register(r"snt_malaria/impact_age_groups", ImpactAgeGroupsViewSet, basename="impact_age_groups")
48 changes: 48 additions & 0 deletions migrations/0021_impactproviderconfig.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Generated by Django 4.2

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another 0021 was merged on main in the meantime, you should rebase/merge main and recreate this migration, otherwise we'll have a conflict


dependencies = [
("snt_malaria", "0020_budget_population_input"),
]

operations = [
migrations.CreateModel(
name="ImpactProviderConfig",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"provider_key",
models.CharField(
choices=[("swisstph", "SwissTPH"), ("idm", "IDM")],
help_text="The impact data provider to use for this account.",
max_length=50,
),
),
(
"account",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="impact_provider_config",
to="iaso.account",
),
),
],
options={
"verbose_name": "Impact provider configuration",
"verbose_name_plural": "Impact provider configurations",
},
),
]
2 changes: 2 additions & 0 deletions models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from .budget_assumptions import BudgetAssumptions
from .budget_settings import BudgetSettings
from .cost_breakdown import InterventionCostBreakdownLine, InterventionCostUnitType
from .impact_provider_config import ImpactProviderConfig
from .intervention import (
Intervention,
InterventionAssignment,
Expand All @@ -17,6 +18,7 @@
"Scenario",
"InterventionCostBreakdownLine",
"InterventionCostUnitType",
"ImpactProviderConfig",
"Budget",
"BudgetSettings",
"BudgetAssumptions",
Expand Down
153 changes: 153 additions & 0 deletions models/idm_impact.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
from django.db import models


class IDMAdminInfo(models.Model):
"""Geographic administrative units (LGAs) with population data in the IDM database."""

class Meta:
app_label = "snt_malaria"
managed = False
db_table = "admin_info"

id = models.AutoField(primary_key=True)
admin_2_name = models.CharField(max_length=255, null=True, blank=True)
state = models.CharField(max_length=255, null=True, blank=True)
population = models.IntegerField(null=True, blank=True)

def __str__(self):
return f"{self.admin_2_name} ({self.state})"


class IDMInterventionPackage(models.Model):
"""Available intervention types in the IDM database."""

class Meta:
app_label = "snt_malaria"
managed = False
db_table = "intervention_package"

id = models.IntegerField(primary_key=True)
option = models.CharField(max_length=255, null=True, blank=True)
type = models.CharField(max_length=255, null=True, blank=True)
description = models.CharField(max_length=255, null=True, blank=True)

def __str__(self):
return f"{self.option} ({self.id})"


class IDMCoverage(models.Model):
"""Coverage level options in the IDM database."""

class Meta:
app_label = "snt_malaria"
managed = False
db_table = "coverage"

id = models.IntegerField(primary_key=True)
option = models.CharField(max_length=255, null=True, blank=True)
key = models.CharField(max_length=255, null=True, blank=True)
description = models.CharField(max_length=255, null=True, blank=True)

def __str__(self):
return f"{self.option} ({self.id})"


class IDMAgeGroup(models.Model):
"""Age group demographics in the IDM database."""

class Meta:
app_label = "snt_malaria"
managed = False
db_table = "age_group"

id = models.IntegerField(primary_key=True)
option = models.CharField(max_length=255, null=True, blank=True)

def __str__(self):
return f"{self.option} ({self.id})"


class IDMModelOutput(models.Model):
"""Main epidemiological simulation results from the IDM database.

Each row represents simulation output for a specific admin unit, year,
age group, and combination of interventions with their coverage levels.
"""

class Meta:
app_label = "snt_malaria"
managed = False
db_table = "model_output"

id = models.AutoField(primary_key=True)

# Admin info FK
admin_info_ref = models.ForeignKey(
IDMAdminInfo,
on_delete=models.DO_NOTHING,
db_column="admin_info",
null=True,
blank=True,
)

year = models.SmallIntegerField(null=True, blank=True)

# Age group FK
age_group_ref = models.ForeignKey(
IDMAgeGroup,
on_delete=models.DO_NOTHING,
db_column="age_group",
null=True,
blank=True,
)

# Intervention columns (FK to intervention_package)
cm = models.SmallIntegerField(null=True, blank=True)
cm_coverage = models.SmallIntegerField(null=True, blank=True)
cm_subsidy = models.SmallIntegerField(null=True, blank=True)
cm_subsidy_coverage = models.SmallIntegerField(null=True, blank=True)
smc = models.SmallIntegerField(null=True, blank=True)
smc_coverage = models.SmallIntegerField(null=True, blank=True)
itn_c = models.SmallIntegerField(null=True, blank=True)
itn_c_coverage = models.SmallIntegerField(null=True, blank=True)
itn_r = models.SmallIntegerField(null=True, blank=True)
itn_r_coverage = models.SmallIntegerField(null=True, blank=True)
irs = models.SmallIntegerField(null=True, blank=True)
irs_coverage = models.SmallIntegerField(null=True, blank=True)
vacc = models.SmallIntegerField(null=True, blank=True)
vacc_coverage = models.SmallIntegerField(null=True, blank=True)
iptp = models.SmallIntegerField(null=True, blank=True)
iptp_coverage = models.SmallIntegerField(null=True, blank=True)
lsm = models.SmallIntegerField(null=True, blank=True)
lsm_coverage = models.SmallIntegerField(null=True, blank=True)

# Outcome metrics
clinical_incidence = models.DecimalField(
max_digits=20, decimal_places=10, null=True, blank=True
)
clinical_incidence_lower = models.DecimalField(
max_digits=20, decimal_places=10, null=True, blank=True
)
clinical_incidence_higher = models.DecimalField(
max_digits=20, decimal_places=10, null=True, blank=True
)
severe_incidence = models.DecimalField(
max_digits=20, decimal_places=10, null=True, blank=True
)
severe_incidence_lower = models.DecimalField(
max_digits=20, decimal_places=10, null=True, blank=True
)
severe_incidence_higher = models.DecimalField(
max_digits=20, decimal_places=10, null=True, blank=True
)
prevalence = models.DecimalField(
max_digits=20, decimal_places=10, null=True, blank=True
)
prevalence_lower = models.DecimalField(
max_digits=20, decimal_places=10, null=True, blank=True
)
prevalence_higher = models.DecimalField(
max_digits=20, decimal_places=10, null=True, blank=True
)


27 changes: 27 additions & 0 deletions models/impact_provider_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from django.db import models


class ImpactProviderConfig(models.Model):
class Meta:
app_label = "snt_malaria"
verbose_name = "Impact provider configuration"
verbose_name_plural = "Impact provider configurations"

PROVIDER_CHOICES = [
("swisstph", "SwissTPH"),
("idm", "IDM"),
]
Comment on lines +10 to +13
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We usually do a nested enum class in IASO, but I don't mind this


account = models.OneToOneField(
"iaso.Account",
on_delete=models.CASCADE,
related_name="impact_provider_config",
)
provider_key = models.CharField(
max_length=50,
choices=PROVIDER_CHOICES,
help_text="The impact data provider to use for this account.",
)

def __str__(self):
return f"{self.account.name} - {self.get_provider_key_display()}"
Loading
Loading