-
Notifications
You must be signed in to change notification settings - Fork 72
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 organization user creation/addition feature #3651
Changes from 28 commits
4b45cd9
2741dd6
125d965
741405b
df20fef
83f4498
6c88171
0e60202
0f09756
d41aa7e
420ae6a
808e525
cd4a571
0f4251c
2431e07
d9ef26e
6488bfe
18e1fa2
4e76c75
eace4cb
41d7dcc
afb6a4d
eb97ff2
2d59d57
3bed51f
1cbee33
c5483e2
2b4e104
4ce15de
acd0457
92e06de
cf5882d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,8 @@ | ||
import logging | ||
import secrets | ||
import csv | ||
import re | ||
from io import TextIOWrapper | ||
import string | ||
from typing import Any, Mapping | ||
|
||
|
@@ -437,6 +440,161 @@ def save(self, commit=True): | |
return instance | ||
|
||
|
||
class MultipleUsersFormWithOrganization(ModelForm): | ||
""" | ||
Create multiple organization users via CSV file | ||
""" | ||
organizations = forms.ModelChoiceField(label='Organization', queryset=Organization.objects.order_by('name')) | ||
indefinite_affiliation = forms.BooleanField( | ||
label="Permanent affiliation", | ||
required=False, | ||
initial=True | ||
) | ||
expires_at = forms.DateTimeField( | ||
label="Affiliation expiration date", | ||
widget=forms.DateTimeInput(attrs={"type": "date"}), | ||
required=False | ||
) | ||
csv_file = forms.FileField(label='* User information', | ||
help_text=mark_safe("<br>* When creating your CSV, please include the following fields: first_name, last_name, email. " | ||
"First and last name columns may be left blank.<br><br>" | ||
"If there is already a Perma.cc account associated with an " | ||
"email, we will add an Organization affiliation. If there is not, " | ||
"an account will be created and automatically affiliated with this " | ||
"Organization.")) | ||
|
||
def __init__(self, request, data=None, files=None, **kwargs): | ||
super(MultipleUsersFormWithOrganization, self).__init__(data, files, **kwargs) | ||
self.request = request | ||
self.user_data = {} | ||
self.created_users = {} | ||
self.updated_users = {} | ||
self.ineligible_users = {} | ||
|
||
# Filter available organizations based on the current user | ||
query = self.fields['organizations'].queryset | ||
if request.user.is_registrar_user(): | ||
query = query.filter(registrar_id=request.user.registrar_id) | ||
elif request.user.is_organization_user: | ||
query = query.filter(users=request.user.pk) | ||
self.fields['organizations'].queryset = query | ||
|
||
class Meta: | ||
model = LinkUser | ||
fields = ["organizations", "indefinite_affiliation", "expires_at", "csv_file"] | ||
|
||
def clean_csv_file(self): | ||
file = self.cleaned_data['csv_file'] | ||
|
||
# check if file is CSV | ||
if not file.name.endswith('.csv'): | ||
raise forms.ValidationError("The file must be a CSV.") | ||
|
||
file = TextIOWrapper(file, encoding='utf-8') | ||
reader = csv.DictReader(file) | ||
|
||
# validate the headers | ||
headers = reader.fieldnames | ||
if not all(item in headers for item in ['first_name', 'last_name', 'email']): | ||
raise forms.ValidationError("CSV file must contain a header row with first_name, last_name and email columns.") | ||
|
||
# validate the rows | ||
seen = set() | ||
row_count = 0 | ||
|
||
for row in reader: | ||
row_count += 1 | ||
email = row.get('email') | ||
email = email.strip().lower() if email else None | ||
rebeccacremona marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
if not email: | ||
raise forms.ValidationError("Each row in the CSV file must contain email.") | ||
if not re.match(r'^[^\s@]+@[^\s@]+\.[^\s@]+$', email): | ||
raise forms.ValidationError("CSV file must contain valid email addresses.") | ||
rebeccacremona marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
if email in seen: | ||
raise forms.ValidationError("CSV file cannot contain duplicate users.") | ||
rebeccacremona marked this conversation as resolved.
Show resolved
Hide resolved
|
||
else: | ||
seen.add(email) | ||
self.user_data[email] = { | ||
'first_name': row.get('first_name', '').strip(), | ||
'last_name': row.get('last_name', '').strip() | ||
} | ||
|
||
if row_count == 0: | ||
raise forms.ValidationError("CSV file must contain at least one user.") | ||
|
||
file.seek(0) | ||
self.cleaned_data['csv_file'] = file | ||
return file | ||
|
||
def save(self, commit=True): | ||
expires_at = self.cleaned_data['expires_at'] | ||
organization = self.cleaned_data['organizations'] | ||
|
||
emails = set(self.user_data.keys()) | ||
existing_users = LinkUser.objects.filter(email__in=emails) | ||
rebeccacremona marked this conversation as resolved.
Show resolved
Hide resolved
|
||
updated_user_affiliations = [] | ||
|
||
for user in existing_users: | ||
if commit: | ||
if user.is_staff or user.is_registrar_user(): | ||
self.ineligible_users[user.email] = user | ||
else: | ||
updated_user_affiliations.append(user) | ||
self.updated_users[user.email] = user | ||
|
||
new_user_emails = emails - set(self.ineligible_users.keys()) - set(self.updated_users.keys()) | ||
created_user_affiliations = [] | ||
|
||
if new_user_emails and commit: | ||
for user in new_user_emails: | ||
rebeccacremona marked this conversation as resolved.
Show resolved
Hide resolved
|
||
new_user = LinkUser( | ||
email=user, | ||
first_name=self.user_data[user]['first_name'], | ||
last_name=self.user_data[user]['last_name'] | ||
) | ||
new_user.save() | ||
self.created_users[user] = new_user | ||
|
||
created_user_affiliations.append( | ||
UserOrganizationAffiliation( | ||
user=new_user, | ||
organization=organization, | ||
expires_at=expires_at | ||
) | ||
) | ||
|
||
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. I regret am having a little trouble reading this section. I think it is partly because of variable names.
similarly with I am confused whether |
||
if commit: | ||
# create the affiliations for new users | ||
UserOrganizationAffiliation.objects.bulk_create(created_user_affiliations) | ||
|
||
# create or update the affiliations of existing users | ||
# affiliations that already exist | ||
preexisting_affiliations = (UserOrganizationAffiliation.objects.filter(user__in=updated_user_affiliations, | ||
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. This is just a thought about readability. Do you think it would be easier to read if, right here after you get the existing affiliation objects, you did the update? Then, you could do the calculations to figure out if any new affiliation objects need to be created, and then create them, in its own section with its own comment. I think that might be clearer than doing them together, but you may disagree 🙂 |
||
organization=organization)) | ||
|
||
preexisting_affiliations_set = set(affiliation.user for affiliation in preexisting_affiliations) | ||
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. Ah, here's a good Django + database thing to know about! Since you are iterating through the |
||
all_user_affiliations = set(updated_user_affiliations) | ||
# new affiliations | ||
new_affiliations = all_user_affiliations - preexisting_affiliations_set | ||
new_affiliation_objs = [] | ||
|
||
for item in new_affiliations: | ||
new_affiliation_objs.append(UserOrganizationAffiliation( | ||
user=item, | ||
organization=organization, | ||
expires_at=expires_at | ||
)) | ||
|
||
if preexisting_affiliations: | ||
preexisting_affiliations.update(expires_at=expires_at) | ||
if new_affiliation_objs: | ||
UserOrganizationAffiliation.objects.bulk_create(new_affiliation_objs) | ||
|
||
return self | ||
|
||
|
||
### USER EDIT FORMS ### | ||
|
||
class UserAddRegistrarForm(UserFormWithRegistrar): | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
# Generated by Django 4.2.16 on 2024-11-20 18:37 | ||
|
||
from django.db import migrations | ||
|
||
FLAG_NAME = "bulk-organization-user-creation" | ||
|
||
def create_bulk_organization_user_creation_feature_flag(apps, schema_editor): | ||
Flag = apps.get_model("waffle", "Flag") | ||
flag = Flag( | ||
name=FLAG_NAME, | ||
testing=True | ||
) | ||
flag.save() | ||
|
||
def delete_bulk_organization_user_creation_feature_flag(apps, schema_editor): | ||
Flag = apps.get_model("waffle", "Flag") | ||
flags = Flag.objects.filter(name=FLAG_NAME) | ||
flags.delete() | ||
|
||
class Migration(migrations.Migration): | ||
|
||
dependencies = [ | ||
('perma', '0056_delete_historicallink'), | ||
('waffle', '0004_update_everyone_nullbooleanfield'), | ||
] | ||
|
||
operations = [ | ||
migrations.RunPython(create_bulk_organization_user_creation_feature_flag, delete_bulk_organization_user_creation_feature_flag), | ||
] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,5 @@ | ||
TITLE: A Perma.cc account has been created for you | ||
{% with form.cleaned_data.organizations.0 as org %} | ||
{% with form.cleaned_data.organizations as org %} | ||
A Perma.cc account has been created for you by {{ request.user.get_full_name }} on behalf of {{ org }}.{% endwith %} | ||
|
||
{% include 'email/includes/activation.txt' %} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
{% extends "admin-layout.html" %} | ||
|
||
{% block title %} | Add users to organization{% endblock %} | ||
|
||
{% block adminContent %} | ||
<h3 class="body-bh">Add multiple users to organization via CSV</h3> | ||
|
||
{% if not form %} | ||
<p>{{ error_message }}</p> | ||
{% else %} | ||
|
||
<form class="add-user" method="post" enctype="multipart/form-data"> | ||
{% csrf_token %} | ||
{% include "includes/fieldset.html" %} | ||
<button type="submit" class="btn">Add organization users</button> | ||
<a href="{% url 'user_management_manage_organization_user' %}" class="btn cancel">Cancel</a> | ||
</form> | ||
|
||
<script> | ||
const checkbox = document.getElementById("id_a-indefinite_affiliation"); | ||
const datetimeField = document.getElementById("id_a-expires_at"); | ||
const datetimeFieldLabel = document.querySelector('label[for="id_a-expires_at"]'); | ||
|
||
const toggleExpirationDateField = () => { | ||
const displayStyle = checkbox.checked ? "none" : "block"; | ||
datetimeField.style.display = displayStyle; | ||
datetimeFieldLabel.style.display = displayStyle; | ||
}; | ||
|
||
document.addEventListener("DOMContentLoaded", toggleExpirationDateField); | ||
checkbox.addEventListener("change", toggleExpirationDateField); | ||
</script> | ||
|
||
{% endif %} | ||
{% endblock %} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -10,12 +10,15 @@ | |
from django.http import HttpResponse, JsonResponse | ||
from django.urls import reverse | ||
from django.core import mail | ||
from django.core.files.uploadedfile import SimpleUploadedFile | ||
from django.conf import settings | ||
from django.db import IntegrityError | ||
from django.test import override_settings | ||
from django.test.client import RequestFactory | ||
|
||
from perma.models import LinkUser, Organization, Registrar, Sponsorship, UserOrganizationAffiliation | ||
from perma.tests.utils import PermaTestCase | ||
from perma.forms import MultipleUsersFormWithOrganization | ||
|
||
|
||
class UserManagementViewsTestCase(PermaTestCase): | ||
|
@@ -641,6 +644,80 @@ def add_org_user(self): | |
organizations=self.organization | ||
).exists()) | ||
|
||
def test_add_multiple_org_users_via_csv(self): | ||
def create_csv_file(filename, content): | ||
return SimpleUploadedFile(filename, content.encode('utf-8'), content_type='text/csv') | ||
|
||
def initialize_form(csv_file, data=None): | ||
data = {'organizations': selected_organization.pk, 'indefinite_affiliation': True} | ||
return MultipleUsersFormWithOrganization(request=request, data=data, files={'csv_file': csv_file}) | ||
|
||
# --- initialize data --- | ||
csv_data = 'first_name,last_name,email\nJohn,Doe,[email protected]\nJane,Smith,[email protected]' | ||
another_csv_data = 'first_name,last_name,email\nJohn2,Doe,[email protected]\nJane2,Smith,[email protected]' | ||
invalid_csv_data = 'name\nJohn Doe' | ||
another_invalid_csv_data = 'first_name,last_name,email\nJohn,Doe,\nJane,Smith,[email protected]' | ||
|
||
valid_csv_file = create_csv_file('users.csv', csv_data) | ||
another_valid_csv_file = create_csv_file('another_valid_users.csv', another_csv_data) | ||
one_more_valid_csv_file = create_csv_file('one_more_valid_users.csv', csv_data) | ||
invalid_csv_file = create_csv_file('invalid_users.csv', invalid_csv_data) | ||
another_invalid_csv_file = create_csv_file('another_invalid_users.csv', another_invalid_csv_data) | ||
|
||
request = RequestFactory().get('/') | ||
request.user = self.registrar_user | ||
selected_organization = self.another_organization | ||
|
||
# --- test form initialization --- | ||
form = MultipleUsersFormWithOrganization(request=request) | ||
# the registrar user has 3 organizations tied to it as verified in the users.json sample data | ||
self.assertEqual(form.fields['organizations'].queryset.count(), 3) | ||
# confirm that the first item in organization selection field matches the first organization of the registrar | ||
self.assertEqual(form.fields['organizations'].queryset.first(), request.user.registrar.organizations | ||
.order_by('name').first()) | ||
|
||
# --- test csv validation --- | ||
# valid csv | ||
form1 = initialize_form(valid_csv_file) | ||
self.assertTrue(form1.is_valid()) | ||
|
||
# invalid csv - missing headers | ||
form2 = initialize_form(invalid_csv_file) | ||
self.assertFalse(form2.is_valid()) | ||
self.assertTrue("CSV file must contain a header row with first_name, last_name and email columns." | ||
in form2.errors['csv_file']) | ||
|
||
# invalid csv - missing email field | ||
form3 = initialize_form(another_invalid_csv_file) | ||
self.assertFalse(form3.is_valid()) | ||
self.assertTrue("Each row in the CSV file must contain email." | ||
in form3.errors['csv_file']) | ||
|
||
# --- test user creation --- | ||
self.assertTrue(form1.is_valid()) | ||
form1.save(commit=True) | ||
created_user_ids = [user.id for user in form1.created_users.values()] | ||
self.assertEqual(len(created_user_ids), 2) | ||
self.assertEqual(UserOrganizationAffiliation.objects.filter(user_id__in=created_user_ids).count(), 2) | ||
|
||
# --- test user update --- | ||
existing_user = LinkUser.objects.create(email="[email protected]", first_name="John2", last_name="Doe") | ||
form4 = initialize_form(another_valid_csv_file) | ||
self.assertTrue(form4.is_valid()) | ||
form4.save(commit=True) | ||
self.assertEqual(len(form4.updated_users), 1) | ||
self.assertTrue(existing_user in form4.updated_users.values()) | ||
self.assertEqual(len(form4.created_users), 1) | ||
self.assertEqual(next(iter(form4.updated_users)), "[email protected]") | ||
|
||
# --- test validation errors --- | ||
LinkUser.objects.filter(raw_email="[email protected]").update(is_staff=True) | ||
form5 = initialize_form(one_more_valid_csv_file) | ||
self.assertTrue(form5.is_valid()) | ||
form5.save(commit=True) | ||
self.assertEqual(len(form5.ineligible_users), 1) | ||
self.assertEqual("[email protected]", next(iter(form5.ineligible_users))) | ||
|
||
def test_admin_user_can_add_new_user_to_org(self): | ||
self.log_in_user(self.admin_user) | ||
self.add_org_user() | ||
|
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 noticed something fun. If you want, you actually don't need the
seen
set or therow_count
integer!if email in self.user_data
is the same asif email in seen
, androw_count
is the same aslen(self.user_data)
🙂 But if you prefer the aesthetics ofseen
androw_count
, no objections here!