Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
85407d6
feat(DataLayerWizard): Introduce multi-step creation wizard
Bewi Sep 9, 2026
8428fd5
feat(DataLayerWizard): Enable editing existing layers via wizard (SNT…
Bewi Sep 10, 2026
7f439b9
feat(DataLayerWizard): Enable live map previews and earlier layer cre…
Bewi Sep 11, 2026
4a744d7
feat(DataLayerWizard): Import CSV to table and support yearly values
Bewi Sep 11, 2026
204dfbb
feat(DataLayer): Validate threshold legend range includes open-ended …
Bewi Sep 15, 2026
2a4d866
refactor(DataLayerWizard): Move is population to details step
Bewi Sep 22, 2026
91ec61c
refactor(DartaLayerWizard): cleanup explicit comments
Bewi Sep 22, 2026
6a72a40
feat(DataLayerWizard): Allow cancelling OpenHexa data layer imports
Bewi Sep 22, 2026
d3140ee
feat(DataLayer): Introduce 'is_complete' flag for layer readiness
Bewi Sep 22, 2026
b1c6b89
feature(OH importer): Remove created metrics if task is cancelled
Bewi Sep 22, 2026
72b24df
refactor(DataLayer): Small UI improvements
Bewi Sep 22, 2026
7258066
feature(OHDatalayer): Allow to edit OH layer type
Bewi Sep 22, 2026
de91bb1
Fine tuning
Bewi Sep 22, 2026
dee94fe
fix(DataLayer): Reset form on layer type change
Bewi Sep 24, 2026
25f553d
fix(DatLayer): Keep years in sync from step to step
Bewi Sep 24, 2026
6606827
feat(DataLayer): Generate default legend for basic data layer
Bewi Sep 24, 2026
3dfbdfc
feat(DataLayers): Enhance wizard feedback and error handling
Bewi Sep 24, 2026
3cfcbd1
fix(DataLayers): Discard stale OpenHexa imports earlier to prevent pr…
Bewi Sep 24, 2026
9fea179
feat(DataLayers): Remove import status indicator from map
Bewi Sep 24, 2026
bbf7181
fix(DataLayer): Delete Composite layer on deleting Data layer
Bewi Sep 24, 2026
4e1da5d
feat(DataLayers): Confirm discarding composite graph changes when nav…
Bewi Sep 24, 2026
962ac10
fix(DataLayer): Handle 0 values in CSV
Bewi Sep 24, 2026
7024dbd
feat(DataLayers): Ensure layers are marked complete via wizard or import
Bewi Sep 24, 2026
8ec3c25
fix(DataLayers): Improve robustness of layer finalisation and empty s…
Bewi Sep 25, 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
27 changes: 27 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## Commands

- Run `docker-test` to execute tests.
- When creating migrations, run `docker-makemigrations`, then format the generated migration file with ruff.
- When adding or modifying backend strings wrapped in `_`, run `docker-make-translations`.

## Code style

- Write minimal comments. Code should be self-explanatory through naming; only comment when the WHY isn't obvious from the code itself.
- Favor clear naming over clever tricks. When a block of logic gets complicated, extract it into a well-named function/method/util instead of adding comments to explain it.

## Backend

- Favor fat serializers / thin views: validation and persistence logic (including `transaction.atomic()` for delete+recreate patterns) belongs in the serializer's `validate_*` / `save()` methods. Views should just call `serializer.is_valid()` / `serializer.save()` and return the response.

## Testing

- Name test methods to describe the scenario and expected behavior (e.g. `test_metric_value_list_filter_by_reference_year_includes_year_match_and_timeless`). Add a one-line docstring restating that in plain English when the name alone isn't enough.

## Frontend

- Keep styles in a separate object that satisfies `SxStyles`.
- Add/update translations whenever `MESSAGES` is added or modified.
- Type components as `FC<Props>` with named exports (not default exports).
- Wrap handlers passed to children or effects in `useCallback`.
- Don't use array index as a React key — use `React.Children.toArray` instead.
- Prefer arrow functions whenever possible.
19 changes: 17 additions & 2 deletions api/composite_layers/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,28 @@ def validate_comments(self, comments):
def validate_legend_config(self, legend_config):
domain = legend_config.get("domain") if isinstance(legend_config, dict) else None
colors = legend_config.get("range") if isinstance(legend_config, dict) else None
if not isinstance(domain, list) or not isinstance(colors, list) or len(domain) != len(colors):
raise serializers.ValidationError("Legend config must have matching 'domain' and 'range' lists.")
if not isinstance(domain, list) or not isinstance(colors, list):
raise serializers.ValidationError("Legend config must have 'domain' and 'range' lists.")
return legend_config

def validate(self, data):
if self.instance is None and not (data.get("name") or "").strip():
raise serializers.ValidationError({"name": "This field is required."})
if "legend_config" in data:
legend_type = data.get("legend_type") or (
self.instance.legend_type if self.instance else CompositeLayer.LegendType.AUTO
)
domain = data["legend_config"]["domain"]
colors = data["legend_config"]["range"]
# A threshold legend's range carries one extra colour, for the open-ended bucket above
# the last breakpoint (see `hasOpenEndedTopBucket` on the frontend).
expected_range_length = (
len(domain) + 1 if legend_type == CompositeLayer.LegendType.THRESHOLD else len(domain)
)
if len(colors) != expected_range_length:
raise serializers.ValidationError(
{"legend_config": "Legend config 'range' does not match 'domain' for this legend type."}
)
return data


Expand Down
11 changes: 10 additions & 1 deletion api/openhexa_data_layers/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def save(self):
or layer["legend_config"]
)

metric_type, _created = MetricType.objects.update_or_create(
metric_type, created = MetricType.objects.update_or_create(
account=account,
code=layer["code"],
defaults={
Expand All @@ -99,4 +99,13 @@ def save(self):
"origin": MetricType.MetricTypeOrigin.OPENHEXA,
},
)
if created:
metric_type.is_complete = False
metric_type.save(update_fields=["is_complete"])
return metric_type


class CancelOpenHexaImportSerializer(serializers.Serializer):
"""Which layer's in-flight value import to stop, by its ``MetricType`` id."""

metric_type_id = serializers.IntegerField()
28 changes: 26 additions & 2 deletions api/openhexa_data_layers/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from rest_framework.response import Response

from iaso.api.tasks.serializers import TaskSerializer
from iaso.models import Task
from iaso.models import ALIVE_STATUSES, Task
from plugins.snt_malaria.providers.openhexa_data_layers import (
METADATA_FILENAME,
fetch_dataset_json,
Expand All @@ -21,7 +21,11 @@
from .constants import IMPORT_TASK_NAME
from .metadata import parse_data_layers
from .permissions import OpenHexaDataLayerPermission
from .serializers import ImportOpenHexaDataLayerSerializer, OpenHexaDataLayerSerializer
from .serializers import (
CancelOpenHexaImportSerializer,
ImportOpenHexaDataLayerSerializer,
OpenHexaDataLayerSerializer,
)


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -120,3 +124,23 @@ def import_status(self, request):
for row in latest_per_layer
}
)

@action(detail=False, methods=["post"], url_path="cancel_import")
def cancel_import(self, request):
"""Ask a data layer's in-flight value import to stop, if it hasn't already finished.

Body: ``{"metric_type_id": <id>}``. The task cooperatively checks the kill flag at its
next checkpoint, so this doesn't stop it instantly.
"""
account = request.user.iaso_profile.account
serializer = CancelOpenHexaImportSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
metric_type_id = serializer.validated_data["metric_type_id"]

(
Task.objects.filter(account=account, name=IMPORT_TASK_NAME, status__in=ALIVE_STATUSES)
.annotate(mt_id=KeyTextTransform("metric_type_id", KeyTransform("kwargs", "params")))
.filter(mt_id=str(metric_type_id))
.update(should_be_killed=True)
)
return Response(status=status.HTTP_204_NO_CONTENT)
4 changes: 4 additions & 0 deletions js/src/constants/taskStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,7 @@ export const isFailedTaskStatus = (status?: string): boolean =>
/** Task will not change any more (succeeded or failed). */
export const isTerminalTaskStatus = (status?: string): boolean =>
includesStatus(TERMINAL, status);

/** Task finished and produced data. */
export const isSuccessTaskStatus = (status?: string): boolean =>
status === TASK_STATUS.SUCCESS;
43 changes: 32 additions & 11 deletions js/src/constants/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,7 @@
"iaso.snt_malaria.settings.dataLayers.errors.required": "This field is required",
"iaso.snt_malaria.settings.dataLayers.errors.noWhitespace": "Whitespace not allowed",
"iaso.snt_malaria.settings.dataLayers.errors.scaleItemsCount": "Invalid number of scale items for the selected legend type",
"iaso.snt_malaria.settings.dataLayers.errors.uniqueCodeErrorHeadline": "Layer with this code already exists.",
"iaso.snt_malaria.settings.dataLayers.errors.uniqueCodeError": "A Layer with this code already exists. Please choose a different code.",
"iaso.snt_malaria.settings.dataLayers.errors.codeImmutableHeadline": "Layer code is immutable.",
"iaso.snt_malaria.settings.dataLayers.errors.genericErrorHeadline": "An unexpected error occurred.",
"iaso.snt_malaria.settings.dataLayers.errors.genericError": "Please try again.",
"iaso.snt_malaria.settings.dataLayers.errors.maxLength": "Value must be at most {max} characters long",
"iaso.snt_malaria.settings.intervention.errors.negativeValueNotAllowed": "Negative value not allowed",
Expand All @@ -96,26 +93,20 @@
"iaso.snt_malaria.onboarding.skip": "Skip",
"iaso.snt_malaria.onboarding.goToDocs": "View documentation",
"iaso.snt_malaria.settings.dataLayers.createLayer": "Create Layer",
"iaso.snt_malaria.settings.dataLayers.continueToEditor": "Continue",
"iaso.snt_malaria.settings.dataLayers.generalSectionTitle": "General",
"iaso.snt_malaria.settings.dataLayers.legendSectionTitle": "Legend & scale",
"iaso.snt_malaria.settings.dataLayers.layerType": "Layer type",
"iaso.snt_malaria.settings.dataLayers.layerTypeData": "Standard data layer",
"iaso.snt_malaria.settings.dataLayers.layerTypeDataInfo": "A layer whose data is imported from a CSV file or OpenHEXA. The data key needs to match the source data's.",
"iaso.snt_malaria.settings.dataLayers.layerTypeCompositeInfo": "A layer composed of other layers. Combine any number of input layers using operations and transformations.",
"iaso.snt_malaria.settings.dataLayers.layerTypeOpenHexa": "OpenHexa data layer",
"iaso.snt_malaria.settings.dataLayers.layerTypeOpenHexaInfo": "A layer defined in your OpenHexa configuration. Its metadata and default legend are filled in from OpenHexa.",
"iaso.snt_malaria.settings.dataLayers.openHexaDataLayer": "Data layer",
"iaso.snt_malaria.settings.dataLayers.openHexaDataLayerHelp": "Metadata and default legend come from this data layer.",
"iaso.snt_malaria.settings.dataLayers.openHexaFieldsReadOnly": "read-only · from data layer",
"iaso.snt_malaria.settings.dataLayers.openHexaColorsEditable": "colors editable",
"iaso.snt_malaria.settings.dataLayers.openHexaDataLayersError": "Could not load data layers from OpenHexa.",
"iaso.snt_malaria.settings.dataLayers.openHexaLayersUnimportable": "Some OpenHexa data layers cannot be imported and are hidden from the list:",
"iaso.snt_malaria.settings.dataLayers.openHexaLayersAlreadyImported": "Already imported (hidden): {layers}",
"iaso.snt_malaria.settings.dataLayers.openHexaImportStarted": "Import started. The layer values will appear once the task completes.",
"iaso.snt_malaria.settings.dataLayers.refreshFromOpenHexa": "Refresh from OpenHexa",
"iaso.snt_malaria.settings.dataLayers.importRunning": "Importing data from OpenHexa…",
"iaso.snt_malaria.settings.dataLayers.importFailed": "OpenHexa import failed",
"iaso.snt_malaria.settings.dataLayers.layerSetupIncomplete": "Setup was not completed for this layer",
"iaso.snt_malaria.settings.dataLayers.searchByName": "Search by name",
"iaso.snt_malaria.settings.dataLayers.noLayersFound": "No layers found.",
"iaso.snt_malaria.settings.dataLayers.expandCategory": "Expand category",
Expand Down Expand Up @@ -548,5 +539,35 @@
"iaso.snt_malaria.planning.comparison.totalCostColumnLabel": "Total cost",
"iaso.snt_malaria.planning.comparison.versusLabel": "vs",
"iaso.snt_malaria.planning.comparison.addScenario": "Add scenario",
"iaso.snt_malaria.planning.comparison.removeScenario": "Remove scenario"
"iaso.snt_malaria.planning.comparison.removeScenario": "Remove scenario",
"iaso.snt_malaria.settings.dataLayers.wizard.title": "New data layer",
"iaso.snt_malaria.settings.dataLayers.wizard.stepType": "Type",
"iaso.snt_malaria.settings.dataLayers.wizard.stepDetails": "Details",
"iaso.snt_malaria.settings.dataLayers.wizard.stepData": "Data",
"iaso.snt_malaria.settings.dataLayers.wizard.stepGraph": "Graph",
"iaso.snt_malaria.settings.dataLayers.wizard.stepLegend": "Legend",
"iaso.snt_malaria.settings.dataLayers.wizard.back": "Back",
"iaso.snt_malaria.settings.dataLayers.wizard.typeQuestion": "What kind of layer is this?",
"iaso.snt_malaria.settings.dataLayers.wizard.importCsvHint": "Upload a CSV to fill the table below, then fine-tune it there before saving.",
"iaso.snt_malaria.settings.dataLayers.wizard.addYear": "Add year",
"iaso.snt_malaria.settings.dataLayers.wizard.removeYear": "Remove {year}",
"iaso.snt_malaria.settings.dataLayers.wizard.manualPaste": "Paste from clipboard",
"iaso.snt_malaria.settings.dataLayers.wizard.manualFilledCount": "{filled} of {total} values filled",
"iaso.snt_malaria.settings.dataLayers.wizard.manualPasteHelp": "Paste one value per line, in the district order shown, to fill a year column.",
"iaso.snt_malaria.settings.dataLayers.wizard.valueAboveHundred": "Above 100 while units are percent",
"iaso.snt_malaria.settings.dataLayers.wizard.gridRegion": "Region",
"iaso.snt_malaria.settings.dataLayers.wizard.gridDistrict": "District",
"iaso.snt_malaria.settings.dataLayers.wizard.summaryAddsToCategory": "Creating this layer adds it to {category}.",
"iaso.snt_malaria.settings.dataLayers.wizard.openHexaManualRefresh": "This layer keeps the snapshot you import until someone refreshes it from OpenHexa.",
"iaso.snt_malaria.settings.dataLayers.wizard.discardLayerConfirm": "Nothing is saved yet — leaving now discards this layer.",
"iaso.snt_malaria.settings.dataLayers.wizard.discardGraphConfirm": "Leaving now discards this layer and its graph.",
"iaso.snt_malaria.settings.dataLayers.wizard.discardCreatedConfirm": "Leaving now removes this layer and any values already imported for it.",
"iaso.snt_malaria.settings.dataLayers.wizard.openHexaImportInfo": "The layer was created and its values are loading from OpenHexa in the background — you can carry on through the wizard while it runs.",
"iaso.snt_malaria.settings.dataLayers.wizard.openHexaImportComplete": "Import complete — check the result table.",
"iaso.snt_malaria.settings.dataLayers.wizard.previewPlaceholder": "A preview of your layer appears here once you add its data.",
"iaso.snt_malaria.settings.dataLayers.wizard.next": "Next: {step}",
"iaso.snt_malaria.settings.dataLayers.wizard.saveChanges": "Save changes",
"iaso.snt_malaria.settings.dataLayers.wizard.discardEditConfirm": "Discard your unsaved changes to this layer?",
"iaso.snt_malaria.settings.dataLayers.wizard.backGraphConfirm": "Going back now will discard any unsaved changes to the composite graph.",
"iaso.snt_malaria.settings.dataLayers.openHexaFieldsReadOnly": "Filled in from OpenHexa — read-only."
}
Loading
Loading