From a5ec801a2a91f928bf582ee9ba2092a5bfbe7d7e Mon Sep 17 00:00:00 2001 From: "Albert (AJ) St. Aubin" Date: Tue, 3 Mar 2020 14:50:28 -0500 Subject: [PATCH] Add an Admin page view to add External IDs for Users in bulk [MICROBA-152] --- .../generate_external_ids_form.html | 21 +++ .../generate_external_user_ids.html | 6 + .../djangoapps/external_user_ids/admin.py | 129 ++++++++++++++++++ .../djangoapps/external_user_ids/models.py | 4 +- .../external_user_ids/tests/factories.py | 26 ++++ .../tests/test_admin_generate_id.py | 62 +++++++++ 6 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 lms/templates/admin/external_user_ids/generate_external_ids_form.html create mode 100644 lms/templates/admin/external_user_ids/generate_external_user_ids.html create mode 100644 openedx/core/djangoapps/external_user_ids/admin.py create mode 100644 openedx/core/djangoapps/external_user_ids/tests/factories.py create mode 100644 openedx/core/djangoapps/external_user_ids/tests/test_admin_generate_id.py diff --git a/lms/templates/admin/external_user_ids/generate_external_ids_form.html b/lms/templates/admin/external_user_ids/generate_external_ids_form.html new file mode 100644 index 0000000000..23533bef52 --- /dev/null +++ b/lms/templates/admin/external_user_ids/generate_external_ids_form.html @@ -0,0 +1,21 @@ +{% extends 'admin/base.html' %} + +{% block content %} +
+

Generate External IDs in bulk

+

+ This form will generate external IDs for a set of Users based on the + provided CSV file. The file should contain a header labeled "ID" and then + a list of numeric IDs, one row per ID. +

+
+ {{ form.as_p }} + {% csrf_token %} + + Cancel + +
+
+
+ +{% endblock %} diff --git a/lms/templates/admin/external_user_ids/generate_external_user_ids.html b/lms/templates/admin/external_user_ids/generate_external_user_ids.html new file mode 100644 index 0000000000..25fef566ae --- /dev/null +++ b/lms/templates/admin/external_user_ids/generate_external_user_ids.html @@ -0,0 +1,6 @@ +{% extends 'admin/change_list.html' %} + +{% block object-tools-items %} +
  • Bulk Generate External IDs
  • + {{ block.super }} +{% endblock %} diff --git a/openedx/core/djangoapps/external_user_ids/admin.py b/openedx/core/djangoapps/external_user_ids/admin.py new file mode 100644 index 0000000000..749379a213 --- /dev/null +++ b/openedx/core/djangoapps/external_user_ids/admin.py @@ -0,0 +1,129 @@ +import csv +from logging import getLogger + +from django import forms +from django.conf.urls import url +from django.contrib import admin, messages +from django.contrib.auth import get_user_model +from django.http import HttpResponseRedirect +from django.shortcuts import render +from django.urls import reverse + +from .models import ExternalId, ExternalIdType + +User = get_user_model() + +logger = getLogger(__name__) + + +class CsvImportForm(forms.Form): + csv_file = forms.FileField(label='CSV File') + id_type = forms.ModelChoiceField( + label='External ID Type', + queryset=ExternalIdType.objects.all() + ) + + +@admin.register(ExternalId) +class ExternalIdAdmin(admin.ModelAdmin): + change_list_template = 'admin/external_user_ids/generate_external_user_ids.html' + list_display = ('user', 'external_user_id', 'external_id_type') + template = 'openedx/core/djangoapps/external_user_ids/templates/admin/generate_external_ids_template.html' + + def get_urls(self): + urls = super(ExternalIdAdmin, self).get_urls() + custom_urls = [ + url( + r'^bulk_generate_external_ids/$', + self.admin_site.admin_view(self.generate_ids_form), + name='bulk_generate_external_ids' + ), + ] + return custom_urls + urls + + def _generate_results_msg(self, user_id_list, unknown_users, created_id_list, existing_id): + return ( + 'Attempted to create for: {}\n'.format(user_id_list) + + 'Could not find: {}\n'.format(unknown_users) + + 'Created External IDs for: {}\n'.format(created_id_list) + + 'External IDs already exist for: {}\n'.format(existing_id) + ) + + def process_generate_ids_request(self, user_id_list, id_type, request, redirect_url): + created_id_list = [] + existing_id = [] + + user_list = User.objects.filter( + id__in=user_id_list + ) + for user in user_list: + new_external_id, created = ExternalId.objects.get_or_create( + user=user, + external_id_type=id_type, + ) + if created: + created_id_list.append(user.id) + else: + existing_id.append(user.id) + found_user_ids = created_id_list + existing_id + unknown_users = list(set(user_id_list) - set(found_user_ids)) + result_msg = self._generate_results_msg(user_id_list, unknown_users, created_id_list, existing_id) + logger.info(result_msg) + self.message_user( + request, + result_msg, + level=messages.SUCCESS) + return HttpResponseRedirect(redirect_url) + + def _render_form(self, request, form): + context = { + 'form': form + } + return render( + request, + 'admin/external_user_ids/generate_external_ids_form.html', + context + ) + + def generate_ids_form(self, request): + if request.method == 'POST': + redirect_url = reverse( + 'admin:external_user_ids_externalid_changelist', + current_app=self.admin_site.name, + ) + upload_file = request.FILES.get('csv_file') + id_type = request.POST.get('id_type') + + if not upload_file or not id_type: + self.message_user(request, 'CSV file and type are required.', level=messages.ERROR) + return HttpResponseRedirect(redirect_url) + + try: + id_type = ExternalIdType.objects.get(id=id_type) + except ExternalIdType.DoesNotExist: + self.message_user(request, 'ID Type selected does not exist', level=messages.ERROR) + return HttpResponseRedirect(redirect_url) + + reader = csv.reader(upload_file.read().decode('utf-8').splitlines()) + headers = next(reader, None) + if len(headers) != 1 or 'ID' not in headers: + self.message_user( + request, + 'File is incorrectly formatted. To many columns or incorrectly named ID column', + level=messages.ERROR + ) + return HttpResponseRedirect(redirect_url) + try: + user_ids = [int(row[0]) for row in reader] + except ValueError: + self.message_user( + request, + 'Data is incorrectly formatted. All ids must be integers', + level=messages.ERROR + ) + return HttpResponseRedirect(redirect_url) + + return self.process_generate_ids_request(user_ids, id_type, request, redirect_url) + + form = CsvImportForm() + return self._render_form(request, form) diff --git a/openedx/core/djangoapps/external_user_ids/models.py b/openedx/core/djangoapps/external_user_ids/models.py index 3bf0980c85..708385394d 100644 --- a/openedx/core/djangoapps/external_user_ids/models.py +++ b/openedx/core/djangoapps/external_user_ids/models.py @@ -11,7 +11,6 @@ from django.db import models from model_utils.models import TimeStampedModel from simple_history.models import HistoricalRecords - LOGGER = getLogger(__name__) @@ -28,6 +27,9 @@ class ExternalIdType(TimeStampedModel): description = models.TextField() history = HistoricalRecords() + def __str__(self): + return self.name + class ExternalId(TimeStampedModel): """ diff --git a/openedx/core/djangoapps/external_user_ids/tests/factories.py b/openedx/core/djangoapps/external_user_ids/tests/factories.py new file mode 100644 index 0000000000..1cd9cc93fc --- /dev/null +++ b/openedx/core/djangoapps/external_user_ids/tests/factories.py @@ -0,0 +1,26 @@ +""" +Test Factory classes for ExternalUserIds +""" + +from uuid import uuid4 + +import factory +from factory.fuzzy import FuzzyChoice, FuzzyText + +from openedx.core.djangoapps.external_user_ids.models import ExternalId, ExternalIdType + + +class ExternalIDTypeFactory(factory.django.DjangoModelFactory): + class Meta(object): + model = ExternalIdType + + name = FuzzyChoice([ExternalIdType.MICROBACHELORS_COACHING]) + description = FuzzyText() + + +class ExternalIdFactory(factory.django.DjangoModelFactory): + class Meta(object): + model = ExternalId + + external_user_id = factory.LazyFunction(uuid4) + external_id_type = factory.SubFactory(ExternalIDTypeFactory) diff --git a/openedx/core/djangoapps/external_user_ids/tests/test_admin_generate_id.py b/openedx/core/djangoapps/external_user_ids/tests/test_admin_generate_id.py new file mode 100644 index 0000000000..fd48a8292b --- /dev/null +++ b/openedx/core/djangoapps/external_user_ids/tests/test_admin_generate_id.py @@ -0,0 +1,62 @@ +# """ +# Test the logic behind the Generate External IDs tools in Admin +# """ +import mock +from django.conf import settings +from django.contrib.admin.sites import AdminSite +from django.test import TestCase +from student.tests.factories import UserFactory + +from openedx.core.djangolib.testing.utils import skip_unless_lms + +# external_ids is not in CMS' INSTALLED_APPS so these imports will error during test collection +if settings.ROOT_URLCONF == 'lms.urls': + from openedx.core.djangoapps.external_user_ids.models import ( + ExternalId, + ) + from openedx.core.djangoapps.external_user_ids.tests.factories import ExternalIDTypeFactory + from openedx.core.djangoapps.external_user_ids.admin import ExternalIdAdmin + + +@skip_unless_lms +class TestGenerateExternalIds(TestCase): + """ + Test generating ExternalIDs for Users. + """ + def setUp(self): + super(TestGenerateExternalIds, self).setUp() + self.users = UserFactory.create_batch(10) + self.user_id_list = [user.id for user in self.users] + self.external_id_admin = ExternalIdAdmin(ExternalId, AdminSite()) + + def test_generate_ids_for_all_users(self): + id_type = ExternalIDTypeFactory.create() + request = mock.Mock() + + assert ExternalId.objects.count() == 0 + self.external_id_admin.process_generate_ids_request( + user_id_list=self.user_id_list, + id_type=id_type, + request=request, + redirect_url='' + ) + assert ExternalId.objects.count() == 10 + + def test_no_new_for_existing_users(self): + id_type = ExternalIDTypeFactory.create() + request = mock.Mock() + + for user in self.users: + ExternalId.objects.create( + user=user, + external_id_type=id_type + ) + + assert ExternalId.objects.count() == 10 + self.external_id_admin.process_generate_ids_request( + user_id_list=self.user_id_list, + id_type=id_type, + request=request, + redirect_url='' + ) + assert ExternalId.objects.count() == 10