diff --git a/lumi2/static/js/group_edit.js b/lumi2/static/js/group_edit.js index 0ee4afa..1219069 100644 --- a/lumi2/static/js/group_edit.js +++ b/lumi2/static/js/group_edit.js @@ -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( + '' + + ' Loading...' + ); + } + + setButtonAppearanceLeaveGroup() { + this.buttonElement.removeClass("btn-danger btn-success btn-secondary"); + this.buttonElement.addClass("btn-danger"); + this.buttonElement.empty(); + this.buttonElement.html( + ' Remove from group' + ); + } + + setButtonAppearanceJoinGroup() { + this.buttonElement.removeClass("btn-danger btn-success btn-secondary"); + this.buttonElement.addClass("btn-success"); + this.buttonElement.empty(); + this.buttonElement.html( + ' 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(); +}); diff --git a/lumi2/static/js/tables.js b/lumi2/static/js/tables.js deleted file mode 100644 index 909aa32..0000000 --- a/lumi2/static/js/tables.js +++ /dev/null @@ -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', - }); -}); diff --git a/lumi2/templates/usermanager/group_edit.html b/lumi2/templates/usermanager/group_edit.html index 9faa074..d89d73b 100644 --- a/lumi2/templates/usermanager/group_edit.html +++ b/lumi2/templates/usermanager/group_edit.html @@ -4,13 +4,11 @@

Editing group: {{ groupname }}

-

Add or remove members from {{ groupname }} here. Hit the Apply button to save your changes.

-

Note that Groups must always have at least one member.

- +
@@ -31,7 +29,7 @@ @@ -40,7 +38,7 @@
Other users
- +
@@ -61,7 +59,7 @@ @@ -70,17 +68,5 @@
{{ groupname }}
-
-
-
- {{ form.csrf_token }} - Reset - {{ form.updated_members }} - {{ form.submit(class="btn btn-primary") }} -
-
-
{% endblock content %} diff --git a/lumi2/usermanager.py b/lumi2/usermanager.py index a8b1e10..7e22538 100644 --- a/lumi2/usermanager.py +++ b/lumi2/usermanager.py @@ -367,11 +367,11 @@ class GroupUpdateForm(FlaskForm): ) -@bp.route("/groups/update/", methods=("GET", "POST")) +@bp.route("/groups/update/") 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, diff --git a/lumi2/webapi.py b/lumi2/webapi.py index d54a029..090351e 100644 --- a/lumi2/webapi.py +++ b/lumi2/webapi.py @@ -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