diff --git a/app/db/003/README b/app/db/003/README new file mode 100644 index 0000000..d8fb265 --- /dev/null +++ b/app/db/003/README @@ -0,0 +1,7 @@ +These database changes are from the editUser feature branch + +This schema change adds a unique constraint on the UserRole table to +prevent duplicate roles for individual users + +It also adds a new log type to account for the edit_user api call + diff --git a/app/db/003/downgrade.sql b/app/db/003/downgrade.sql new file mode 100644 index 0000000..d78ec38 --- /dev/null +++ b/app/db/003/downgrade.sql @@ -0,0 +1,14 @@ +-- temporarily remove foreign keys to remove unique index +ALTER TABLE UserRole DROP FOREIGN KEY fk_User_usrID; +ALTER TABLE UserRole DROP FOREIGN KEY fk_UserRole_rolID; + +-- remove unique index from UserRole +ALTER TABLE UserRole DROP INDEX uc_UserIDRoleID; + +-- reapply the foreign key constraints +ALTER TABLE UserRole ADD CONSTRAINT fk_User_usrID FOREIGN KEY (usrID) REFERENCES User (usrID) ON DELETE CASCADE; +ALTER TABLE UserRole ADD CONSTRAINT fk_UserRole_rolID FOREIGN KEY (rolID) REFERENCES Role (rolID) ON DELETE CASCADE; + +-- remove the log type for the edit user api call +DELETE FROM LogType +WHERE logtType='account_updated'; diff --git a/app/db/003/upgrade.sql b/app/db/003/upgrade.sql new file mode 100644 index 0000000..b6be7f1 --- /dev/null +++ b/app/db/003/upgrade.sql @@ -0,0 +1,9 @@ +-- ensure there will be no duplicates user / user role pairs +ALTER TABLE UserRole +ADD CONSTRAINT uc_UserIDRoleID UNIQUE (usrID, rolID); + +-- add the logtype for the edit_user api call +INSERT INTO LogType +(logtType, logtDescription) +VALUES +('account_updated',''); diff --git a/app/deploy/fabfile.py b/app/deploy/fabfile.py index 9c5794d..700a66e 100644 --- a/app/deploy/fabfile.py +++ b/app/deploy/fabfile.py @@ -274,6 +274,57 @@ def mysql_list_tables(): result = local(cmd, capture=True) print(result) +def _is_valid_version(version_number): + """Checks if the version has the right number of digits""" + version_string = str(version_number) + return len(version_string) <= 3 + +def _get_version_string(version_number): + """Produces a three char string that corresponds to the desired db folder""" + version_string = str(version_number) + while len(version_string) < 3: + version_string = '0' + version_string + return version_string + +def _step_to_version(version_number, is_upgrade): + """Step once to the passed version. Must be on adjacent version + Version numbers are given by the number for the folder + in app/db/ + """ + require('environment', provided_by=[production, staging]) + + exists = mysql_check_db_exists() + if not exists: + abort(colors.red("Unable to create tables in database '%(db_name)s'." + "The database does not exist" % env)) + if not _is_valid_version(version_number) is True: + abort(colors.red("Unable to upgrade to version {} '%(db_name)s'." + "Please look in app/db for valid versions".format(version_number) + % env)) + + login_path = _mysql_login_path() + sql_file = 'upgrade.sql' if is_upgrade else 'downgrade.sql' + sql = _get_version_string(version_number) + '/' + sql_file + + with lcd('../db/'): + cmd = ("mysql --login-path={} %(db_name)s < {}" + .format(login_path, sql) + % env) + local(cmd) + +def mysql_version_upgrade(version_number): + """Upgrade to the passed version. Must be on adjacent version + Version numbers are given by the number for the folder + in app/db/ + """ + _step_to_version(version_number, True) + +def mysql_version_downgrade(version_number): + """Downgrade to the passed version. Must be on adjacent version + Version numbers are given by the number for the folder + in app/db/ + """ + _step_to_version(version_number, False) def mysql_create_tables(): """ Create the application tables. @@ -297,7 +348,7 @@ def mysql_create_tables(): .format(env.environment)) login_path = _mysql_login_path() - files = ['001/upgrade.sql', '002/upgrade.sql', '002/data.sql'] + files = ['001/upgrade.sql', '002/upgrade.sql', '002/data.sql', '003/upgrade.sql'] with lcd('../db/'): for sql in files: @@ -322,7 +373,7 @@ def mysql_drop_tables(): abort(colors.red("Unable to drop tables in database '%(db_name)s'." "The database does not exist" % env)) - files = ['002/downgrade.sql', '001/downgrade.sql'] + files = ['003/downgrade.sql', '002/downgrade.sql', '001/downgrade.sql'] with lcd('../db/'): for sql in files: diff --git a/app/fabfile.py b/app/fabfile.py index f79a27a..a2fb804 100644 --- a/app/fabfile.py +++ b/app/fabfile.py @@ -62,6 +62,7 @@ def init_db(): local('sudo mysql ctsi_dropper_s < db/001/upgrade.sql') local('sudo mysql ctsi_dropper_s < db/002/upgrade.sql') local('sudo mysql ctsi_dropper_s < db/002/data.sql') + local('sudo mysql ctsi_dropper_s < db/003/upgrade.sql') @task @@ -78,6 +79,7 @@ def reset_db(): local('sudo mysql ctsi_dropper_s < db/001/upgrade.sql') local('sudo mysql ctsi_dropper_s < db/002/upgrade.sql') local('sudo mysql ctsi_dropper_s < db/002/data.sql') + local('sudo mysql ctsi_dropper_s < db/003/upgrade.sql') @task diff --git a/app/redidropper/database/crud_mixin.py b/app/redidropper/database/crud_mixin.py index 90b0232..c7659fa 100644 --- a/app/redidropper/database/crud_mixin.py +++ b/app/redidropper/database/crud_mixin.py @@ -33,19 +33,16 @@ def get_by_id(cls, id): return cls.query.get(int(id)) return None - @classmethod def create(cls, **kwargs): """ Helper for session.add() + session.commit() """ instance = cls(**kwargs) return instance.save() - def update(self, commit=True, **kwargs): for attr, value in kwargs.iteritems(): setattr(self, attr, value) - return commit and self.save() or self - + return self.save() if commit else self def save(self, commit=True): db.session.add(self) @@ -53,7 +50,6 @@ def save(self, commit=True): db.session.commit() return self - def delete(self, commit=True): db.session.delete(self) return commit and db.session.commit() diff --git a/app/redidropper/models/log_entity.py b/app/redidropper/models/log_entity.py index a39b7b5..a6ebeec 100644 --- a/app/redidropper/models/log_entity.py +++ b/app/redidropper/models/log_entity.py @@ -23,7 +23,8 @@ LOG_TYPE_FILE_DOWNLOADED, \ LOG_TYPE_ACCOUNT_MODIFIED, \ LOG_TYPE_REDCAP_SUBJECTS_IMPORTED, \ - LOG_TYPE_REDCAP_EVENTS_IMPORTED + LOG_TYPE_REDCAP_EVENTS_IMPORTED, \ + LOG_TYPE_ACCOUNT_UPDATED class LogEntity(db.Model, CRUDMixin): @@ -92,6 +93,11 @@ def account_created(session_id, details=''): """ Log account creation """ LogEntity._log(LOG_TYPE_ACCOUNT_CREATED, session_id, details) + @staticmethod + def account_updated(session_id, details=''): + """ Log account updated """ + LogEntity._log(LOG_TYPE_ACCOUNT_UPDATED, session_id, details) + @staticmethod def login(session_id, details=''): """ Log successful login """ diff --git a/app/redidropper/models/log_type_entity.py b/app/redidropper/models/log_type_entity.py index b3c0188..a3cb4ff 100644 --- a/app/redidropper/models/log_type_entity.py +++ b/app/redidropper/models/log_type_entity.py @@ -13,6 +13,7 @@ LOG_TYPE_ACCOUNT_MODIFIED = 'account_modified' LOG_TYPE_REDCAP_SUBJECTS_IMPORTED = 'redcap_subjects_impported' LOG_TYPE_REDCAP_EVENTS_IMPORTED = 'redcap_events_imported' +LOG_TYPE_ACCOUNT_UPDATED = 'account_updated' # LOG_TYPE_ = '' diff --git a/app/redidropper/models/user_entity.py b/app/redidropper/models/user_entity.py index 89547e4..abde53d 100644 --- a/app/redidropper/models/user_entity.py +++ b/app/redidropper/models/user_entity.py @@ -33,7 +33,7 @@ class UserEntity(db.Model, UserMixin, CRUDMixin): modified_at = db.Column("usrModifiedAt", db.TIMESTAMP, nullable=False) email_confirmed_at = db.Column("usrEmailConfirmedAt", db.DateTime, nullable=False, - server_default='0000-00-00 00:00:00') + server_default='1901-10-04 11:17:00') active = db.Column("usrIsActive", db.Boolean(), nullable=False, server_default='1') diff --git a/app/redidropper/routes/api.py b/app/redidropper/routes/api.py index a08ce59..bea8d9d 100644 --- a/app/redidropper/routes/api.py +++ b/app/redidropper/routes/api.py @@ -179,61 +179,118 @@ def download_file(): LogEntity.file_downloaded(session['uuid'], file_path) return send_file(file_path, as_attachment=True) +def __extract_user_information(request): + return { + "email": request.form.get('email'), + "first": request.form.get('first'), + "last": request.form.get('last'), + "minitial": request.form.get('minitial'), + "roles": request.form.getlist('roles[]'), + "is_edit": request.form.get('isEdit'), + "usr_id": request.form.get('usrId'), + } + +def __get_date_information(): + return { + "added_at": datetime.today(), + "access_expires_at": utils.get_expiration_date(180), + } + +def __generate_credentials(email): + # @TODO: use a non-gatorlink password here + password = email + salt, password_hash = utils.generate_auth(app.config['SECRET_KEY'], + password) + # Note: we store the salt as a prefix + return { + "email": email, + "salt": salt, + "password_hash": password_hash, + } + + +def __assign_roles(roles_required, user): + """ + Delete all roles for the user if not in the + provided `roles_required` list and assing new roles. + """ + all_roles = RoleEntity.query.all() + user_roles = [role for role in all_roles if role.name in roles_required] + user = UserEntity.update(user, roles=user_roles) + return user + + +def __check_is_existing_user(email): + """ + :rtype boolean + :return True if a user exists in the database with the given email + """ + try: + existing_user = UserEntity.query.filter_by(email=email).one() + return True + except: + return False + @app.route('/api/save_user', methods=['POST']) @login_required +@perm_admin.require(http_exception=403) def api_save_user(): """ Save a new user to the database TODO: Add support for reading a password field """ - email = request.form['email'] - first = request.form['first'] - last = request.form['last'] - minitial = request.form['minitial'] - roles = request.form.getlist('roles[]') + request_data = __extract_user_information(request) + credentials = __generate_credentials(request_data["email"]) + date_data = __get_date_information() - email_exists = False - try: - existing_user = UserEntity.query.filter_by(email=email).one() - email_exists = existing_user is not None - except: - pass - - if email_exists: + if __check_is_existing_user(request_data["email"]): return utils.jsonify_error( {'message': 'Sorry. This email is already taken.'}) - # @TODO: use a non-gatorlink password here - password = email - salt, password_hash = utils.generate_auth(app.config['SECRET_KEY'], - password) - added_date = datetime.today() - access_end_date = utils.get_expiration_date(180) + user = UserEntity.create(email=request_data["email"], + first=request_data["first"], + last=request_data["last"], + minitial=request_data["minitial"], + added_at=date_data["added_at"], + modified_at=date_data["added_at"], + access_expires_at=date_data["access_expires_at"], + password_hash="{}:{}" + .format(credentials["salt"], + credentials["password_hash"])) - # Note: we store the salt as a prefix - user = UserEntity.create(email=email, - first=first, - last=last, - minitial=minitial, - added_at=added_date, - modified_at=added_date, - access_expires_at=access_end_date, - password_hash="{}:{}".format(salt, password_hash)) - - user_roles = [] - try: - for role_name in roles: - role_entity = RoleEntity.query.filter_by(name=role_name).one() - user_roles.append(role_entity) - except Exception as exc: - app.logger.debug("Problem saving user: {}".format(exc)) + __assign_roles(request_data["roles"], user) - [user.roles.append(rol) for rol in user_roles] - user = UserEntity.save(user) app.logger.debug("saved user: {}".format(user)) LogEntity.account_created(session['uuid'], user) return utils.jsonify_success({'user': user.serialize()}) +@app.route('/api/edit_user', methods=['POST']) +@login_required +@perm_admin.require(http_exception=403) +def api_edit_user(): + """ Edit an existing user in the database + TODO: Add support for reading a password field + """ + request_data = __extract_user_information(request) + credentials = __generate_credentials(request_data["email"]) + date_data = __get_date_information() + + user = UserEntity.get_by_id(id=request_data["usr_id"]) + user.update(email=request_data["email"], + first=request_data["first"], + last=request_data["last"], + minitial=request_data["minitial"], + added_at=date_data["added_at"], + modified_at=date_data["added_at"], + access_expires_at=date_data["access_expires_at"], + password_hash="{}:{}".format(credentials["salt"], + credentials["password_hash"])) + + __assign_roles(request_data["roles"], user) + + app.logger.debug("updated user: {}".format(user)) + LogEntity.account_updated(session['uuid'], user) + return utils.jsonify_success({'user': user.serialize()}) @app.route('/api/list_users', methods=['POST', 'GET']) @login_required diff --git a/app/redidropper/static/js/admin.js b/app/redidropper/static/js/admin.js index 6885c04..e1a9702 100644 --- a/app/redidropper/static/js/admin.js +++ b/app/redidropper/static/js/admin.js @@ -5,16 +5,21 @@ // AddNewUserForm - ... var AdminUsersRow = React.createClass({ + getInitialState: function() { return { record: this.props.record, - row_num: this.props.row_num + row_num: this.props.row_num, + onEditUser: this.props.onEditUser, + editStatus: this.props.editStatus }; }, componentWillReceiveProps: function(nextProps) { this.setState({ record: nextProps.record, - row_num: nextProps.row_num + row_num: nextProps.row_num, + onEditUser: nextProps.onEditUser, + editStatus: nextProps.editStatus }); }, sendEmailVerification: function() { @@ -141,10 +146,13 @@ var AdminUsersRow = React.createClass({ render: function() { var record = this.state.record; var row_num = this.state.row_num; + var onEditUser = this.state.onEditUser; + var edit_user_form; var roles, emailButton, expireButton, deactivateButton, + editButton, title = "", display = "", expirationDate = record.access_expires_at; @@ -208,6 +216,26 @@ var AdminUsersRow = React.createClass({ } + if (record.is_active && this.props.editStatus) { + // You want to pass a function that gets called. If passed with + // out the braces, the function will be called upon render. + // This is done using an arrow function. + // Delegate to parent component. + editButton = + } + else { + editButton = + } + return (
Email Verified | Account Expiration | Account Status | +Update User | @@ -339,7 +377,18 @@ var AdminUsersPagination = React.createClass({ var AddNewUserForm = React.createClass({ getInitialState: function() { - return {error: ""}; + return { + error: "", + editRecord: this.props.editRecord, + }; + }, + + componentWillReceiveProps: function(nextProps) { + this.setState( + { + error: nextProps.error, + editRecord: nextProps.editRecord, + }); }, clearError: function() { @@ -350,19 +399,16 @@ var AddNewUserForm = React.createClass({ //Get the values entered by the user in the form var usrEmail = this.refs.user_email.getDOMNode().value.trim(); var usrFirst = this.refs.user_first_name.getDOMNode().value.trim(); - var usrMI = this.refs.user_middle_name.getDOMNode().value.trim(); + var usrMI = this.refs.user_middle_initial.getDOMNode().value.trim(); var usrLast = this.refs.user_last_name.getDOMNode().value.trim(); - var roles = []; + var isEdit = typeof this.state.editRecord === "object" && typeof this.state.editRecord.email === "string"; + var usrId = (this.state.editRecord || {}).id; - // https://developer.mozilla.org/en-US/docs/Web/API/HTMLOptionsCollection - // https://github.com/facebook/react/blob/057f41ec0f01e5e716358ad18cf7166d5cc00d68/src/browser/ui/dom/components/__tests__/ReactDOMSelect-test.js - var collection = this.refs.user_roles.getDOMNode().options; + var roleCheckboxes = document.getElementsByClassName("roleCheckbox") + var roles = Array.prototype.map.call(roleCheckboxes, (input) => { + return input.checked && input.value; + }).filter(Boolean); - for (var i = 0; i < collection.length; i++) { - if (collection.item(i).selected) { - roles.push(collection.item(i).value); - } - } console.log("roles: " + roles); if (usrEmail === "") { @@ -397,12 +443,14 @@ var AddNewUserForm = React.createClass({ "first" : usrFirst, "minitial" : usrMI, "last" : usrLast, - "roles[]" : roles + "roles[]" : roles, + "usrId" : usrId, + "isEdit" : isEdit, }; console.log('sending data: ' + Utils.print_r(data)); - var request = Utils.api_post_json("/api/save_user", data); + var request = Utils.api_post_json(isEdit ? "/api/edit_user" : "/api/save_user", data); var _this = this; request.success( function(json) { @@ -423,7 +471,11 @@ var AddNewUserForm = React.createClass({ 'is_active' : record.is_active, 'roles' : record.roles }; - _this.props.addNewUser(data); + if (isEdit) { + _this.props.updateUser(data); + } else { + _this.props.addNewUser(data); + } } } else { @@ -441,65 +493,67 @@ var AddNewUserForm = React.createClass({ }, render: function() { var error; - + var editRecord = this.state.editRecord; + var isChecked = (role) => this.state.editRecord && this.state.editRecord.roles.indexOf(role) > -1; + console.log("Render of edit user" + editRecord) // Add generic function for displaying errors - if (this.state.error !== "") { - error =
---|