def check_transition_possible(self, current_status, new_status): """Check if the transition is valid.""" valid_statuses = { "In Progress": "retract", "Available": "release", "Deleted": "delete", "Expired": "expire", "Archived": "archive", } if new_status not in valid_statuses: raise DDSArgumentError("Invalid status") possible_transitions = { "In Progress": ["Available", "Deleted", "Archived"], "Available": ["In Progress", "Expired", "Archived"], "Expired": ["Available", "Archived"], } current_transition = possible_transitions.get(current_status) if not current_transition: raise DDSArgumentError( message=f"Cannot change status for a project that has the status '{current_status}'." ) if new_status not in current_transition: raise DDSArgumentError( message=( f"You cannot {valid_statuses[new_status]} a " f"project that has the current status '{current_status}'." ) )
def check_eligibility_for_deletion(status, has_been_available): """Check if a project status is eligible for deletion""" if status not in ["In Progress"]: raise DDSArgumentError( "Project Status prevents files from being deleted.") if has_been_available: raise DDSArgumentError( "Existing project contents cannot be deleted since the project has been previously made available to recipients." ) return True
def verify_args(*args, **kwargs): if not flask.request.args: raise DDSArgumentError( message="Required information missing from request!") return func(*args, **kwargs)
def check_eligibility_for_download(status, user_role): """Check if a project status makes it eligible to download""" if status == "Available" or (status == "In Progress" and user_role in ["Unit Admin", "Unit Personnel"]): return True raise DDSArgumentError("Current Project status limits file download.")
def delete_project(self, project: models.Project, current_time: datetime.datetime): """Delete project: Make status Deleted. Only possible from In Progress. """ # Check if valid status transition self.check_transition_possible(current_status=project.current_status, new_status="Deleted") # Can only be Deleted if never made Available if project.has_been_available: raise DDSArgumentError( "You cannot delete a project that has been made available previously. " "Please abort the project if you wish to proceed." ) project.is_active = False try: # Deletes files (also commits session in the function - possibly refactor later) RemoveContents().delete_project_contents(project=project) self.rm_project_user_keys(project=project) # Delete metadata from project row self.delete_project_info(proj=project) except (TypeError, DatabaseError, DeletionError, BucketNotFoundError) as err: flask.current_app.logger.exception(err) db.session.rollback() raise DeletionError( project=project.public_id, message="Server Error: Status was not updated" ) from err delete_message = ( f"\nAll files in project '{project.public_id}' deleted and project info cleared" ) return models.ProjectStatuses(status="Deleted", date_created=current_time), delete_message
def release_project( self, project: models.Project, current_time: datetime.datetime, deadline_in: int ) -> models.ProjectStatuses: """Release project: Make status Available. Only allowed from In Progress and Expired. """ # Check if valid status transition self.check_transition_possible( current_status=project.current_status, new_status="Available" ) if deadline_in > 90: raise DDSArgumentError( message="The deadline needs to be less than (or equal to) 90 days." ) deadline = dds_web.utils.current_time(to_midnight=True) + datetime.timedelta( days=deadline_in ) # Project can only move from Expired 2 times if project.current_status == "Expired": if project.times_expired > 2: raise DDSArgumentError( "Project availability limit: Project cannot be made Available any more times" ) else: # current status is in progress if project.has_been_available: # No change in deadline if made available before deadline = project.current_deadline else: project.released = current_time # Create row in ProjectStatuses return models.ProjectStatuses( status="Available", date_created=current_time, deadline=deadline )
def post(self): """Give access to user.""" # Verify that user specified json_input = flask.request.json if "email" not in json_input: raise DDSArgumentError(message="User email missing.") user = user_schemas.UserSchema().load( {"email": json_input.pop("email")}) if not user: raise NoSuchUserError() # Verify that project specified project_info = flask.request.args project = None if project_info and project_info.get("project"): project = project_schemas.ProjectRequiredSchema().load( project_info) # Verify permission to give user access self.verify_renew_access_permission(user=user, project=project) # Give access to specific project or all active projects if no project specified list_of_projects = None if not project: if user.role == "Researcher": list_of_projects = [ x.project for x in user.project_associations ] elif user.role in ["Unit Personnel", "Unit Admin"]: list_of_projects = user.unit.projects else: list_of_projects = [project] errors = self.give_project_access(project_list=list_of_projects, current_user=auth.current_user(), user=user) if errors: return {"errors": errors} return { "message": f"Project access updated for user '{user.primary_email}'." }
def put(self): """Update info in db.""" # Verify project ID and access project = project_schemas.ProjectRequiredSchema().load( flask.request.args) # Get file name from request from CLI file_name = flask.request.json.get("name") if not file_name: raise DDSArgumentError( "No file name specified. Cannot update file.") # Update file info try: flask.current_app.logger.debug( "Updating file in current project: %s", project.public_id) flask.current_app.logger.debug(f"File name: {file_name}") file = models.File.query.filter( sqlalchemy.and_( models.File.project_id == sqlalchemy.func.binary( project.id), models.File.name == sqlalchemy.func.binary(file_name), )).first() if not file: raise NoSuchFileError() file.time_latest_download = dds_web.utils.current_time() except (sqlalchemy.exc.SQLAlchemyError, sqlalchemy.exc.OperationalError) as err: db.session.rollback() flask.current_app.logger.exception(str(err)) raise DatabaseError( message=str(err), alt_message="Update of file info failed" + (": Database malfunction." if isinstance( err, sqlalchemy.exc.OperationalError) else "."), ) from err else: # flask.current_app.logger.debug("File %s updated", file_name) db.session.commit() return {"message": "File info updated."}
def archive_project(self, project: models.Project, current_time: datetime.datetime, aborted: bool = False): """Archive project: Make status Archived. Only possible from In Progress, Available and Expired. Optional aborted flag if something has gone wrong. """ # Check if valid status transition self.check_transition_possible(current_status=project.current_status, new_status="Archived") if project.current_status == "In Progress": if project.has_been_available and not aborted: raise DDSArgumentError( "You cannot archive a project that has been made available previously. " "Please abort the project if you wish to proceed.") project.is_active = False try: # Deletes files (also commits session in the function - possibly refactor later) RemoveContents().delete_project_contents(project=project) delete_message = f"\nAll files in {project.public_id} deleted" self.rm_project_user_keys(project=project) # Delete metadata from project row if aborted: project = self.delete_project_info(project) delete_message += " and project info cleared" except (TypeError, DatabaseError, DeletionError, BucketNotFoundError) as err: flask.current_app.logger.exception(err) db.session.rollback() raise DeletionError( project=project.public_id, message="Server Error: Status was not updated") from err return ( models.ProjectStatuses(status="Archived", date_created=current_time, is_aborted=aborted), delete_message, )
def expire_project( self, project: models.Project, current_time: datetime.datetime, deadline_in: int ) -> models.ProjectStatuses: """Expire project: Make status Expired. Only possible from Available. """ # Check if valid status transition self.check_transition_possible(current_status=project.current_status, new_status="Expired") if deadline_in > 30: raise DDSArgumentError( message="The deadline needs to be less than (or equal to) 30 days." ) deadline = dds_web.utils.current_time(to_midnight=True) + datetime.timedelta( days=deadline_in ) return models.ProjectStatuses( status="Expired", date_created=current_time, deadline=deadline )
def check_eligibility_for_upload(status): """Check if a project status is eligible for upload/modification""" if status != "In Progress": raise DDSArgumentError( "Project not in right status to upload/modify files.") return True
def put(self): """Update existing file.""" # Verify project ID and access project = project_schemas.ProjectRequiredSchema().load( flask.request.args) # Verify that projet has correct status for upload check_eligibility_for_upload(status=project.current_status) file_info = flask.request.json if not all(x in file_info for x in ["name", "name_in_bucket", "subpath", "size"]): raise DDSArgumentError( "Information is missing, cannot add file to database.") try: # Check if file already in db existing_file = models.File.query.filter( sqlalchemy.and_( models.File.name == sqlalchemy.func.binary( file_info.get("name")), models.File.project_id == project.id, )).first() # Error if not found if not existing_file or existing_file is None: raise NoSuchFileError( "Cannot update non-existent file " f"'{werkzeug.utils.secure_filename(file_info.get('name'))}' in the database!" ) # Get version row current_file_version = models.Version.query.filter( sqlalchemy.and_( models.Version.active_file == sqlalchemy.func.binary( existing_file.id), models.Version.time_deleted.is_(None), )).all() if len(current_file_version) > 1: flask.current_app.logger.warning( "There is more than one version of the file " "which does not yet have a deletion timestamp.") # Same timestamp for deleted and created new file new_timestamp = dds_web.utils.current_time() # Overwritten == deleted/deactivated for version in current_file_version: if version.time_deleted is None: version.time_deleted = new_timestamp # Update file info existing_file.subpath = file_info.get("subpath") existing_file.size_original = file_info.get("size") existing_file.size_stored = file_info.get("size_processed") existing_file.compressed = file_info.get("compressed") existing_file.salt = file_info.get("salt") existing_file.public_key = file_info.get("public_key") existing_file.time_uploaded = new_timestamp existing_file.checksum = file_info.get("checksum") # New version new_version = models.Version( size_stored=file_info.get("size_processed"), time_uploaded=new_timestamp, active_file=existing_file.id, project_id=project, ) # Update foreign keys and relationships project.file_versions.append(new_version) existing_file.versions.append(new_version) db.session.add(new_version) db.session.commit() except (sqlalchemy.exc.SQLAlchemyError, sqlalchemy.exc.OperationalError) as err: db.session.rollback() raise DatabaseError( message=str(err), alt_message=f"Failed updating file information" + (": Database malfunction." if isinstance( err, sqlalchemy.exc.OperationalError) else "."), ) from err return {"message": f"File '{file_info.get('name')}' updated in db."}
def post(self): """Update Project Status.""" # Verify project ID and access project = project_schemas.ProjectRequiredSchema().load(flask.request.args) # Check if valid status json_input = flask.request.json new_status = json_input.get("new_status") if not new_status: raise DDSArgumentError(message="No status transition provided. Specify the new status.") # Override default to send email send_email = json_input.get("send_email", True) # Initial variable definition curr_date = dds_web.utils.current_time() delete_message = "" is_aborted = False # Moving to Available if new_status == "Available": deadline_in = json_input.get("deadline", project.responsible_unit.days_in_available) new_status_row = self.release_project( project=project, current_time=curr_date, deadline_in=deadline_in ) elif new_status == "In Progress": new_status_row = self.retract_project(project=project, current_time=curr_date) elif new_status == "Expired": deadline_in = json_input.get("deadline", project.responsible_unit.days_in_expired) new_status_row = self.expire_project( project=project, current_time=curr_date, deadline_in=deadline_in ) elif new_status == "Deleted": new_status_row, delete_message = self.delete_project( project=project, current_time=curr_date ) elif new_status == "Archived": is_aborted = json_input.get("is_aborted", False) new_status_row, delete_message = self.archive_project( project=project, current_time=curr_date, aborted=is_aborted ) else: raise DDSArgumentError(message="Invalid status") try: project.project_statuses.append(new_status_row) db.session.commit() except (sqlalchemy.exc.OperationalError, sqlalchemy.exc.SQLAlchemyError) as err: flask.current_app.logger.exception(err) db.session.rollback() raise DatabaseError( message=str(err), alt_message=( "Status was not updated" + ( ": Database malfunction." if isinstance(err, sqlalchemy.exc.OperationalError) else ": Server Error." ) ), ) from err # Mail users once project is made available if new_status == "Available" and send_email: for user in project.researchusers: AddUser.compose_and_send_email_to_user( userobj=user.researchuser, mail_type="project_release", project=project ) return_message = f"{project.public_id} updated to status {new_status}" + ( " (aborted)" if new_status == "Archived" and is_aborted else "" ) if new_status != "Available": return_message += delete_message + "." else: return_message += ( f". An e-mail notification has{' not ' if not send_email else ' '}been sent." ) return {"message": return_message}
def post(self): """Create a new project.""" p_info = flask.request.json # Verify enough number of Unit Admins or return message force_create = p_info.pop("force", False) if not isinstance(force_create, bool): raise DDSArgumentError(message="`force` is a boolean value: True or False.") warning_message = dds_web.utils.verify_enough_unit_admins( unit_id=auth.current_user().unit.id, force_create=force_create ) if warning_message: return {"warning": warning_message} # Add a new project to db import pymysql try: new_project = project_schemas.CreateProjectSchema().load(p_info) db.session.add(new_project) except sqlalchemy.exc.OperationalError as err: raise DatabaseError(message=str(err), alt_message="Unexpected database error.") if not new_project: raise DDSArgumentError("Failed to create project.") # TODO: Change -- the bucket should be created before the row is added to the database # This is a quick fix so that things do not break with ApiS3Connector(project=new_project) as s3: try: s3.resource.create_bucket(Bucket=new_project.bucket) except ( botocore.exceptions.ClientError, botocore.exceptions.ParamValidationError, ) as err: # For now just keeping the project row raise S3ConnectionError(str(err)) from err try: db.session.commit() except (sqlalchemy.exc.SQLAlchemyError, sqlalchemy.exc.OperationalError, TypeError) as err: flask.current_app.logger.exception(err) db.session.rollback() raise DatabaseError( message=str(err), alt_message=( "Project was not created" + ( ": Database malfunction." if isinstance(err, sqlalchemy.exc.OperationalError) else ": Server error." ), ), ) from err except ( marshmallow.exceptions.ValidationError, DDSArgumentError, AccessDeniedError, ) as err: flask.current_app.logger.exception(err) db.session.rollback() raise flask.current_app.logger.debug( f"Project {new_project.public_id} created by user {auth.current_user().username}." ) user_addition_statuses = [] if "users_to_add" in p_info: for user in p_info["users_to_add"]: try: existing_user = user_schemas.UserSchema().load(user) unanswered_invite = user_schemas.UnansweredInvite().load(user) except ( marshmallow.exceptions.ValidationError, sqlalchemy.exc.OperationalError, ) as err: if isinstance(err, sqlalchemy.exc.OperationalError): flask.current_app.logger.error(err) addition_status = "Unexpected database error." else: addition_status = f"Error for {user.get('email')}: {err}" user_addition_statuses.append(addition_status) continue if not existing_user and not unanswered_invite: # Send invite if the user doesn't exist invite_user_result = AddUser.invite_user( email=user.get("email"), new_user_role=user.get("role"), project=new_project, ) if invite_user_result["status"] == http.HTTPStatus.OK: invite_msg = ( f"Invitation sent to {user['email']}. " "The user should have a valid account to be added to a project" ) else: invite_msg = invite_user_result["message"] user_addition_statuses.append(invite_msg) else: # If it is an existing user, add them to project. addition_status = "" try: add_user_result = AddUser.add_to_project( whom=existing_user or unanswered_invite, project=new_project, role=user.get("role"), ) except DatabaseError as err: addition_status = f"Error for {user['email']}: {err.description}" else: addition_status = add_user_result["message"] user_addition_statuses.append(addition_status) return { "status": http.HTTPStatus.OK, "message": f"Added new project '{new_project.title}'", "project_id": new_project.public_id, "user_addition_statuses": user_addition_statuses, }