Skip to content

IA-5375 allow import/export of mappings - #3369

Open
mestachs wants to merge 21 commits into
developfrom
IA-5375-allow-import-export-of-mappings
Open

mestachs wants to merge 21 commits into
developfrom
IA-5375-allow-import-export-of-mappings

Conversation

@mestachs

@mestachs mestachs commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

What problem is this PR solving?

Allow to export/import/copy dhis2 mappings from another form version (or form or server)

Related JIRA tickets

IA-5375

Changes

  • Mainly client side wizard
    • when importing you need to review questions that might be mapped/nevermapped in the current version and that the mapping to import says its
    • added some validations on the json to not accept everything but clearly it just prevent "simple accident" (ex uploading a geojson instead of a mapping file or importing a tracker mapping into an aggregate one)
    • none of the dhis2 ids is "revalidated", we trust, export error might show up because of that
  • Improved just a bit the server side patch and validation
  • Add audit logs to patch and link to display them (just the api output)
    • since now a single "mass import" can mess up everything, let's keep a copy before modifications

How to test

  • the seed command is recommended (much more complete for dhis2 mappings)
    • as the name suggest it's all around dhis2.
    • dhis2 has play servers that can be used, since they regularly release/patches the url change over time
    • you can verify the version : via https://play.dhis2.org (prefer stable version, and sometimes it's public instances, they can be vandalized by people, if you can login with user:admin password:district then the seed command should work
    • you can seed via : ``docker compose run --rm iaso manage seed_test_data --mode=seed --dhis2version=2.40.12`
    • when the command completes you'll get credentials to log in your dev environnement
  • then you can go in the menu :
    • Forms
      • so you can pick probably a "Quantity PCA form stable-xxxx"
      • download the xlsform (the file with survey / choices / settings sheet)
image - re-upload it as is with "create version" - final list will look like this image - go back to the form, click on the dhis2 icon image - pick the last version (2026...) image - the general concept is a question is mapped to one data element in dhis2 (or several if tracker, this adds a bit of complexity everywhere), - note this screen is old, was initially targeted to blsq people, a redesign might come afterwards, and we some legacy too (typing, not standard components, weird diff in mappings since I was young and in a hurry) - then you have access to the 2 new buttons : one for exporting, one for importing the mappings where the wizard/dialog lies.

The rest of test is playing with same features

  • creating form versions that will copy the mapping and remove questions from the mappings that are no more in the forms
  • importing a json or comparing/importing with a previous version
  • target mainly event/aggregate mappings (tracker is no more used and would definitively need some cleanup)
  • create new version of the same form (add/remove/rename questions)
  • then you can see impacts on the wizard
  • you can also manipulate the mapping in normal edit session
  • then replay with the wizard (reimport form another version)
  • rince and repeat

Print screen / video

2 new buttons at the top

image

then a "big" wizard/dialog to pick from another version or import from a file

a first step to pick the mapping

image

then decide for each questions, if you want to take it or not (especially if a question was mapped in current and to import mapping)

image image image

Notes

  • re-validating the dhis2 ids hidden in mapping is out of scope (the export will fail that will the sign)
  • since we now have the audit logs we might be able to restore an old/working mapping if necessary

Doc

Tell us where the doc can be found (docs folder, wiki, in the code...).

@tdethier
tdethier self-requested a review October 6, 2026 08:27

@tdethier tdethier left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

general comments:

  • i made a few comments to improve readability, otherwise backend looks good
  • i don't really understand what these mappings are, so some comments i made below might be dumb

testing:

  • the "how to test" section might need a bit more explanations, because i was lost
  • comparing a mapping with itself shows that all quesitons are dropped, i would expect everything to be shown in "identical"
image
  • when checking a mapping and clicking on a question on the left side, i get an error screen
Image

it looks like each click calls our API, which calls the DHIS2 API and there's a configuration issue on my account - we should probably get a better error message in such cases

ERROR    2026-10-07 10:56:55,656 django.request -- Internal Server Error: /api/datasources/78/dataElements.json
requests.exceptions.HTTPError: 404 Client Error: Not Found for url: https://play.im.dhis2.org/stable-2-41-9/api/dataElements.json?fields=id%2Cname%2CvalueType%2CdomainType%2CoptionSet%5Boptions%5Bid%2Cname%2Ccode%5D%5D%2CcategoryCombo%5Bid%2Cname%2CcategoryOptionCombos%5Bid%2Cname%5D%5D%2CdataSetElements%5BdataSet%5Bid%2Cname%2CperiodType%5D%5D&pageSize=10&filter=code%3Ailike%3Aimei

i'll let somebody else review the frontend

Comment thread iaso/models/base.py


class MappingVersionQuerySet(models.QuerySet):
def filter_for_user(self, user: User):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i would probably add a unit test at the model level to make sure you get the expected results from this

Comment thread iaso/odk/parsing.py Outdated


def visit_mappable(node, form_descriptor, mappable_questions):
if node.get("type") not in NOT_MAPPABLE_TYPES and "name" in node:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i would make local variables for node_type and node_name

mapping_version.form_version = form_version

questions_by_name = form_version.questions_by_name()
# unlike questions_by_name, also has the questions inside repeat groups

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

do you still need questions_by_name then?

Comment thread iaso/odk/parsing.py Outdated
SELECT_MULTIPLE_TYPE = "select all that apply"


def _choices(node, form_descriptor):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i would also rename this, i don't get why it's called _choices but returns children nodes

from .common import HasPermission, ModelViewSet, TimestampField


def get_question_mapping_shape_error(mapping_type, data_element):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nitpick: i would probably add unit tests for this function and test it without any API call, but tests with API calls work too

Comment thread iaso/api/mapping_versions.py Outdated
if data_element is not None and not is_unmap:
shape_error = get_question_mapping_shape_error(instance.mapping.mapping_type, data_element)
if shape_error:
raise serializers.ValidationError({path: shape_error})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

maybe we should store errors in a list, then check outside the for question_name, data_element loop if the list has errors, in case there are multiple errors in the same question_mappings? i'm not sure if that scenario is possible

Comment thread iaso/tests/api/test_mappingversions.py Outdated
Comment on lines +342 to +343
self.assertJSONResponse(resp, status.HTTP_200_OK)
self.assertEqual(len(resp.json()["mapping_versions"]), 0)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

same suggestion for all calls

Suggested change
self.assertJSONResponse(resp, status.HTTP_200_OK)
self.assertEqual(len(resp.json()["mapping_versions"]), 0)
result = self.assertJSONResponse(resp, status.HTTP_200_OK)
self.assertEqual(len(result["mapping_versions"]), 0)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

went a step further

    def assertMappingVersionsCount(self, response, expected_count):
        result = self.assertJSONResponse(response, status.HTTP_200_OK)
        self.assertEqual(len(result["mapping_versions"]), expected_count)

self.assertEqual(self.get_question_mappings(mapping_version_id), original)
self.assertEqual(Modification.objects.filter(object_id=mapping_version_id).count(), 1)

def test_mappingversions_patch_logs_modification(self):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

👍

@mestachs

mestachs commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor Author

the 500 / http error looks more the datasource or dhis2 wasn't available anymore (perhaps play dhis2 url version increment ?) or you seeded the account right before testing ?

thanks, I'll have a look at the comments

@mestachs

mestachs commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor Author

so from your bad experience during testing

  • removed the show other mappings using the same dataset/program (not the query was triggering too much remote n+1)
    • advanced user can still export and reimport the json (the wizard will still review and allow safe import only)
  • fixed a bit the seed command to have more valid xlsform and mappings (quality form is still not matching its mapping but it's a lot more complex) (DONE)
  • I noticed a n+1 when creating more mapping versions so fixed it and wrote test for it

@Bewi Bewi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Globally it looks good, I would just give a bit more love to the dialog:

  • Fix table overflow (first column should not grow like it does now)
  • Empty tables => improve layout, either hide it and show a message or keep headings and show a message under it
Image Image

});
return result;
}, [rows]);
const bucketRows = rows.filter(row => row.kind === bucket);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

useMemo

mappingVersionId: mappingVersion.id,
questionMappings: plan.changes,
});
openSnackBar(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why not using the bulkUpdate snack query for this?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ah, I get it, it doesn't allow to use replacements.
Could we log a task for this ?


{currentMappingVersion && (
<Box className={classes.containerFullHeightNoTabPadded}>
<Box className={classes.actions}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I see we still use className with classes here.
If not to much of work, I would change it to use SX in this file.

};
rows.forEach(row => {
const decision = decisions[row.questionKey];
if (row.kind === 'dropped') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Would be nice to have all kinds in an enum

export class MappingImportError extends Error {
constructor(
public reason:
| 'invalidJson'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same here, to prevent typos and improve reusability of this list of invalid errors

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants