intermittent commit (squash me later)

This commit is contained in:
Julian Lobbes 2022-11-29 11:18:43 +01:00
parent 413bc29ec4
commit 632c03a2b9
5 changed files with 376 additions and 190 deletions

View File

@ -1,143 +1,187 @@
class AbstractUserEntry {
constructor(username, tableRow) {
this.username = username;
this.tableRow = tableRow;
this.membershipToggleButton = tableRow.querySelector(".toggleMembershipButton");
this.membershipToggleButton.addEventListener("click", this.onButtonPress.bind(this));
};
};
$(function() {
$("table").tablesorter({
theme: 'bootstrap',
headerTemplate: '{content} {icon}',
cssIcon: 'bi-arrow-down-up',
cssIconNone: '',
cssIconAsc: 'bi-arrow-up',
cssIconDesc: 'bi-arrow-down',
});
});
function removeUserFromFormField(username) {
let formField = document.getElementById("updated_members");
let oldMembersList = JSON.parse(formField.value);
let newMembersList = Array();
for (let member of oldMembersList) {
if (member != username) {
newMembersList.push(member);
class UserEntry {
constructor(username, isMember, rowElement) {
this.username = username;
this.isMember = isMember;
this.rowElement = rowElement;
this.buttonElement = $(rowElement).find(".toggleMembershipButton");
if (isMember) {
$(this.buttonElement).click(() => this.onClickLeave());
} else {
$(this.buttonElement).click(() => this.onClickJoin());
}
}
formField.value = JSON.stringify(newMembersList);
adjustLastMemberButtonState();
};
function addUserToFormField(username) {
let formField = document.getElementById("updated_members");
let oldMembersList = JSON.parse(formField.value);
oldMembersList.push(username);
formField.value = JSON.stringify(oldMembersList);
adjustLastMemberButtonState();
};
class MemberEntry extends AbstractUserEntry {
onButtonPress() {
this.tableRow.remove();
createRemovedMemberRow(this.username);
removeUserFromFormField(this.username);
};
};
class NonMemberEntry extends AbstractUserEntry {
onButtonPress() {
this.tableRow.remove();
createAddedMemberRow(this.username);
addUserToFormField(this.username);
};
};
function createRemovedMemberRow(username) {
let newTableRow = nonMembersTable.querySelector("tbody").insertRow(0);
newTableRow.className = "userEntry text-center bg-danger bg-gradient";
newTableRow.id = username;
let newTableHeader = document.createElement("th");
newTableHeader.scope = "row";
let newImage = document.createElement("img");
newImage.src = `/static/images/users/${username}/thumbnail.jpg`;
newImage.alt = `Profile picture for user ${username}.`;
newImage.className = "img-fluid rounded";
newImage.style = "max-width: 50px";
newTableHeader.appendChild(newImage);
newTableRow.appendChild(newTableHeader);
let newTableDataUsername = document.createElement("td");
let newUsernameAnchor = document.createElement("a");
newUsernameAnchor.href = `/users/view/${username}`;
newUsernameAnchor.textContent = username;
newTableDataUsername.appendChild(newUsernameAnchor);
newTableRow.appendChild(newTableDataUsername);
let newTableDataButton = document.createElement("td");
let newTableButton = document.createElement("button");
newTableButton.type = "button";
newTableButton.className = "toggleMembershipButton inProgress btn btn-outline-light";
newTableButton.disabled = true;
newTableButton.textContent = "Will be removed";
newTableDataButton.appendChild(newTableButton);
newTableRow.appendChild(newTableDataButton);
};
function createAddedMemberRow(username) {
let newTableRow = membersTable.querySelector("tbody").insertRow(0);
newTableRow.className = "userEntry text-center bg-success bg-gradient";
newTableRow.id = username;
let newTableHeader = document.createElement("th");
newTableHeader.scope = "row";
let newImage = document.createElement("img");
newImage.src = `/static/images/users/${username}/thumbnail.jpg`;
newImage.alt = `Profile picture for user ${username}.`;
newImage.className = "img-fluid rounded";
newImage.style = "max-width: 50px";
newTableHeader.appendChild(newImage);
newTableRow.appendChild(newTableHeader);
let newTableDataUsername = document.createElement("td");
let newUsernameAnchor = document.createElement("a");
newUsernameAnchor.href = `/users/view/${username}`;
newUsernameAnchor.textContent = username;
newTableDataUsername.appendChild(newUsernameAnchor);
newTableRow.appendChild(newTableDataUsername);
let newTableDataButton = document.createElement("td");
let newTableButton = document.createElement("button");
newTableButton.type = "button";
newTableButton.className = "toggleMembershipButton inProgress btn btn-outline-light";
newTableButton.disabled = true;
newTableButton.textContent = "Will be added";
newTableDataButton.appendChild(newTableButton);
newTableRow.appendChild(newTableDataButton);
};
/**
* If there is only one member in the group member table, disables that member's
* remove button.
* If there is more than one member, activates the removal button.
*/
function adjustLastMemberButtonState() {
memberRows = membersTable.querySelectorAll(".userEntry");
if (memberRows.length == 1) {
memberRows[0].querySelector(".toggleMembershipButton").disabled = true;
} else {
for (let button of membersTable.querySelectorAll(".toggleMembershipButton")) {
if (!button.className.includes("inProgress")) {
button.disabled = false;
onClickLeave() {
// Deactivate the last remaining member's togglebutton before leaving
if ($(membersTable).find(".userEntry").length < 2) {
for (entry of $(membersTable).find(".userEntry")) {
if ($(entry).id != this.username) {
$(entry).find(".toggleMembershipButton")[0].prop("disabled", true);
}
}
}
this.setButtonAppearanceInProgress();
$.ajax({
context: {
"userEntry": this
},
url: `/api/group/${groupname}`,
type: "GET",
dataType: "json",
}).done(function(groupJson) {
$.ajax({
context: {
"userEntry": this.userEntry,
},
url: `/api/group/${groupname}`,
type: "PUT",
dataType: "json",
data: JSON.stringify(groupJson),
contentType: "application/json",
})
.done(function(groupJson) {
this.userEntry.isMember = false;
$(this.userEntry.buttonElement).off("click");
$(this.userEntry.buttonElement).click(() => this.userEntry.onClickJoin());
$(this.userEntry.rowElement).prependTo($("#tableNonMembers").find("tbody"));
this.userEntry.setButtonAppearanceJoinGroup();
})
.fail(function(xhr, status, errorThrown) {
console.log(`Error: ${errorThrown}`);
console.log(`Status: ${status}`);
console.dir(xhr);
alert("Sorry, there was a problem sending information to the server.");
this.userEntry.setButtonAppearanceLeaveGroup();
});
}).fail(function(xhr, status, errorThrown) {
console.log(`Error: ${errorThrown}`);
console.log(`Status: ${status}`);
alert("Sorry, there was a problem retrieving information from the server.");
this.userEntry.setButtonAppearanceLeaveGroup();
});
}
onClickJoin() {
this.setButtonAppearanceInProgress();
$.ajax({
context: {
"userEntry": this
},
url: `/api/group/${groupname}`,
type: "GET",
dataType: "json",
}).done(function(groupJson) {
// Add current user to the members array
groupJson.group.members.push(this.userEntry.username);
$.ajax({
context: {
"userEntry": this.userEntry,
},
url: `/api/group/${groupname}`,
type: "PUT",
dataType: "json",
data: JSON.stringify(groupJson),
contentType: "application/json",
})
.done(function(groupJson) {
this.userEntry.isMember = true;
$(this.userEntry.buttonElement).off("click");
$(this.userEntry.buttonElement).click(() => this.userEntry.onClickLeave());
$(this.userEntry.rowElement).prependTo($("#tableMembers").find("tbody"));
this.userEntry.setButtonAppearanceLeaveGroup();
})
.fail(function(xhr, status, errorThrown) {
console.log(`Error: ${errorThrown}`);
console.log(`Status: ${status}`);
console.dir(xhr);
alert("Sorry, there was a problem sending information to the server.");
this.userEntry.setButtonAppearanceJoinGroup();
});
}).fail(function(xhr, status, errorThrown) {
console.log(`Error: ${errorThrown}`);
console.log(`Status: ${status}`);
alert("Sorry, there was a problem retrieving information from the server.");
this.userEntry.setButtonAppearanceJoinGroup();
});
}
setButtonAppearanceInProgress() {
this.buttonElement.removeClass("btn-danger btn-success btn-secondary");
this.buttonElement.addClass("btn-secondary");
this.buttonElement.empty();
this.buttonElement.html(
'<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>' +
'<span> Loading...</span>'
);
}
setButtonAppearanceLeaveGroup() {
this.buttonElement.removeClass("btn-danger btn-success btn-secondary");
this.buttonElement.addClass("btn-danger");
this.buttonElement.empty();
this.buttonElement.html(
'<i class="bi-box-arrow-right"></i> Remove from group'
);
}
setButtonAppearanceJoinGroup() {
this.buttonElement.removeClass("btn-danger btn-success btn-secondary");
this.buttonElement.addClass("btn-success");
this.buttonElement.empty();
this.buttonElement.html(
'<i class="bi-box-arrow-right"></i> Add to group'
);
}
}
const membersTable = document.getElementById("groupMembers");
const nonMembersTable = document.getElementById("groupNonMembers");
let memberEntries = new Set();
let nonMemberEntries = new Set();
function getUserEntries() {
let userEntries = [];
for (let userEntry of document.body.querySelectorAll(".userEntry")) {
if (userEntry.parentElement.parentElement.id === "groupMembers") {
memberEntries.add(new MemberEntry(userEntry.id, userEntry));
} else if (userEntry.parentElement.parentElement.id === "groupNonMembers") {
nonMemberEntries.add(new NonMemberEntry(userEntry.id, userEntry));
// Construct member entries
for (let entry of membersTable.find("tbody").find(".userEntry")) {
userEntries.push(new UserEntry(
entry.id,
true,
entry
));
}
// Construct nonmember entries
for (let entry of nonMembersTable.find("tbody").find(".userEntry")) {
userEntries.push(new UserEntry(
entry.id,
false,
entry
));
}
return userEntries;
}
adjustLastMemberButtonState();
let nonMembersTable = undefined;
let membersTable = undefined;
let entries = undefined;
const groupname = window.location.pathname.split("/").pop();
$(document).ready(function() {
nonMembersTable = $("#tableNonMembers");
membersTable = $("#tableMembers");
entries = getUserEntries();
});

View File

@ -1,10 +0,0 @@
$(function() {
$("table").tablesorter({
theme: 'bootstrap',
headerTemplate: '{content} {icon}',
cssIcon: 'bi-arrow-down-up',
cssIconNone: '',
cssIconAsc: 'bi-arrow-up',
cssIconDesc: 'bi-arrow-down',
});
});

View File

@ -4,13 +4,11 @@
<div class="row">
<div class="col">
<h1>Editing group: {{ groupname }}</h1>
<p class="text-muted">Add or remove members from <i>{{ groupname }}</i> here. Hit the <kbd>Apply</kbd> button to save your changes.</p>
<p class="text-muted">Note that Groups must always have at least one member.</p>
</div>
</div>
<div class="row">
<div class="col-sm border rounded m-2">
<table class="table table-hover align-middle" id="groupNonMembers">
<table class="table table-hover align-middle" id="tableNonMembers">
<thead>
<tr>
<th scope="col" colspan="3" class="text-center text-opacity-75 fs-4">Other users</th>
@ -31,7 +29,7 @@
</td>
<td>
<button type="button" class="toggleMembershipButton btn btn-success">
Add
<i class="bi-box-arrow-in-right"></i> Add to group
</button>
</td>
</tr>
@ -40,7 +38,7 @@
</table>
</div>
<div class="col-sm border border-primary rounded m-2">
<table class="table table-hover align-middle" id="groupMembers">
<table class="table table-hover align-middle" id="tableMembers">
<thead>
<tr>
<th scope="col" colspan="3" class="text-center text-primary text-opacity-75 fs-4">{{ groupname }}</th>
@ -61,7 +59,7 @@
</td>
<td>
<button type="button" class="toggleMembershipButton btn btn-danger">
Remove
<i class="bi-box-arrow-right"></i> Remove from group
</button>
</td>
</tr>
@ -70,17 +68,5 @@
</table>
</div>
</div>
<div class="row mb-2">
<div class="d-grid gap-2 col-2 mx-auto">
<form method="post" enctype="application/json">
{{ form.csrf_token }}
<a class="btn btn-secondary"
href="{{ url_for('usermanager.group_update', groupname=groupname) }}"
role="button">Reset</a>
{{ form.updated_members }}
{{ form.submit(class="btn btn-primary") }}
</form>
</div>
</div>
<script src="{{ url_for('static', filename='js/group_edit.js') }}"></script>
{% endblock content %}

View File

@ -367,11 +367,11 @@ class GroupUpdateForm(FlaskForm):
)
@bp.route("/groups/update/<string:groupname>", methods=("GET", "POST"))
@bp.route("/groups/update/<string:groupname>")
def group_update(groupname: str):
"""Detail and Update view for a group.
Shows a form allowing the modification of user memberships for this group.
Shows a table allowing the modification of user memberships for this group.
"""
try:
@ -387,36 +387,10 @@ def group_update(groupname: str):
members = {user for user in group.members}
non_members = {user for user in ldap.get_users(conn)} - members
form = GroupUpdateForm()
if request.method == 'GET':
form.updated_members.data = dumps([user.username for user in members])
else:
try:
updated_usernames_list = loads(form.updated_members.data)
except JSONDecodeError:
abort(400)
if not isinstance(updated_usernames_list, list):
abort(400)
updated_members = set()
for username in updated_usernames_list:
if not isinstance(username, str):
abort(400)
try:
updated_members.add(ldap.get_user(conn, username))
except ldap.EntryNotFoundException:
abort(400)
if not len(updated_members):
abort(400)
ldap.update_group(conn, Group(group.groupname, updated_members))
flash(f"The Group '{group.groupname}' was updated.")
# TODO redirect to group list view
return redirect(url_for('usermanager.group_update', groupname=group.groupname))
conn.unbind()
return render_template(
'usermanager/group_edit.html',
form=form,
groupname=group.groupname,
members=members,
non_members=non_members,

View File

@ -1,10 +1,11 @@
from json import JSONEncoder, JSONDecoder, loads, dumps
from json import JSONEncoder, JSONDecoder, loads, dumps, JSONDecodeError
from flask import Blueprint
from flask import Blueprint, request
from flask_restful import Resource
import lumi2.ldap as ldap
from lumi2.usermodel import User, Group
from lumi2.exceptions import InvalidStringFormatException
class UserEncoder(JSONEncoder):
@ -57,8 +58,106 @@ class UserResource(Resource):
}
def get_group_from_json(input_dict: dict) -> Group:
"""Creates a Group object using the input dictionary.
The input must be a dictionary of the following format:
{
"group": {
"groupname": "mygroup",
"members": [
"alice",
"bob",
"carlie"
]
}
}
Returns
-------
Group
The Group object equivalent to the input dict.
Raises
------
TypeError
If the input is not of type dict.
json.JSONDecodeError
If the input string is not valid JSON.
lumi2.ldap.EntryNotFoundException
If any of the users listed as members do not exist.
ValueError
If the input string's format is incorrect, or if the list of Group
members is empty.
"""
if not isinstance(input_dict, dict):
raise ValueError(f"Expected a dictionary but got: '{type(input_dict)}'.")
if len(input_dict) != 1 or 'group' not in input_dict.keys():
raise ValueError(f"Expected exactly one entry called 'group'.")
group_dict = input_dict['group']
if not isinstance(group_dict, dict):
raise ValueError(f"Expected a dictionary but got: '{type(group_dict)}'.")
if len(group_dict.keys()) != 2:
raise ValueError("Invalid number of keys in Group entry.")
for required_key in ['groupname', 'members']:
if required_key not in group_dict.keys():
raise ValueError(f"Expected a key called '{required_key}' in Group entry.")
groupname = group_dict['groupname']
if not isinstance(groupname, str):
raise ValueError("Expected the value for 'groupname' to be a string.")
try:
Group.assert_is_valid_groupname(groupname)
except InvalidStringFormatException as e:
raise ValueError(f"Invalid group name: {e}")
member_usernames = group_dict['members']
if not isinstance(member_usernames, list):
raise ValueError("Expected the value for 'members' to be a list.")
if not len(member_usernames):
raise ValueError("Group must have at least one member.")
members = set()
conn = ldap.get_connection()
for username in member_usernames:
if not isinstance(username, str):
raise ValueError("Member list may contain only strings.")
members.add(ldap.get_user(conn, username))
conn.unbind()
return Group(groupname, members)
class GroupResource(Resource):
def get(self, groupname):
"""The GroupResource represents a Group object in the REST API.
In JSON, a Group is represented as follows:
{
"groupname": "mygroup",
"members": [
"alice",
"bob",
"charlie"
]
}
"""
def get(self, groupname: str):
"""Retrieves the group specified by the groupname as a JSON object.
Attributes
----------
groupname : str
The name of the group to be retrieved.
Returns
-------
json : str , status : int
A JSON string and HTTP status code.
If the request was handled successfully,
"""
try:
conn = ldap.get_connection()
except:
@ -74,3 +173,96 @@ class GroupResource(Resource):
"members": [user.username for user in group.members],
}
}
def put(self, groupname):
try:
conn = ldap.get_connection()
except:
return 500
try:
# Make sure the requested group exists
group = ldap.get_group(conn, groupname)
except ldap.EntryNotFoundException:
conn.unbind()
return {"message": f"Group '{groupname}' does not exist."}, 400
try:
# Parse the JSON-submitted group
group = get_group_from_json(request.get_json())
except JSONDecodeError as e:
conn.unbind()
return {"message": f"Invalid JSON format: {e}"}, 400
except ValueError as e:
conn.unbind()
return {"message": f"Invalid string format: {e}"}, 400
except ldap.EntryNotFoundException as e:
conn.unbind()
return {"message": f"Entry not found: {e}"}
if not group.groupname == groupname:
conn.unbind()
return {"message": "Groupname mismatch between endpoint and submitted data."}
ldap.update_group(conn, group)
conn.unbind()
return {
"group": {
"groupname": group.groupname,
"members": [user.username for user in group.members],
}
}
def add_user_to_group(self, groupname):
"""Accepts a PUT request to add a user to the specified Group.
The request data must be a JSON string of the following format:
{
"username": "my-user"
}
The user must exist on the server.
If the user is already a group member, no action is taken.
"""
try:
conn = ldap.get_connection()
except:
return 500
try:
group = ldap.get_group(conn, groupname)
except ldap.EntryNotFoundException:
conn.unbind()
return {"message": f"Group '{groupname}' does not exist."}, 404
container_dict = request.get_json()
if not isinstance(container_dict, dict):
conn.unbind()
return {"message": "Invalid data: expected a JSON object."}, 400
if len(container_dict.keys()) != 1:
conn.unbind()
return {"message": "Invalid data: too many keys (expected 1)."}, 400
if not "username" in container_dict.keys():
conn.unbind()
return {"message": "Invalid data: no 'username' key found."}, 400
username = container_dict['username']
if not isinstance(username, str):
conn.unbind()
return {"message": "Invalid data: 'username' must be a string."}, 400
try:
User.assert_is_valid_username(username)
except InvalidStringFormatException as e:
conn.unbind()
return {"message": f"Invalid username: {e}"}, 400
try:
user = ldap.get_user(conn, username)
except ldap.EntryNotFoundException as e:
conn.unbind()
return {"message": f"User '{username}' does not exist."}, 400
if username in group.members:
conn.unbind()
return {"username": username}, 200
group.members.add(user)
ldap.update_group(conn, group)
conn.unbind()
return {"username": username}, 200