Skip to content
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

bulk user import #949

Merged
merged 8 commits into from
Nov 7, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
Prev Previous commit
Next Next commit
feat: add task description, form validation
  • Loading branch information
dodumosu committed Sep 1, 2023
commit d5dd379f58ce3e67f23ffdea01c138b39c0a099c
12 changes: 6 additions & 6 deletions apollo/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
TASK_DESCRIPTIONS = {
'apollo.locations.tasks.import_locations': _('Import Locations'),
'apollo.participants.tasks.import_participants': _('Import Participants'),
'apollo.submissions.tasks.init_submissions': _('Generate Checklists')
'apollo.submissions.tasks.init_submissions': _('Generate Checklists'),
'apollo.submissions.tasks.init_survey_submissions': _('Generate Surveys'),
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is probably related to another PR and shouldn't be included here.

'apollo.users.tasks.import_users': _('Import Users'),
}


Expand Down Expand Up @@ -110,8 +112,6 @@ def delay_flask_security_mail(msg):
subject=msg.subject, sender=msg.sender, recipients=msg.recipients,
body=msg.body)



configure_uploads(app, uploads)

# set up JWT callbacks
Expand Down Expand Up @@ -187,7 +187,7 @@ def on_failure(self, exc, task_id, args, kwargs, einfo):
'id': task_id,
'status': _('FAILED'),
'progress': self.task_info,
'description': TASK_DESCRIPTIONS.get(self.request.task),
'description': TASK_DESCRIPTIONS.get(self.request.task, _('Task')),
'quit': True,
}

Expand All @@ -205,7 +205,7 @@ def on_success(self, retval, task_id, args, kwargs):
'id': task_id,
'status': _('COMPLETED'),
'progress': self.task_info,
'description': TASK_DESCRIPTIONS.get(self.request.task),
'description': TASK_DESCRIPTIONS.get(self.request.task, _('Task')),
'quit': True,
}

Expand All @@ -227,7 +227,7 @@ def update_task_info(self, **kwargs):
'id': request.id,
'status': _('RUNNING'),
'progress': task_metadata.get('result'),
'description': TASK_DESCRIPTIONS.get(self.request.task)
'description': TASK_DESCRIPTIONS.get(self.request.task, _('Task'))
}

if channel is not None:
Expand Down
4 changes: 2 additions & 2 deletions apollo/frontend/views_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ def import_headers(upload_id: int):
upload.delete()
return abort(400)

template_name = 'admin/location_headers.html'
template_name = 'admin/user_headers.html'

if request.method == 'GET':
form = mapping_form_class()
Expand All @@ -109,7 +109,7 @@ def import_headers(upload_id: int):
error_msgs.append(msg)

return render_template(
'admin/location_headers_errors.html', error_msgs=error_msgs
'admin/user_headers_errors.html', error_msgs=error_msgs
), 400

data = {}
Expand Down
2 changes: 2 additions & 0 deletions apollo/templates/admin/user_headers.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{% from 'admin/locations_header_form.html' import render_mapping_form %}
{{ render_mapping_form(form) }}
13 changes: 13 additions & 0 deletions apollo/templates/admin/user_headers_errors.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<div class="alert alert-danger alert-dismissible fade show" role="alert">
{% trans %}<strong>There was a problem</strong> with your submission. Please correct the errors and re-submit.{% endtrans %}
{% if error_msgs -%}
<ul id="error-list" class="mb-0 mt-1">
{%- for error_msg in error_msgs %}
<li>{{ error_msg }}</li>
{% endfor -%}
</ul>
<button type="button" class="close" data-dismiss="alert" aria-label="{{ _('Close') }}">
<span aria-hidden="true">&times;</span>
</button>
{%- endif %}
</div>
155 changes: 155 additions & 0 deletions apollo/templates/admin/user_list.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
{% extends "admin/model/list.html" %}

{% block model_menu_bar_after_filters %}
<li class="nav-item"><a href="#" class="nav-link" id="open-wizard">{{ _('Bulk import') }}</a></li>
{% endblock %}

{% block head_css %}
{{ super() }}
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='css/bootstrap-wizard.css') }}">
{% endblock head_css %}


{% block body %}
{{ super() }}

<!-- IMPORT WIZARD -->
<div id="import-wizard" class="wizard" data-title="{{ _('Import Users') }}">
<!-- step #1 -->
<div class="wizard-card" data-cardname="uploadFile">
<h3>{{ _('Upload File') }}</h3>
<div class="wizard-input-section">
<form action="{{ url_for('user.import_users') }}" enctype="multipart/form-data" method="post" id="upload-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="custom-file">
<input type="file" class="custom-file-input upload" id="uploadFile" name="spreadsheet">
<label for="uploadFile" class="custom-file-label" data-browse="{{ _('Browse') }}">{{ _('Choose file') }}</label>
<small class="form-text text-muted" id="uploadFileHelptext">{{ _('Only .csv, .xls and .xlsx files') }}</small>
</div>
</form>
</div>
</div>

<!-- step #2 -->
<div class="wizard-card pr-0" data-cardname="mapFields">
<h3>{{ _('Map Fields') }}</h3>
<div class="wizard-input-section overflow-auto"></div>
</div>

<div class="wizard-card" data-cardname="finalize">
<h3>{{ _('Finalize') }}</h3>
<div class="wizard-input-section">
<div class="alert alert-info">
<span class="create-server-name">{% trans %}Click the <strong>Submit</strong> button to begin the import process.{% endtrans %}</span>
</div>
</div>

<div class="wizard-failure">
<div class="alert alert-danger">
{% trans %}There was a problem submitting the form. Please try again in a minute.{% endtrans %}
</div>
</div>
</div>
</div>
{% endblock body %}

{% block tail_js %}
{{ super() }}
<script type="text/javascript" src="{{ url_for('static', filename='js/bootstrap-wizard.min.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/jquery.form.js') }}"></script>
<script type="text/javascript">
$(function() {
var wizard = $('#import-wizard').wizard({
keyboard : true,
contentHeight : 500,
contentWidth : 700,
showCancel: true,
backdrop: true,
buttons: {'cancelText': "{{ _('Cancel') }}",
'nextText': "{{ _('Next') }}",
'backText': "{{ _('Back') }}",
'submitText': "{{ _('Submit') }}",
'submittingText': "{{ _('Submitting...') }}"}
});

wizard.on('closed', function() {
wizard.reset();
var uploadFile = document.getElementById('uploadFile');
uploadFile.value = ''
uploadFile.dispatchEvent(new Event('change'));
$('#uploadFile').removeClass('is-invalid');
$('#uploadFileHelptext').removeClass('invalid-feedback');
});

wizard.cards['uploadFile'].on('validate', function (card) {
var cont = true;
$('#upload-form').ajaxSubmit({
async: false,
error: function (data) {
input = card.el.find("#uploadFile");
input_helptext = card.el.find('#uploadFileHelptext');
input.addClass('is-invalid');
input_helptext.removeClass('text-muted');
input_helptext.addClass('invalid-feedback');
cont = false;
},
success: function (data) {
$('.wizard-input-section', wizard.cards['mapFields'].el).html(data);
$('.wizard-input-section').height(wizard.dimensions.cardContainer - 85);
cont = true;
}
});
return cont;
});

wizard.cards['mapFields'].on('validate', function (card) {
var cont = false;

$.ajax({
type: 'POST',
url: $('#form-action').val(),
data: wizard.serialize(),
async: false,
beforeSend: function (xhr) { xhr.setRequestHeader('X-Validate', '1'); },
}).done(function (response) {
cont = true;
}).fail(function (data) {
$('#form-action-errors', wizard.cards['mapFields'].el).html(data.responseText);
$('#form-action-errors', wizard.cards['mapFields'].el).removeClass('d-none');
cont = false;
});

return cont;
});

wizard.on("submit", function(wizard) {
$.ajax({
type: 'POST',
url: $('#form-action').val(),
data: wizard.serialize()
}).done(function (response) {
wizard.trigger("success");
wizard.hideButtons();
wizard._submitting = false;
wizard.submitSuccess();
wizard.updateProgressBar(0);
wizard.close();
}).fail(function (data) {
wizard.submitFailure();
wizard.showButtons();
});
});

$('#open-wizard').click(function(e) {
e.preventDefault();
wizard.reset();
var uploadFile = document.getElementById('uploadFile');
uploadFile.value = ''
uploadFile.dispatchEvent(new Event('change'));
$('#uploadFile').removeClass('is-invalid');
$('#uploadFileHelptext').removeClass('invalid-feedback');
wizard.show();
});
});
</script>
{% endblock tail_js %}
21 changes: 20 additions & 1 deletion apollo/users/forms.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
from flask_babelex import lazy_gettext as _
from flask_babelex import gettext, lazy_gettext as _
from flask_wtf import FlaskForm
from sqlalchemy.sql import and_, exists
from wtforms import fields, validators, widgets
Expand Down Expand Up @@ -85,5 +85,24 @@ def make_import_mapping_form(import_file):
for index, column in enumerate(data_frame.columns):
attributes[str(index)] = fields.SelectField(
column, choices=field_choices)

def _validate_mappings(form: FlaskForm) -> bool:
rv = FlaskForm.validate(form)

errors = []
mapped_values = form.data.values()

if 'email' not in mapped_values:
errors.append(
gettext('Email was not mapped'),
)
rv = False

form.errors['__validate__'] = errors

return rv

attributes['validate'] = _validate_mappings


return type('UserImportMapForm', (FlaskForm,), attributes)
9 changes: 9 additions & 0 deletions apollo/users/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import os

import pandas as pd
from flask_babelex import gettext

from apollo import helpers
from apollo.core import uploads
Expand Down Expand Up @@ -57,6 +58,10 @@ def import_users(task, upload_id: int, mappings: dict, channel: str = None):

# email is required
if not _is_valid(email):
error_log.append({
'label': 'ERROR',
'message': gettext('No valid email found'),
})
error_records += 1
continue

Expand All @@ -74,6 +79,10 @@ def import_users(task, upload_id: int, mappings: dict, channel: str = None):

# password is required for a new user
if not _is_valid(password) and user.id is None:
error_log.append({
'label': 'ERROR',
'message': gettext('New user has no password set'),
})
error_records += 1
continue
user.set_password(str(password))
Expand Down