-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add import and export functionality for App Users. #4
Merged
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
8fdc483
Add import and export functionality for App Users.
simonkagwi 614593d
Fix database connections for tests in GitHub Actions.
simonkagwi 33901b5
Use full_clean to validate AppUserTemplateVariables during import.
simonkagwi 2456a92
Unit test improvements.
simonkagwi 0299e1c
Show an error message if the import file cannot be read using the sel…
simonkagwi e514933
style forms; fix auto-selection of format on import
copelco 44d597b
Use a PostgreSQL service container for the GA workflow.
simonkagwi c6775e1
Undo temp change in GA workflow.
simonkagwi 81ab67e
Add logging for database changes during imports.
simonkagwi 408befd
Add a success message for successful imports.
simonkagwi 5edac67
Add some comments in our custom import-export classes and methods.
simonkagwi 99c306d
Make AppUser.central_id nullable.
simonkagwi 2841f51
Fix AttributeErrors in create_or_update_app_users().
simonkagwi 4d8f8c5
Test import of a new user with only the name provided.
simonkagwi 3a07ede
Test updating of null AppUser.central_id.
simonkagwi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,176 @@ | ||
from decimal import InvalidOperation | ||
from functools import partial | ||
|
||
import structlog | ||
from django.core.exceptions import ValidationError | ||
from import_export import resources, fields, widgets | ||
|
||
from .models import AppUser, AppUserTemplateVariable | ||
|
||
logger = structlog.getLogger(__name__) | ||
|
||
|
||
class AppUserTemplateVariableWidget(widgets.ForeignKeyWidget): | ||
"""Widget for the AppUserTemplateVariable columns in the import/export files.""" | ||
|
||
def __init__(self, template_variable, **kwargs): | ||
# The TemplateVariable instance for the column | ||
self.template_variable = template_variable | ||
super().__init__(AppUserTemplateVariable, field="value", **kwargs) | ||
|
||
def get_lookup_kwargs(self, value, row, **kwargs): | ||
# A dictionary used to query the AppUserTemplateVariable model to get | ||
# the variable for the current row | ||
return {"template_variable": self.template_variable, "app_user_id": row["id"] or None} | ||
|
||
def clean(self, value, row=None, **kwargs): | ||
"""Validate the AppUserTemplateVariable during import.""" | ||
try: | ||
template_variable = super().clean(value, row, **kwargs) | ||
except AppUserTemplateVariable.DoesNotExist: | ||
template_variable = AppUserTemplateVariable( | ||
value=value, **self.get_lookup_kwargs(value, row, **kwargs) | ||
) | ||
if template_variable is not None: | ||
template_variable.value = value | ||
try: | ||
template_variable.full_clean(exclude=["app_user"]) | ||
except ValidationError as e: | ||
raise ValueError(e.messages[0]) | ||
return template_variable | ||
|
||
|
||
class AppUserTemplateVariableField(fields.Field): | ||
"""Field for the AppUserTemplateVariable columns in the import/export files.""" | ||
|
||
def save(self, instance, row, is_m2m=False, **kwargs): | ||
"""Queue up saving or deleting the AppUserTemplateVariable during import. | ||
At this point the related AppUser may not exist yet (if "id" column is blank) | ||
and/or may not be fully validated so we'll save or delete the | ||
AppUserTemplateVariable later in `AppUserResource.do_instance_save()`. | ||
""" | ||
# Get the validated AppUserTemplateVariable. It will be None if the value | ||
# is blank in the import file, in which case we need to delete the variable | ||
cleaned = self.clean(row, **kwargs) | ||
if cleaned is None: | ||
instance._template_variables_to_delete.append(self.column_name) | ||
else: | ||
instance._template_variables_to_save.append(cleaned) | ||
|
||
|
||
class PositiveIntegerWidget(widgets.IntegerWidget): | ||
"""Widget for the `central_id` column.""" | ||
|
||
def clean(self, value, row=None, **kwargs): | ||
try: | ||
val = super().clean(value, row, **kwargs) | ||
except InvalidOperation: | ||
# The base class will raise a decimal.InvalidOperation if the value | ||
# is not a valid number | ||
raise ValueError("Value must be an integer.") | ||
if val and val < 0: | ||
raise ValueError("Value must be positive.") | ||
return val | ||
|
||
|
||
class AppUserResource(resources.ModelResource): | ||
"""Custom ModelResource for importing/exporting AppUsers.""" | ||
|
||
central_id = fields.Field( | ||
attribute="central_id", column_name="central_id", widget=PositiveIntegerWidget() | ||
) | ||
|
||
class Meta: | ||
model = AppUser | ||
fields = ("id", "name", "central_id") | ||
clean_model_instances = True | ||
|
||
def __init__(self, project): | ||
# The project for which we are importing/exporting AppUsers | ||
self.project = project | ||
super().__init__() | ||
# Add columns for each TemplateVariable related to the project passed in | ||
for template_variable in project.template_variables.all(): | ||
self.fields[template_variable.name] = AppUserTemplateVariableField( | ||
attribute=template_variable.name, | ||
column_name=template_variable.name, | ||
# The `dehydrate_method` will be called with an AppUser instance as | ||
# the only argument to get the value for the AppUserTemplateVariable | ||
# column during export | ||
dehydrate_method=partial(self.get_template_variable_value, template_variable.pk), | ||
widget=AppUserTemplateVariableWidget(template_variable), | ||
) | ||
|
||
def export_resource(self, instance, selected_fields=None, **kwargs): | ||
"""Called for each AppUser instance during export.""" | ||
# Create a dictionary that we can use to look up template variables | ||
# for the instance, instead of doing a DB query for each variable. | ||
# The template variables are prefetched in the queryset passed in to | ||
# the `AppUserResource.export()` call in the view | ||
instance.template_variables_dict = { | ||
i.template_variable_id: i.value for i in instance.app_user_template_variables.all() | ||
} | ||
return super().export_resource(instance, selected_fields, **kwargs) | ||
|
||
def get_queryset(self): | ||
# Queryset used to look up AppUsers during import | ||
return self.project.app_users.all() | ||
|
||
def get_instance(self, instance_loader, row): | ||
"""Called during import to get the current AppUser, by querying the | ||
queryset from `get_queryset()` using the id from the current row. | ||
""" | ||
# `instance` will be None if an AppUser with the ID could not be found | ||
# in the queryset from `get_queryset()` | ||
instance = super().get_instance(instance_loader, row) | ||
if row["id"] and not instance: | ||
raise ValidationError( | ||
{"id": f"An app user with ID {row['id']} does not exist in the current project."} | ||
) | ||
return instance | ||
|
||
@staticmethod | ||
def get_template_variable_value(variable_pk, app_user): | ||
"""Used to set the `dehydrate_method` argument when instantiating a | ||
AppUserTemplateVariableField. | ||
""" | ||
return app_user.template_variables_dict.get(variable_pk) | ||
|
||
def import_instance(self, instance, row, **kwargs): | ||
"""Called for each AppUser during import.""" | ||
instance.project = self.project | ||
# We'll use these lists to queue up AppUserTemplateVariables for saving | ||
# or deleting together in the do_instance_save() method below. | ||
instance._template_variables_to_save = [] | ||
instance._template_variables_to_delete = [] | ||
super().import_instance(instance, row, **kwargs) | ||
|
||
def do_instance_save(self, instance, is_create): | ||
"""Save the AppUser and save/delete the AppUserTemplateVariables.""" | ||
logger.info( | ||
"Updating AppUser via file import", | ||
new=is_create, | ||
id=instance.id, | ||
project_id=self.project.id, | ||
) | ||
instance.save() | ||
for template_variable in instance._template_variables_to_save: | ||
template_variable.app_user = instance | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe we could add logging here and in the delete loop below to log when certain actions occur. |
||
logger.info( | ||
"Updating AppUserTemplateVariable via file import", | ||
new=not template_variable.pk, | ||
id=template_variable.id, | ||
variable_name=template_variable.template_variable.name, | ||
app_user_id=instance.id, | ||
project_id=self.project.id, | ||
) | ||
template_variable.save() | ||
if not is_create: | ||
for name in instance._template_variables_to_delete: | ||
logger.info( | ||
"Deleting AppUserTemplateVariable via file import", | ||
variable_name=name, | ||
app_user_id=instance.id, | ||
project_id=self.project.id, | ||
) | ||
instance.app_user_template_variables.filter(template_variable__name=name).delete() |
22 changes: 22 additions & 0 deletions
22
apps/odk_publish/migrations/0002_alter_appuser_central_id.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
# Generated by Django 5.1.5 on 2025-02-07 16:27 | ||
|
||
from django.db import migrations, models | ||
|
||
|
||
class Migration(migrations.Migration): | ||
dependencies = [ | ||
("odk_publish", "0001_initial"), | ||
] | ||
|
||
operations = [ | ||
migrations.AlterField( | ||
model_name="appuser", | ||
name="central_id", | ||
field=models.PositiveIntegerField( | ||
blank=True, | ||
help_text="The ID of this app user in ODK Central.", | ||
null=True, | ||
verbose_name="app user ID", | ||
), | ||
), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it could be helpful to add comments to the classes/methods in this file, to help provide context of when methods are called.