Skip to content

Commit

Permalink
Add bulk user create page to control panel
Browse files Browse the repository at this point in the history
  • Loading branch information
stijn-uva committed Oct 5, 2023
1 parent d34cc0e commit 9894ed6
Show file tree
Hide file tree
Showing 4 changed files with 176 additions and 2 deletions.
1 change: 1 addition & 0 deletions webtool/static/css/control-panel.css
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ table td[colspan] {

.user-controls > div {
text-align: center;
line-height: 3em;
}

.tab-container > div {
Expand Down
57 changes: 57 additions & 0 deletions webtool/templates/controlpanel/user-bulk.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{% extends "controlpanel/layout.html" %}

{% block title %}Bulk user creation{% endblock %}
{% block body_class %}plain-page frontpage admin {{ body_class }}{% endblock %}
{% block subbreadcrumbs %}{% set navigation.sub = "user-bulk" %}{% endblock %}

{% block body %}
<article class="small">
<section>
<h2><span>Bulk user creation</span></h2>

<p class="intro">You can create multiple users at the same time by preparing a CSV file with their data and
uploading it below. The spreadsheet <em>must</em> have a <code>name</code> column that contains the
username of the user to be created. If no other data is provided, the user is created with no
password.</p>
<p>{% if __user_config("mail.server") %}
Optionally, if the username is an e-mail address, an e-mail will be sent to them with a password reset
link. Note that e-mail providers may frown upon the mass of e-mails sent at once when creating multiple
users at the same time.
{% else %}
No mail server is currently configured, but if you configure one you will also have the option of
sending the users a link to set their password through.
{% endif %}
You can always find the 'reset password' link for a user via the user management page in the control
panel.</p>
<p>You can additionally provide the columns <code>password</code>, <code>expires</code> (a timestamp
indicating when the user will be automatically deleted; YYYY-MM-DD
HH:MM:SS is preferred), <code>tags</code> (a comma-separated list of user tags), and <code>notes</code>
(put whatever you want in here). Other columns will be ignored. Rows with invalid data (existing
usernames, bad expiration dates) will be skipped.</p>

<form action="{{ url_for("user_bulk") }}" method="POST" class="wide user-bulk" enctype="multipart/form-data">
{% for notice in flashes %}
<p class="form-notice">{{ notice|safe }}</p>
{% endfor %}

<div class="form-element{% if "datafile" in incomplete %} missing{% endif %}">
<label for="forminput-datafile">CSV file</label>
<div>
<input name="datafile" id="forminput-datafile" type="file">
</div>
</div>

{% if __user_config("mail.server") %}
<div class="form-element">
<label for="send-email">Send e-mail?</label>
<input type="checkbox" id="send-email" name="send-email">
</div>
{% endif %}

<div class="submit-container">
<button name="action-private"><i class="fa f-fw fa-user-plus" aria-hidden="true"></i> Upload and create users</button>
</div>
</form>
</section>
</article>
{% endblock %}
4 changes: 3 additions & 1 deletion webtool/templates/controlpanel/users.html
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,16 @@ <h2><span>Manage</span></h2>
<div>
<a href="{{ url_for("manipulate_user", mode="create") }}" class="button-like"><i
class="fa fa-user-plus" aria-hidden="true"></i> Create user</a>
<a href="{{ url_for("user_bulk") }}" class="button-like"><i
class="fa fa-user-plus" aria-hidden="true"></i> Create users (bulk)</a>
{% if __user_config("privileges.admin.can_manage_tags") %}
<a href="{{ url_for("manipulate_tags") }}" class="button-like"><i
class="fa fa-arrow-down-1-9" aria-hidden="true"></i> Manage tags</a>
{% endif %}
</div>
<hr>
<form action="{{ url_for("list_users") }}" method="GET">
<input aria-label="User filter" placeholder="Search by username" name="name"
<input aria-label="User filter" placeholder="Search by username/notes" name="name"
value="{{ filter.name }}" list="all-users">

<input aria-label="User filter" placeholder="Search by tag" name="tag"
Expand Down
116 changes: 115 additions & 1 deletion webtool/views/views_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import re

from pathlib import Path
from dateutil.parser import parse as parse_datetime, ParserError

import backend
from email.mime.multipart import MIMEMultipart
Expand Down Expand Up @@ -96,7 +97,8 @@ def list_users(page):
filter_bits = []
replacements = []
if filter_name:
filter_bits.append("name LIKE %s")
filter_bits.append("(name LIKE %s OR userdata::json->>'notes' LIKE %s)")
replacements.append("%" + filter_name + "%")
replacements.append("%" + filter_name + "%")

if tag:
Expand Down Expand Up @@ -704,6 +706,118 @@ def get_log(logfile):
return ""


@app.route("/user-bulk", methods=["GET", "POST"])
@login_required
@setting_required("privileges.admin.can_manage_users")
def user_bulk():
"""
Create many users at once
Useful if one wants to e.g. import users from elsewhere
"""
incomplete = []

if request.method == "POST" and request.files:
# handle the CSV file
# sniff the dialect, because it's CSV, so who knows what format it's in
file = io.TextIOWrapper(request.files["datafile"])
sample = file.read(3 * 1024) # 3kB should be enough
dialect = csv.Sniffer().sniff(sample, delimiters=(",", ";", "\t"))
file.seek(0)
reader = csv.DictReader(file, dialect=dialect)

# keep track of what we read from the file
prospective_users = []
dupes = []
failed_rows = []
mail_fail = False
row_index = 1

# use while True instead of looping through the reader directly,
# because that way we can catch read errors for individual lines
while True:
try:
row = next(reader)
if "name" not in row:
# the one required column
raise ValueError()
else:
prospective_users.append(row)

except ValueError:
failed_rows.append(row_index)
continue

except StopIteration:
break

# OK, we have the users with enough data, now add them one by one
success = 0
if prospective_users:
for user in prospective_users:
# prevent duplicate users
exists = db.fetchone("SELECT name FROM users WHERE name = %s", (user["name"],))
if exists:
dupes.append(user["name"])
continue

# only insert with username - other properties are set through
# the object
db.insert("users", {"name": user["name"], "timestamp_created": int(time.time())})
user_obj = User.get_by_name(db, user["name"])

if user.get("expires"):
try:
# expiration date needs to be a parseable timestamp
# note that we do not check if it is in the future!
expires_after = parse_datetime(user["expires"])
user_obj.set_value("delete-after", int(expires_after.timestamp()))
except (OverflowError, ParserError):
# delete the already created user because we have bad
# data, and continue with the next one
failed_rows.append(user.get("name"))
user_obj.delete()
continue

if user.get("password"):
user_obj.set_password(user["password"])

elif config.get("mail.server") and not mail_fail and "@" in user.get("name"):
# can send a registration e-mail, but only if the name is
# an email address and we have a mail server
try:
user_obj.email_token(new=True)
except RuntimeError as e:
mail_fail = str(e)

if user.get("tags"):
for tag in user["tags"].split(","):
user_obj.add_tag(tag.strip())

if user.get("notes"):
user_obj.set_value("notes", user.get("notes"))

success += 1

flash(f"{success} user(s) were created.")

# and now we have some specific errors to output if anything went wrong
else:
flash("No valid rows in user file, no users added.")

if dupes:
flash(f"The following users were skipped because the username already exists: {', '.join(dupes)}.")

if mail_fail:
flash(f"E-mails were not sent ({mail_fail}).")

if failed_rows and prospective_users:
flash(f"The following rows were skipped because the data in them was invalid: {', '.join([str(r) for r in failed_rows])}.")

return render_template("controlpanel/user-bulk.html", flashes=get_flashed_messages(),
incomplete=incomplete)


@app.route("/dataset-bulk/", methods=["GET", "POST"])
@login_required
@setting_required("privileges.admin.can_manipulate_all_datasets")
Expand Down

0 comments on commit 9894ed6

Please sign in to comment.