diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f71c69244..1561b1288 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,9 +1,24 @@ Changelog ========== -.. _2.3.1: +.. _2.5.0: -2.3.1 - 2023-07-05 +version 2.5.0 - 2023-08-30 +~~~~~~~~~~~~~~~~~~~~~~~~ + +- Dependencies: + - `cryptography` from `39.0.1` to `41.0.3` + - `certifi` from `2022.12.07` to `2023.07.22` +- _New_ project buckets will be created at a new storage location if Unit information has been updated with storage keys and information. +- Bug fixed: Listing projects via web interface works again +- Endpoint `ProjectBusy` is no longer usable; `dds-cli` versions prior to `2.2.0` will no longer work +- New endpoint `UnitUserEmails`: Super Admins can get primary emails for Unit Admins- and Personnel. This is for emailing purposes. +- Message about project being busy has been changed to a more accurate and understandable statement +- Documentation: Typo fixed in Technical Overview + +.. _2.4.0: + +2.4.0 - 2023-07-05 ~~~~~~~~~~~~~~~~~~~ - Dependencies: diff --git a/SPRINTLOG.md b/SPRINTLOG.md index 77e5e29d4..8a17e9827 100644 --- a/SPRINTLOG.md +++ b/SPRINTLOG.md @@ -267,7 +267,26 @@ _Nothing merged in CLI during this sprint_ - Change from personal name to unit name if / where it's displayed in emails ([#1439](https://github.com/ScilifelabDataCentre/dds_web/pull/1439)) - Refactoring: `lost_files_s3_db` flask command changed to group with subcommands ([#1438](https://github.com/ScilifelabDataCentre/dds_web/pull/1438)) -# 2023-06-26 - 2023-07-14 +# 2023-06-26 - 2023-08-04 (Summer) - Change display project info depending on the user role ([#1440](https://github.com/ScilifelabDataCentre/dds_web/pull/1440)) - New version: 2.4.0 ([#1443](https://github.com/ScilifelabDataCentre/dds_web/pull/1443)) +- Bug fix: Web UI project listing fix ([#1445](https://github.com/ScilifelabDataCentre/dds_web/pull/1445)) +- Documentation: Technical Overview, section Creating a Unit in the DDS ([#1449](https://github.com/ScilifelabDataCentre/dds_web/pull/1449)) + +# 2023-08-07 - 2023-08-18 + +- Empty endpoint: `ProjectBusy` ([#1446](https://github.com/ScilifelabDataCentre/dds_web/pull/1446)) + +# 2023-08-04 - 2023-08-18 + +- Rename storage-related columns in `Unit` table ([#1447](https://github.com/ScilifelabDataCentre/dds_web/pull/1447)) +- Dependency: Bump `cryptography` to 41.0.3 due to security vulnerability alerts(s) ([#1451](https://github.com/ScilifelabDataCentre/dds_web/pull/1451)) +- Allow for change of storage location ([#1448](https://github.com/ScilifelabDataCentre/dds_web/pull/1448)) +- Endpoint: `UnitUserEmails`; Return primary emails for Unit Personnel- and Admins ([#1454](https://github.com/ScilifelabDataCentre/dds_web/pull/1454)) +- Change message about project being busy with upload etc ([#1450](https://github.com/ScilifelabDataCentre/dds_web/pull/1450)) + +# 2023-08-21 - 2023-09-01 + +- Dependency: Bump `certifi` to 2023.07.22 due to security vulnerability alert(s) ([#1452](https://github.com/ScilifelabDataCentre/dds_web/pull/1452)) +- New version: 2.5.0 ([#1458](https://github.com/ScilifelabDataCentre/dds_web/pull/1458)) diff --git a/dds_web/__init__.py b/dds_web/__init__.py index 337903d9f..318ff4c2f 100644 --- a/dds_web/__init__.py +++ b/dds_web/__init__.py @@ -277,11 +277,13 @@ def load_user(user_id): quarterly_usage, collect_stats, monitor_usage, + update_unit, ) # Add flask commands - general app.cli.add_command(fill_db_wrapper) app.cli.add_command(create_new_unit) + app.cli.add_command(update_unit) app.cli.add_command(update_uploaded_file_with_log) app.cli.add_command(lost_files_s3_db) diff --git a/dds_web/api/__init__.py b/dds_web/api/__init__.py index c5824ea00..380b8c51f 100644 --- a/dds_web/api/__init__.py +++ b/dds_web/api/__init__.py @@ -91,6 +91,7 @@ def output_json(data, code, headers=None): ) api.add_resource(superadmin_only.AnyProjectsBusy, "/proj/busy/any", endpoint="projects_busy_any") api.add_resource(superadmin_only.Statistics, "/stats", endpoint="stats") +api.add_resource(superadmin_only.UnitUserEmails, "/user/emails", endpoint="user_emails") # Invoicing ############################################################################ Invoicing # api.add_resource(user.ShowUsage, "/usage", endpoint="usage") diff --git a/dds_web/api/api_s3_connector.py b/dds_web/api/api_s3_connector.py index af7dbe99a..de26175f1 100644 --- a/dds_web/api/api_s3_connector.py +++ b/dds_web/api/api_s3_connector.py @@ -17,6 +17,7 @@ ) from dds_web.database import models +import dds_web.utils #################################################################################################### @@ -51,14 +52,19 @@ def __exit__(self, exc_type, exc_value, tb): return True def get_s3_info(self): - """Get information required to connect to cloud.""" + """Get information required to connect to cloud storage.""" + # Check if to use sto4 + use_sto4 = dds_web.utils.use_sto4( + unit_object=self.project.responsible_unit, project_object=self.project + ) + endpoint, name, accesskey, secretkey = ( models.Unit.query.filter_by(id=self.project.responsible_unit.id) .with_entities( - models.Unit.safespring_endpoint, - models.Unit.safespring_name, - models.Unit.safespring_access, - models.Unit.safespring_secret, + models.Unit.sto4_endpoint if use_sto4 else models.Unit.sto2_endpoint, + models.Unit.sto4_name if use_sto4 else models.Unit.sto2_name, + models.Unit.sto4_access if use_sto4 else models.Unit.sto2_access, + models.Unit.sto4_secret if use_sto4 else models.Unit.sto2_secret, ) .one_or_none() ) diff --git a/dds_web/api/project.py b/dds_web/api/project.py index fdefa0fcc..da18a9340 100644 --- a/dds_web/api/project.py +++ b/dds_web/api/project.py @@ -39,6 +39,7 @@ ProjectBusyError, S3ConnectionError, NoSuchUserError, + VersionMismatchError, ) from dds_web.api.user import AddUser from dds_web.api.schemas import project_schemas, user_schemas @@ -94,7 +95,7 @@ def post(self): if project.busy: raise ProjectBusyError( message=( - f"The project '{project_id}' is currently busy with upload/download/deletion. " + f"The status for the project '{project_id}' is already in the process of being changed. " "Please try again later. \n\nIf you know the project is not busy, contact support." ) ) @@ -144,8 +145,11 @@ def post(self): try: project.project_statuses.append(new_status_row) - project.busy = False + project.busy = False # TODO: Use set_busy instead? db.session.commit() + flask.current_app.logger.info( + f"Busy status set. Project: '{project.public_id}', Busy: False" + ) except (sqlalchemy.exc.OperationalError, sqlalchemy.exc.SQLAlchemyError) as err: flask.current_app.logger.exception(err) db.session.rollback() @@ -489,7 +493,7 @@ def format_project_dict(self, current_user): user_projects = models.Project.query.filter(sqlalchemy.and_(*all_filters)).all() researcher = False - if auth.current_user().role not in ["Super Admin", "Unit Admin", "Unit Personnel"]: + if current_user.role not in ["Super Admin", "Unit Admin", "Unit Personnel"]: researcher = True # Get info for all projects @@ -922,45 +926,20 @@ def give_project_access(project_list, current_user, user): class ProjectBusy(flask_restful.Resource): - @auth.login_required(role=["Unit Admin", "Unit Personnel", "Project Owner", "Researcher"]) + @auth.login_required @logging_bind_request - @dbsession - @json_required def put(self): - """Set project to busy / not busy.""" - # Get project ID, project and verify access - project_id = dds_web.utils.get_required_item(obj=flask.request.args, req="project") - project = dds_web.utils.collect_project(project_id=project_id) - dds_web.utils.verify_project_access(project=project) + """OLD ENDPOINT. + Previously set project status to busy. - # Get busy or not busy - request_json = flask.request.get_json(silent=True) - set_to_busy = request_json.get("busy") # Already checked by json_required - if set_to_busy is None: - raise DDSArgumentError(message="Missing information about setting busy or not busy.") - - if set_to_busy: - # Check if project is busy - if project.busy: - return {"ok": False, "message": "The project is already busy, cannot proceed."} - - # Set project as busy - project.busy = True - else: - # Check if project is not busy - if not project.busy: - return { - "ok": False, - "message": "The project is already not busy, cannot proceed.", - } - - # Set project to not busy - project.busy = False - - return { - "ok": True, - "message": f"Project {project_id} was set to {'busy' if set_to_busy else 'not busy'}.", - } + TODO: Can remove from 2024. Will otherwise cause breaking changes for old CLI versions. + """ + raise VersionMismatchError( + message=( + "Your CLI version is trying to use functionality which is no longer in use. " + "Upgrade your version to the latest one and run your command again." + ) + ) class ProjectInfo(flask_restful.Resource): diff --git a/dds_web/api/superadmin_only.py b/dds_web/api/superadmin_only.py index 13e5b618a..6703dd348 100644 --- a/dds_web/api/superadmin_only.py +++ b/dds_web/api/superadmin_only.py @@ -47,7 +47,7 @@ def get(self): "Public ID": u.public_id, "External Display Name": u.external_display_name, "Contact Email": u.contact_email, - "Safespring Endpoint": u.safespring_endpoint, + "Safespring Endpoint": u.sto2_endpoint, "Days In Available": u.days_in_available, "Days In Expired": u.days_in_expired, "Size": u.size, @@ -345,3 +345,23 @@ def get(self): if stat_rows ] } + + +class UnitUserEmails(flask_restful.Resource): + """Get emails for Unit Admins and Unit Personnel.""" + + @auth.login_required(role=["Super Admin"]) + @logging_bind_request + @handle_db_error + def get(self): + """Collect the user emails and return a list.""" + # Get all emails connected to a Unit Admin or Personnel account + user_emails = [user.primary_email for user in models.UnitUser.query.all()] + + # Return empty if no emails + if not user_emails: + flask.current_app.logger.info("There are no primary emails to return.") + return {"empty": True} + + # Return emails + return {"emails": user_emails} diff --git a/dds_web/commands.py b/dds_web/commands.py index 930c65c67..a69c45e2b 100644 --- a/dds_web/commands.py +++ b/dds_web/commands.py @@ -8,6 +8,7 @@ import sys import datetime from dateutil.relativedelta import relativedelta +import gc # Installed import click @@ -130,10 +131,10 @@ def create_new_unit( external_display_name=external_display_name, contact_email=contact_email, internal_ref=internal_ref or public_id, - safespring_endpoint=safespring_endpoint, - safespring_name=safespring_name, - safespring_access=safespring_access, - safespring_secret=safespring_secret, + sto4_endpoint=safespring_endpoint, + sto4_name=safespring_name, + sto4_access=safespring_access, + sto4_secret=safespring_secret, days_in_available=days_in_available, days_in_expired=days_in_expired, quota=quota, @@ -144,6 +145,61 @@ def create_new_unit( flask.current_app.logger.info(f"Unit '{name}' created") + # Clean up information + del safespring_endpoint + del safespring_name + del safespring_access + del safespring_secret + gc.collect() + + +@click.command("update-unit") +@click.option("--unit-id", "-u", type=str, required=True) +@click.option("--sto4-endpoint", "-se", type=str, required=True) +@click.option("--sto4-name", "-sn", type=str, required=True) +@click.option("--sto4-access", "-sa", type=str, required=True) +@click.option("--sto4-secret", "-ss", type=str, required=True) +@flask.cli.with_appcontext +def update_unit(unit_id, sto4_endpoint, sto4_name, sto4_access, sto4_secret): + """Update unit info.""" + # Imports + import rich.prompt + from dds_web import db + from dds_web.utils import current_time + from dds_web.database import models + + # Get unit + unit: models.Unit = models.Unit.query.filter_by(public_id=unit_id).one_or_none() + if not unit: + flask.current_app.logger.error(f"There is no unit with the public ID '{unit_id}'.") + return + + # Warn user if sto4 info already exists + if unit.sto4_start_time: + do_update = rich.prompt.Confirm.ask( + f"Unit '{unit_id}' appears to have sto4 variables set already. Are you sure you want to overwrite them?" + ) + if not do_update: + flask.current_app.logger.info(f"Cancelling sto4 update for unit '{unit_id}'.") + return + + # Set sto4 info + unit.sto4_start_time = current_time() + unit.sto4_endpoint = sto4_endpoint + unit.sto4_name = sto4_name + unit.sto4_access = sto4_access + unit.sto4_secret = sto4_secret + db.session.commit() + + flask.current_app.logger.info(f"Unit '{unit_id}' updated successfully") + + # Clean up information + del sto4_endpoint + del sto4_name + del sto4_access + del sto4_secret + gc.collect() + @click.command("update-uploaded-file") @click.option("--project", "-p", type=str, required=True) @@ -235,7 +291,8 @@ def list_lost_files(project_id: str): # Imports import boto3 from dds_web.database import models - from dds_web.utils import list_lost_files_in_project + from dds_web.utils import list_lost_files_in_project, use_sto4 + from dds_web.errors import S3InfoNotFoundError if project_id: flask.current_app.logger.debug(f"Searching for lost files in project '{project_id}'.") @@ -248,12 +305,26 @@ def list_lost_files(project_id: str): # Start s3 session session = boto3.session.Session() + # Check which Safespring storage location to use + # Use sto4 if project created after sto4 info added + try: + sto4: bool = use_sto4(unit_object=project.responsible_unit, project_object=project) + except S3InfoNotFoundError as err: + flask.current_app.logger.error(str(err)) + sys.exit(1) + # Connect to S3 resource = session.resource( service_name="s3", - endpoint_url=project.responsible_unit.safespring_endpoint, - aws_access_key_id=project.responsible_unit.safespring_access, - aws_secret_access_key=project.responsible_unit.safespring_secret, + endpoint_url=project.responsible_unit.sto4_endpoint + if sto4 + else project.responsible_unit.sto2_endpoint, + aws_access_key_id=project.responsible_unit.sto4_access + if sto4 + else project.responsible_unit.sto2_access, + aws_secret_access_key=project.responsible_unit.sto4_secret + if sto4 + else project.responsible_unit.sto2_secret, ) # List the lost files @@ -291,20 +362,34 @@ def list_lost_files(project_id: str): # Start s3 session session = boto3.session.Session() - # Connect to S3 - resource_unit = session.resource( - service_name="s3", - endpoint_url=unit.safespring_endpoint, - aws_access_key_id=unit.safespring_access, - aws_secret_access_key=unit.safespring_secret, - ) - # Counts in_db_but_not_in_s3_count: int = 0 in_s3_but_not_in_db_count: int = 0 # List files in all projects for proj in unit.projects: + # Check which Safespring storage location to use + # Use sto4 if roject created after sto4 info added + try: + sto4: bool = use_sto4(unit_object=unit, project_object=proj) + except S3InfoNotFoundError as err: + flask.current_app.logger.error(str(err)) + continue + + # Connect to S3 + resource_unit = session.resource( + service_name="s3", + endpoint_url=proj.responsible_unit.sto4_endpoint + if sto4 + else proj.responsible_unit.sto2_endpoint, + aws_access_key_id=proj.responsible_unit.sto4_access + if sto4 + else proj.responsible_unit.sto2_access, + aws_secret_access_key=proj.responsible_unit.sto4_secret + if sto4 + else proj.responsible_unit.sto2_secret, + ) + # List the lost files try: in_db_but_not_in_s3, in_s3_but_not_in_db = list_lost_files_in_project( @@ -338,7 +423,8 @@ def add_missing_bucket(project_id: str): import boto3 from botocore.client import ClientError from dds_web.database import models - from dds_web.utils import bucket_is_valid + from dds_web.utils import bucket_is_valid, use_sto4 + from dds_web.errors import S3InfoNotFoundError # Get project object project: models.Project = models.Project.query.filter_by(public_id=project_id).one_or_none() @@ -354,12 +440,25 @@ def add_missing_bucket(project_id: str): # Start s3 session session = boto3.session.Session() + # Use sto4 if project created after sto4 info added + try: + sto4 = use_sto4(unit_object=project.responsible_unit, project_object=project) + except S3InfoNotFoundError as err: + flask.current_app.logger.error(str(err)) + sys.exit(1) + # Connect to S3 resource = session.resource( service_name="s3", - endpoint_url=project.responsible_unit.safespring_endpoint, - aws_access_key_id=project.responsible_unit.safespring_access, - aws_secret_access_key=project.responsible_unit.safespring_secret, + endpoint_url=project.responsible_unit.sto4_endpoint + if sto4 + else project.responsible_unit.sto2_endpoint, + aws_access_key_id=project.responsible_unit.sto4_access + if sto4 + else project.responsible_unit.sto2_access, + aws_secret_access_key=project.responsible_unit.sto4_secret + if sto4 + else project.responsible_unit.sto2_secret, ) # Check if bucket exists @@ -392,7 +491,8 @@ def delete_lost_files(project_id: str): # Imports import boto3 from dds_web.database import models - from dds_web.utils import list_lost_files_in_project + from dds_web.utils import list_lost_files_in_project, use_sto4 + from dds_web.errors import S3InfoNotFoundError # Get project object project: models.Project = models.Project.query.filter_by(public_id=project_id).one_or_none() @@ -403,12 +503,25 @@ def delete_lost_files(project_id: str): # Start s3 session session = boto3.session.Session() + # Use sto4 if project created after sto4 info added + try: + sto4: bool = use_sto4(unit_object=project.responsible_unit, project_object=project) + except S3InfoNotFoundError as err: + flask.current_app.logger.error(str(err)) + sys.exit(1) + # Connect to S3 resource = session.resource( service_name="s3", - endpoint_url=project.responsible_unit.safespring_endpoint, - aws_access_key_id=project.responsible_unit.safespring_access, - aws_secret_access_key=project.responsible_unit.safespring_secret, + endpoint_url=project.responsible_unit.sto4_endpoint + if sto4 + else project.responsible_unit.sto2_endpoint, + aws_access_key_id=project.responsible_unit.sto4_access + if sto4 + else project.responsible_unit.sto2_access, + aws_secret_access_key=project.responsible_unit.sto4_secret + if sto4 + else project.responsible_unit.sto2_secret, ) # Get list of lost files diff --git a/dds_web/database/models.py b/dds_web/database/models.py index ef92a4be1..c568db36c 100644 --- a/dds_web/database/models.py +++ b/dds_web/database/models.py @@ -188,12 +188,20 @@ class Unit(db.Model): external_display_name = db.Column(db.String(255), unique=False, nullable=False) contact_email = db.Column(db.String(255), unique=False, nullable=True) internal_ref = db.Column(db.String(50), unique=True, nullable=False) - safespring_endpoint = db.Column( - db.String(255), unique=False, nullable=False - ) # unique=True later - safespring_name = db.Column(db.String(255), unique=False, nullable=False) # unique=True later - safespring_access = db.Column(db.String(255), unique=False, nullable=False) # unique=True later - safespring_secret = db.Column(db.String(255), unique=False, nullable=False) # unique=True later + + # Safespring storage + sto2_endpoint = db.Column(db.String(255), unique=False, nullable=False) # unique=True later + sto2_name = db.Column(db.String(255), unique=False, nullable=False) # unique=True later + sto2_access = db.Column(db.String(255), unique=False, nullable=False) # unique=True later + sto2_secret = db.Column(db.String(255), unique=False, nullable=False) # unique=True later + + # New safespring storage + sto4_start_time = db.Column(db.DateTime(), nullable=True) + sto4_endpoint = db.Column(db.String(255), unique=False, nullable=True) # unique=True later + sto4_name = db.Column(db.String(255), unique=False, nullable=True) # unique=True later + sto4_access = db.Column(db.String(255), unique=False, nullable=True) # unique=True later + sto4_secret = db.Column(db.String(255), unique=False, nullable=True) # unique=True later + days_in_available = db.Column(db.Integer, unique=False, nullable=False, default=90) counter = db.Column(db.Integer, unique=False, nullable=True) days_in_expired = db.Column(db.Integer, unique=False, nullable=False, default=30) diff --git a/dds_web/development/db_init.py b/dds_web/development/db_init.py index c04937ccf..2c5ed5ab6 100644 --- a/dds_web/development/db_init.py +++ b/dds_web/development/db_init.py @@ -91,10 +91,10 @@ def fill_db(): contact_email="support@example.com", internal_ref="someunit", quota=10**9, # 1 GB - safespring_endpoint=current_app.config.get("SAFESPRING_URL"), - safespring_name=current_app.config.get("DDS_SAFESPRING_PROJECT"), - safespring_access=current_app.config.get("DDS_SAFESPRING_ACCESS"), - safespring_secret=current_app.config.get("DDS_SAFESPRING_SECRET"), + sto2_endpoint=current_app.config.get("SAFESPRING_URL"), + sto2_name=current_app.config.get("DDS_SAFESPRING_PROJECT"), + sto2_access=current_app.config.get("DDS_SAFESPRING_ACCESS"), + sto2_secret=current_app.config.get("DDS_SAFESPRING_SECRET"), ) unit_1.users.extend([unituser_1, unituser_2, unitadmin_1, unitadmin_2, unitadmin_3]) diff --git a/dds_web/development/factories.py b/dds_web/development/factories.py index 17e907a33..fff01c8fa 100644 --- a/dds_web/development/factories.py +++ b/dds_web/development/factories.py @@ -25,10 +25,10 @@ class Meta: external_display_name = "Display Name" contact_email = "support@example.com" internal_ref = factory.Sequence(lambda n: f"someunit {n}") - safespring_endpoint = "endpoint" - safespring_name = "dds.example.com" - safespring_access = "access" - safespring_secret = "secret" + sto2_endpoint = "endpoint" + sto2_name = "dds.example.com" + sto2_access = "access" + sto2_secret = "secret" @factory.post_generation def users(self, create, extracted, **kwargs): diff --git a/dds_web/static/js/dds.js b/dds_web/static/js/dds.js index a9643ca49..861827669 100644 --- a/dds_web/static/js/dds.js +++ b/dds_web/static/js/dds.js @@ -18,7 +18,7 @@ } form.classList.add("was-validated"); }, - false + false, ); }); })(); diff --git a/dds_web/static/scss/_variables.scss b/dds_web/static/scss/_variables.scss index ac7189afe..79e4121a8 100644 --- a/dds_web/static/scss/_variables.scss +++ b/dds_web/static/scss/_variables.scss @@ -28,8 +28,19 @@ $font-family-sans-serif: "Lato", // Everything else is the default Bootstrap 5 sans-serif font stack.. system-ui, - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", - sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !default; + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + "Helvetica Neue", + Arial, + "Noto Sans", + "Liberation Sans", + sans-serif, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji" !default; // // Override Bootstrap defaults diff --git a/dds_web/utils.py b/dds_web/utils.py index f7269d22c..eb34ef5a4 100644 --- a/dds_web/utils.py +++ b/dds_web/utils.py @@ -24,6 +24,7 @@ DDSArgumentError, NoSuchProjectError, MaintenanceOngoingException, + S3InfoNotFoundError, ) import flask_mail import flask_login @@ -725,3 +726,26 @@ def list_lost_files_in_project(project, s3_resource): ) return diff_db, diff_s3 + + +def use_sto4(unit_object, project_object) -> bool: + """Check if project is newer than sto4 info, in that case return True.""" + project_id_logging: str = f"Safespring location for project '{project_object.public_id}': " + sto4_endpoint_added = unit_object.sto4_start_time + if sto4_endpoint_added and project_object.date_created > sto4_endpoint_added: + if not all( + [ + unit_object.sto4_endpoint, + unit_object.sto4_name, + unit_object.sto4_access, + unit_object.sto4_secret, + ] + ): + raise S3InfoNotFoundError( + message=f"One or more sto4 variables are missing for unit {unit_object.public_id}." + ) + flask.current_app.logger.info(f"{project_id_logging}sto4") + return True + + flask.current_app.logger.info(f"{project_id_logging}sto2") + return False diff --git a/dds_web/version.py b/dds_web/version.py index 3d67cd6bb..50062f87c 100644 --- a/dds_web/version.py +++ b/dds_web/version.py @@ -1 +1 @@ -__version__ = "2.4.0" +__version__ = "2.5.0" diff --git a/doc/Technical-Overview.pdf b/doc/Technical-Overview.pdf index f5b8459fa..1d0c3e67f 100644 Binary files a/doc/Technical-Overview.pdf and b/doc/Technical-Overview.pdf differ diff --git a/docker-compose.yml b/docker-compose.yml index d4a9c540d..dd7a6939f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -113,6 +113,27 @@ services: # source: ./minio-data # target: /data + minio2: # Added in order to be able to test the new sto4 move + container_name: dds_minio_2 + image: minio/minio:RELEASE.2022-02-24T22-12-01Z + profiles: + - s3 + - full-dev + - cli + command: server /data --console-address ":9003" + ports: + - 127.0.0.1:9002:9000 + - 127.0.0.1:9003:9003 + environment: + MINIO_ROOT_USER: minio2 # access key + MINIO_ROOT_PASSWORD: minioPassword2 # secret key + # NOTE: Uncomment if you want to keep your data. + # Mounts a folder into the container to make uploaded data persistent. + # volumes: + # - type: bind + # source: ./minio-data + # target: /data + mailcatcher: container_name: dds_mailcatcher image: sj26/mailcatcher:latest diff --git a/migrations/versions/1e56b6212479_add_sto4_columns.py b/migrations/versions/1e56b6212479_add_sto4_columns.py new file mode 100644 index 000000000..26a720b3e --- /dev/null +++ b/migrations/versions/1e56b6212479_add_sto4_columns.py @@ -0,0 +1,36 @@ +"""add-sto4-columns + +Revision ID: 1e56b6212479 +Revises: bb2f428feb9b +Create Date: 2023-07-26 10:40:22.591583 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = "1e56b6212479" +down_revision = "bb2f428feb9b" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("units", sa.Column("sto4_start_time", sa.DateTime(), nullable=True)) + op.add_column("units", sa.Column("sto4_endpoint", sa.String(length=255), nullable=True)) + op.add_column("units", sa.Column("sto4_name", sa.String(length=255), nullable=True)) + op.add_column("units", sa.Column("sto4_access", sa.String(length=255), nullable=True)) + op.add_column("units", sa.Column("sto4_secret", sa.String(length=255), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("units", "sto4_secret") + op.drop_column("units", "sto4_access") + op.drop_column("units", "sto4_name") + op.drop_column("units", "sto4_endpoint") + op.drop_column("units", "sto4_start_time") + # ### end Alembic commands ### diff --git a/migrations/versions/bb2f428feb9b_rename_safespring_columns.py b/migrations/versions/bb2f428feb9b_rename_safespring_columns.py new file mode 100644 index 000000000..745df73cf --- /dev/null +++ b/migrations/versions/bb2f428feb9b_rename_safespring_columns.py @@ -0,0 +1,82 @@ +"""rename-safespring-columns + +Revision ID: bb2f428feb9b +Revises: 2cefec51b9bb +Create Date: 2023-07-26 07:11:20.429058 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = "bb2f428feb9b" +down_revision = "2cefec51b9bb" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + table_name="units", + column_name="safespring_endpoint", + nullable=False, + new_column_name="sto2_endpoint", + type_=sa.String(length=255), + ) + op.alter_column( + table_name="units", + column_name="safespring_name", + nullable=False, + new_column_name="sto2_name", + type_=sa.String(length=255), + ) + op.alter_column( + table_name="units", + column_name="safespring_access", + nullable=False, + new_column_name="sto2_access", + type_=sa.String(length=255), + ) + op.alter_column( + table_name="units", + column_name="safespring_secret", + nullable=False, + new_column_name="sto2_secret", + type_=sa.String(length=255), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + table_name="units", + column_name="sto2_endpoint", + nullable=False, + new_column_name="safespring_endpoint", + type_=sa.String(length=255), + ) + op.alter_column( + table_name="units", + column_name="sto2_name", + nullable=False, + new_column_name="safespring_name", + type_=sa.String(length=255), + ) + op.alter_column( + table_name="units", + column_name="sto2_access", + nullable=False, + new_column_name="safespring_access", + type_=sa.String(length=255), + ) + op.alter_column( + table_name="units", + column_name="sto2_secret", + nullable=False, + new_column_name="safespring_secret", + type_=sa.String(length=255), + ) + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt index 0d6fd43f9..984882c5f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,11 +8,11 @@ blinker==1.4 boto3==1.20.47 botocore==1.23.47 cachetools==5.2.0 -certifi==2022.12.07 +certifi==2023.07.22 cffi==1.15.0 charset-normalizer==2.0.11 click==8.0.3 -cryptography==39.0.1 +cryptography==41.0.3 Deprecated==1.2.13 dnspython==2.2.0 dominate==2.6.0 @@ -54,6 +54,7 @@ qrcode==7.3.1 redis==4.5.5 requests==2.31.0 requests_cache==0.9.4 +rich==12.5.1 s3transfer==0.5.1 simplejson==3.17.6 six==1.16.0 diff --git a/tests/__init__.py b/tests/__init__.py index 9e8c72e75..eb27869bf 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -230,5 +230,6 @@ class DDSEndpoint: USER_FIND = BASE_ENDPOINT + "/user/find" TOTP_DEACTIVATE = BASE_ENDPOINT + "/user/totp/deactivate" STATS = BASE_ENDPOINT + "/stats" + USER_EMAILS = BASE_ENDPOINT + "/user/emails" TIMEOUT = 5 diff --git a/tests/api/test_project.py b/tests/api/test_project.py index c6c6e77c8..ae51b79b7 100644 --- a/tests/api/test_project.py +++ b/tests/api/test_project.py @@ -246,7 +246,10 @@ def test_projectstatus_when_busy(module_client): json={"something": "something"}, ) assert response.status_code == http.HTTPStatus.BAD_REQUEST - assert f"The project '{project.public_id}' is currently busy" in response.json.get("message") + assert ( + f"The status for the project '{project.public_id}' is already in the process of being changed." + in response.json.get("message") + ) def test_projectstatus_when_not_busy_but_invalid(module_client): @@ -1335,72 +1338,13 @@ def test_set_busy_no_token(module_client): assert "No token" in response.json.get("message") -def test_set_busy_superadmin_not_allowed(module_client): - """Super admin cannot set project busy/not busy.""" - token = tests.UserAuth(tests.USER_CREDENTIALS["superadmin"]).token(module_client) - response = module_client.put( - tests.DDSEndpoint.PROJECT_BUSY, - headers=token, - ) - assert response.status_code == http.HTTPStatus.FORBIDDEN - - -def test_set_busy_no_args(module_client): - """Args required to set busy/not busy.""" - # Unit Personnel - token = tests.UserAuth(tests.USER_CREDENTIALS["unituser"]).token(module_client) - response = module_client.put( - tests.DDSEndpoint.PROJECT_BUSY, - headers=token, - ) - assert response.status_code == http.HTTPStatus.BAD_REQUEST - assert "Required data missing" in response.json.get("message") - - # Unit Admin - token = tests.UserAuth(tests.USER_CREDENTIALS["unitadmin"]).token(module_client) - response = module_client.put( - tests.DDSEndpoint.PROJECT_BUSY, - headers=token, - ) - assert response.status_code == http.HTTPStatus.BAD_REQUEST - assert "Required data missing" in response.json.get("message") - - # Researcher - token = tests.UserAuth(tests.USER_CREDENTIALS["researchuser"]).token(module_client) - response = module_client.put( - tests.DDSEndpoint.PROJECT_BUSY, - headers=token, - ) - assert response.status_code == http.HTTPStatus.BAD_REQUEST - assert "Required data missing" in response.json.get("message") - - -def test_set_busy_no_busy(module_client): - """busy bool required.""" - for username in ["researchuser", "projectowner", "unituser", "unitadmin"]: - # Get user - user = models.User.query.filter_by(username=username).one_or_none() - assert user - - # Get project - project = user.projects[0] - assert project - - # Authenticate and run - token = tests.UserAuth(tests.USER_CREDENTIALS[username]).token(module_client) - response = module_client.put( - tests.DDSEndpoint.PROJECT_BUSY, - headers=token, - query_string={"project": project.public_id}, - json={"something": "notabool"}, - ) - assert response.status_code == http.HTTPStatus.BAD_REQUEST - assert "Missing information about setting busy or not busy." in response.json.get("message") - +def test_set_busy_invalid_version(module_client): + """ProjectBusy endpoint is empty and should only return error message about invalid version.""" + # Error messages + major_version_error: str = "You're using an old CLI version, please upgrade to the latest one." + busy_error: str = "Your CLI version is trying to use functionality which is no longer in use. Upgrade your version to the latest one and run your command again." -def test_set_busy_true(module_client): - """Set project as busy.""" - for username in ["researchuser", "projectowner", "unituser", "unitadmin"]: + for username in ["superadmin", "researchuser", "projectowner", "unituser", "unitadmin"]: # Get user user = models.User.query.filter_by(username=username).one_or_none() assert user @@ -1409,104 +1353,22 @@ def test_set_busy_true(module_client): project = user.projects[0] assert project - # Set project to not busy - project.busy = False - db.session.commit() - assert not project.busy - # Authenticate and run token = tests.UserAuth(tests.USER_CREDENTIALS[username]).token(module_client) - response = module_client.put( - tests.DDSEndpoint.PROJECT_BUSY, - headers=token, - query_string={"project": project.public_id}, - json={"busy": True}, - ) - assert response.status_code == http.HTTPStatus.OK - assert f"Project {project.public_id} was set to busy." in response.json.get("message") - - -def test_set_not_busy_project_already_not_busy(module_client): - """Set project as busy.""" - for username in ["researchuser", "projectowner", "unituser", "unitadmin"]: - # Get user - user = models.User.query.filter_by(username=username).one_or_none() - assert user - - # Get project - project = user.projects[0] - assert project - - # Set project to not busy - project.busy = False - db.session.commit() - assert not project.busy - - # Authenticate and run - token = tests.UserAuth(tests.USER_CREDENTIALS[username]).token(module_client) - response = module_client.put( - tests.DDSEndpoint.PROJECT_BUSY, - headers=token, - query_string={"project": project.public_id}, - json={"busy": False}, - ) - assert response.status_code == http.HTTPStatus.OK - assert f"The project is already not busy, cannot proceed." in response.json.get("message") - - -def test_set_busy_false(module_client): - """Set project as not busy.""" - for username in ["researchuser", "projectowner", "unituser", "unitadmin"]: - # Get user - user = models.User.query.filter_by(username=username).one_or_none() - assert user - - # Get project - project = user.projects[0] - assert project - - # Set project to busy - project.busy = True - db.session.commit() - assert project.busy - - # Authenticate and run - token = tests.UserAuth(tests.USER_CREDENTIALS[username]).token(module_client) - response = module_client.put( - tests.DDSEndpoint.PROJECT_BUSY, - headers=token, - query_string={"project": project.public_id}, - json={"busy": False}, - ) - assert response.status_code == http.HTTPStatus.OK - assert f"Project {project.public_id} was set to not busy." in response.json.get("message") - - -def test_set_busy_project_already_busy(module_client): - """Set a busy project as busy.""" - for username in ["researchuser", "projectowner", "unituser", "unitadmin"]: - # Get user - user = models.User.query.filter_by(username=username).one_or_none() - assert user - - # Get project - project = user.projects[0] - assert project - - # Set project to busy - project.busy = True - db.session.commit() - assert project.busy - - token = tests.UserAuth(tests.USER_CREDENTIALS[username]).token(module_client) - response = module_client.put( - tests.DDSEndpoint.PROJECT_BUSY, - headers=token, - query_string={"project": project.public_id}, - json={"busy": True}, - ) - assert response.status_code == http.HTTPStatus.OK - assert "The project is already busy, cannot proceed." in response.json.get("message") + for version, error_message in { + token["X-CLI-Version"]: busy_error, + "1.9.9": major_version_error, + "2.1.9": busy_error, + }.items(): + token["X-CLI-Version"] = version + response = module_client.put( + tests.DDSEndpoint.PROJECT_BUSY, + headers=token, + query_string={"project": project.public_id}, + json={"something": "notabool"}, + ) + assert response.status_code == http.HTTPStatus.FORBIDDEN + assert error_message in response.json.get("message") # Project usage diff --git a/tests/api/test_s3.py b/tests/api/test_s3.py index 8a3a855fc..7318eabef 100644 --- a/tests/api/test_s3.py +++ b/tests/api/test_s3.py @@ -25,11 +25,11 @@ def test_get_s3_info_unauthorized(client: flask.testing.FlaskClient) -> None: # Returned info - expected expected_return: typing.Dict = { - "safespring_project": project.responsible_unit.safespring_name, - "url": project.responsible_unit.safespring_endpoint, + "safespring_project": project.responsible_unit.sto2_name, + "url": project.responsible_unit.sto2_endpoint, "keys": { - "access_key": project.responsible_unit.safespring_access, - "secret_key": project.responsible_unit.safespring_secret, + "access_key": project.responsible_unit.sto2_access, + "secret_key": project.responsible_unit.sto2_secret, }, "bucket": project.bucket, } diff --git a/tests/api/test_superadmin_only.py b/tests/api/test_superadmin_only.py index ae60c97f9..416651097 100644 --- a/tests/api/test_superadmin_only.py +++ b/tests/api/test_superadmin_only.py @@ -9,7 +9,8 @@ import unittest from datetime import datetime, timedelta from unittest import mock - +from unittest.mock import patch +from unittest.mock import PropertyMock # Installed import flask @@ -93,7 +94,7 @@ def test_list_units_as_super_admin(client: flask.testing.FlaskClient) -> None: "Public ID": unit.public_id, "External Display Name": unit.external_display_name, "Contact Email": unit.contact_email, - "Safespring Endpoint": unit.safespring_endpoint, + "Safespring Endpoint": unit.sto2_endpoint, "Days In Available": unit.days_in_available, "Days In Expired": unit.days_in_expired, "Size": unit.size, @@ -898,3 +899,79 @@ def add_row_to_reporting_table(time): "TBHours Last Month": reporting_row.tbhours, "TBHours Total": reporting_row.tbhours_since_start, } + + +# UnitUserEmails + + +def test_unituseremails_accessdenied(client: flask.testing.FlaskClient) -> None: + """Only Super Admins can get the emails.""" + no_access_users: typing.Dict = users.copy() + no_access_users.pop("Super Admin") + + for u in no_access_users: + token: typing.Dict = get_token(username=users[u], client=client) + response: werkzeug.test.WrapperTestResponse = client.get( + tests.DDSEndpoint.USER_EMAILS, headers=token + ) + assert response.status_code == http.HTTPStatus.FORBIDDEN + + +def test_unituseremails_no_emails(client: flask.testing.FlaskClient) -> None: + """Empty should be returned if no emails.""" + # No users returned from query + with patch("dds_web.database.models.UnitUser.query") as mock_users: + mock_users.return_value = [] + + # Authenticate + token: typing.Dict = get_token(username=users["Super Admin"], client=client) + + # Call endpoint + response: werkzeug.test.WrapperTestResponse = client.get( + tests.DDSEndpoint.USER_EMAILS, headers=token + ) + assert response.status_code == http.HTTPStatus.OK + + # Verify response + assert response.json and response.json.get("empty") == True + + +def test_unituseremails_ok(client: flask.testing.FlaskClient) -> None: + """Return user emails for unit users only.""" + # Emails that should be returned + unituser_emails = [user.primary_email for user in models.UnitUser.query.all()] + + # Emails that should not be returned + researcher_emails = [user.primary_email for user in models.ResearchUser.query.all()] + superadmin_emails = [user.primary_email for user in models.SuperAdmin.query.all()] + non_primary_emails = [ + email.email for email in models.Email.query.filter_by(primary=False).all() + ] + + # Authenticate + token: typing.Dict = get_token(username=users["Super Admin"], client=client) + + # Call endpoint + response: werkzeug.test.WrapperTestResponse = client.get( + tests.DDSEndpoint.USER_EMAILS, headers=token + ) + assert response.status_code == http.HTTPStatus.OK + + # Verify response ------------------------------- + + # There should be a json response + json_response = response.json + assert json_response + + # There should be emails in response + emails = json_response.get("emails") + assert emails + + # The list of emails should contain all unit user primary emails + assert len(emails) == len(unituser_emails) + for e in unituser_emails: + assert e in emails + + # The list of should not contain any of the other emails + for e in researcher_emails + superadmin_emails + non_primary_emails: + assert e not in emails diff --git a/tests/conftest.py b/tests/conftest.py index 927a84866..a9d05709b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -121,10 +121,10 @@ def demo_data(): contact_email="support@example.com", internal_ref="someunit", quota=10**9, - safespring_endpoint="endpoint", - safespring_name="dds.example.com", - safespring_access="access", - safespring_secret="secret", + sto2_endpoint="endpoint", + sto2_name="dds.example.com", + sto2_access="access", + sto2_secret="secret", ), Unit( name="The league of the extinct gentlemen", @@ -133,10 +133,10 @@ def demo_data(): contact_email="tloteg@mailtrap.io", internal_ref="Unit to test user deletion", quota=10**9, - safespring_endpoint="endpoint", - safespring_name="dds.example.com", - safespring_access="access", - safespring_secret="secret", + sto2_endpoint="endpoint", + sto2_name="dds.example.com", + sto2_access="access", + sto2_secret="secret", ), ] diff --git a/tests/requirements-test.txt b/tests/requirements-test.txt index 4453da88d..4d668030c 100644 --- a/tests/requirements-test.txt +++ b/tests/requirements-test.txt @@ -6,4 +6,4 @@ sqlalchemy-utils==0.38.2 pytest-mock==3.7.0 pyfakefs==4.5.5 requests_cache==0.9.4 -testfixtures==7.0.4 \ No newline at end of file +testfixtures==7.0.4 diff --git a/tests/test_commands.py b/tests/test_commands.py index ebac7aa40..1ad7ea497 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -13,12 +13,14 @@ from datetime import datetime, timedelta import pathlib import csv +from dateutil.relativedelta import relativedelta # Installed import click from pyfakefs.fake_filesystem import FakeFilesystem import flask_mail import freezegun +import rich.prompt # Own from dds_web.commands import ( @@ -32,6 +34,7 @@ quarterly_usage, collect_stats, lost_files_s3_db, + update_unit, ) from dds_web.database import models from dds_web import db @@ -95,10 +98,10 @@ def create_command_options_from_dict(options: typing.Dict) -> typing.List: "external_display_name": "newexternaldisplay", "contact_email": "newcontact@mail.com", "internal_ref": "newinternalref", - "safespring_endpoint": "newsafespringendpoint", - "safespring_name": "newsafespringname", - "safespring_access": "newsafespringaccess", - "safespring_secret": "newsafespringsecret", + "sto2_endpoint": "newsafespringendpoint", + "sto2_name": "newsafespringname", + "sto2_access": "newsafespringaccess", + "sto2_secret": "newsafespringsecret", "days_in_available": 45, "days_in_expired": 15, } @@ -213,6 +216,169 @@ def test_create_new_unit_success(client, runner) -> None: # assert f"Unit '{correct_unit['name']}' created" in result.output +# update_unit + + +def test_update_unit_no_such_unit(client, runner, capfd) -> None: + """Try to update a non existent unit -> Error.""" + # Create command options + command_options: typing.List = [ + "--unit-id", + "unitdoesntexist", + "--sto4-endpoint", + "endpoint_sto4", + "--sto4-name", + "name_sto4", + "--sto4-access", + "access_sto4", + "--sto4-secret", + "secret_sto4", + ] + + # Run command + result: click.testing.Result = runner.invoke(update_unit, command_options) + assert result.exit_code == 0 + assert not result.output + + # Get logging + _, err = capfd.readouterr() + + # Verify message + assert f"There is no unit with the public ID '{command_options[1]}'." in err + + +def test_update_unit_sto4_start_time_exists_mock_prompt_False(client, runner, capfd) -> None: + """Start time already recorded. Answer no to prompt about update anyway. No changes should be made.""" + # Get existing unit + unit: models.Unit = models.Unit.query.first() + unit_id: str = unit.public_id + + # Get sto4 info from start + sto4_endpoint_original = unit.sto4_endpoint + sto4_name_original = unit.sto4_name + sto4_access_original = unit.sto4_access + sto4_secret_original = unit.sto4_secret + sto4_info_original = [ + sto4_endpoint_original, + sto4_name_original, + sto4_access_original, + sto4_secret_original, + ] + assert sto4_info_original == [None, None, None, None] + + # Set sto4 start time + unit.sto4_start_time = current_time() + db.session.commit() + + # Create command options + command_options: typing.List = [ + "--unit-id", + unit_id, + "--sto4-endpoint", + "endpoint_sto4", + "--sto4-name", + "name_sto4", + "--sto4-access", + "access_sto4", + "--sto4-secret", + "secret_sto4", + ] + + # Run command + # Mock rich prompt - False + with patch.object(rich.prompt.Confirm, "ask", return_value=False) as mock_ask: + result: click.testing.Result = runner.invoke(update_unit, command_options) + assert result.exit_code == 0 + assert not result.output + mock_ask.assert_called_once + + # Get logging + _, err = capfd.readouterr() + + # Verify logging + assert f"Cancelling sto4 update for unit '{unit_id}'." in err + assert f"Unit '{unit_id}' updated successfully" not in err + + # Verify no change in unit + unit: models.Unit = models.Unit.query.filter_by(public_id=unit_id).first() + assert unit + assert [ + unit.sto4_endpoint, + unit.sto4_name, + unit.sto4_access, + unit.sto4_secret, + ] == sto4_info_original + + +def test_update_unit_sto4_start_time_exists_mock_prompt_True(client, runner, capfd) -> None: + """Start time already recorded. Answer yes to prompt about update anyway. Changes should be made.""" + # Get existing unit + unit: models.Unit = models.Unit.query.first() + unit_id: str = unit.public_id + + # Get sto4 info from start + sto4_endpoint_original = unit.sto4_endpoint + sto4_name_original = unit.sto4_name + sto4_access_original = unit.sto4_access + sto4_secret_original = unit.sto4_secret + sto4_info_original = [ + sto4_endpoint_original, + sto4_name_original, + sto4_access_original, + sto4_secret_original, + ] + assert sto4_info_original == [None, None, None, None] + + # Set sto4 start time + unit.sto4_start_time = current_time() + db.session.commit() + + # Create command options + command_options: typing.List = [ + "--unit-id", + unit_id, + "--sto4-endpoint", + "endpoint_sto4", + "--sto4-name", + "name_sto4", + "--sto4-access", + "access_sto4", + "--sto4-secret", + "secret_sto4", + ] + + # Run command + # Mock rich prompt - True + with patch.object(rich.prompt.Confirm, "ask", return_value=True) as mock_ask: + result: click.testing.Result = runner.invoke(update_unit, command_options) + assert result.exit_code == 0 + assert not result.output + mock_ask.assert_called_once + + # Get logging + _, err = capfd.readouterr() + + # Verify logging + assert f"Cancelling sto4 update for unit '{unit_id}'." not in err + assert f"Unit '{unit_id}' updated successfully" in err + + # Verify change in unit + unit: models.Unit = models.Unit.query.filter_by(public_id=unit_id).first() + assert unit + assert [ + unit.sto4_endpoint, + unit.sto4_name, + unit.sto4_access, + unit.sto4_secret, + ] != sto4_info_original + assert [unit.sto4_endpoint, unit.sto4_name, unit.sto4_access, unit.sto4_secret] == [ + command_options[3], + command_options[5], + command_options[7], + command_options[9], + ] + + # update_uploaded_file_with_log @@ -290,8 +456,106 @@ def test_list_lost_files_no_lost_files_in_project(client, cli_runner, boto3_sess """flask lost-files ls: project specified, no lost files.""" # Get project project = models.Project.query.first() + public_id = project.public_id assert project + # Use sto2 -- no sto4_endpoint_added date --------------------------------------------- + project_unit = project.responsible_unit + assert not project_unit.sto4_start_time + + # Mock project.files -- no files + with patch("dds_web.database.models.Project.files", new_callable=PropertyMock) as mock_files: + mock_files.return_value = [] + + # Run command + result: click.testing.Result = cli_runner.invoke( + lost_files_s3_db, ["ls", "--project-id", public_id] + ) + assert result.exit_code == 0 + + # Verify output -- no lost files + _, err = capfd.readouterr() + assert f"Safespring location for project '{public_id}': sto2" in err + assert f"Searching for lost files in project '{public_id}'." in err + assert f"No lost files in project '{public_id}'" in err + # --------------------------------------------------------------------------------------- + + # Use sto2 -- sto4_endpoint_added but project created before ---------------------------- + project_unit.sto4_start_time = current_time() + db.session.commit() + + assert project_unit.sto4_start_time + assert project.date_created < project_unit.sto4_start_time + + # Mock project.files -- no files + with patch("dds_web.database.models.Project.files", new_callable=PropertyMock) as mock_files: + mock_files.return_value = [] + + # Run command + result: click.testing.Result = cli_runner.invoke( + lost_files_s3_db, ["ls", "--project-id", project.public_id] + ) + assert result.exit_code == 0 + + # Verify output -- no lost files + _, err = capfd.readouterr() + assert f"Safespring location for project '{project.public_id}': sto2" in err + assert f"Searching for lost files in project '{project.public_id}'." in err + assert f"No lost files in project '{project.public_id}'" in err + # --------------------------------------------------------------------------------------- + + # Use sto2 -- sto4_endpoint_added, project created after, but not all info is available -- + project_unit.sto4_start_time = current_time() - relativedelta(hours=1) + db.session.commit() + + assert project_unit.sto4_start_time + assert project.date_created > project_unit.sto4_start_time + assert not all( + [ + project_unit.sto4_endpoint, + project_unit.sto4_name, + project_unit.sto4_access, + project_unit.sto4_secret, + ] + ) + + # Mock project.files -- no files + with patch("dds_web.database.models.Project.files", new_callable=PropertyMock) as mock_files: + mock_files.return_value = [] + + # Run command + result: click.testing.Result = cli_runner.invoke( + lost_files_s3_db, ["ls", "--project-id", project.public_id] + ) + assert result.exit_code == 1 + + # Verify output -- no lost files + _, err = capfd.readouterr() + assert f"One or more sto4 variables are missing for unit {project_unit.public_id}." in err + assert f"Safespring location for project '{project.public_id}': sto2" not in err + assert f"Searching for lost files in project '{project.public_id}'." in err + assert f"No lost files in project '{project.public_id}'" not in err + # --------------------------------------------------------------------------------------- + + # Use sto4 -- sto4_endpoint_added, project created after, and all info is available ----- + project_unit.sto4_start_time = current_time() - relativedelta(hours=1) + project_unit.sto4_endpoint = "endpoint" + project_unit.sto4_name = "name" + project_unit.sto4_access = "access" + project_unit.sto4_secret = "secret" + db.session.commit() + + assert project_unit.sto4_start_time + assert project.date_created > project_unit.sto4_start_time + assert all( + [ + project_unit.sto4_endpoint, + project_unit.sto4_name, + project_unit.sto4_access, + project_unit.sto4_secret, + ] + ) + # Mock project.files -- no files with patch("dds_web.database.models.Project.files", new_callable=PropertyMock) as mock_files: mock_files.return_value = [] @@ -304,9 +568,13 @@ def test_list_lost_files_no_lost_files_in_project(client, cli_runner, boto3_sess # Verify output -- no lost files _, err = capfd.readouterr() + assert f"Safespring location for project '{project.public_id}': sto2" not in err + assert f"Safespring location for project '{project.public_id}': sto4" in err assert f"Searching for lost files in project '{project.public_id}'." in err assert f"No lost files in project '{project.public_id}'" in err + # --------------------------------------------------------------------------------------- + def test_list_lost_files_missing_in_s3_in_project(client, cli_runner, boto3_session, capfd): """flask lost-files ls: project specified, lost files in s3.""" @@ -333,11 +601,101 @@ def test_list_lost_files_missing_in_s3_in_project(client, cli_runner, boto3_sess not in err ) - assert f"Lost files in project: {project.public_id}\t\tIn DB but not S3: {len(len(project.files))}\tIn S3 but not DB: 0\n" + assert f"Lost files in project: {project.public_id}\t\tIn DB but not S3: {len(project.files)}\tIn S3 but not DB: 0\n" def test_list_lost_files_no_lost_files_total(client, cli_runner, boto3_session, capfd): """flask lost-files ls: no project specified, no lost files.""" + # Use sto2 -- no sto4_endpoint_added date --------------------------------------------- + for u in models.Unit.query.all(): + assert not u.sto4_start_time + + # Mock project.files -- no files + with patch("dds_web.database.models.Project.files", new_callable=PropertyMock) as mock_files: + mock_files.return_value = [] + + # Run command + result: click.testing.Result = cli_runner.invoke(lost_files_s3_db, ["ls"]) + assert result.exit_code == 0 + + # Verify output -- no lost files + _, err = capfd.readouterr() + assert "Searching for lost files in project" not in err + assert "No project specified, searching for lost files in all units." in err + for u in models.Unit.query.all(): + assert f"Listing lost files in unit: {u.public_id}" in err + for p in u.projects: + assert f"Safespring location for project '{p.public_id}': sto2" in err + assert f"Safespring location for project '{p.public_id}': sto4" not in err + assert f"No lost files for unit '{u.public_id}'" in err + # --------------------------------------------------------------------------------------- + + # Use sto2 -- sto4_endpoint_added but project created before ---------------------------- + for u in models.Unit.query.all(): + u.sto4_start_time = current_time() + for p in u.projects: + assert p.date_created < u.sto4_start_time + db.session.commit() + + # Mock project.files -- no files + with patch("dds_web.database.models.Project.files", new_callable=PropertyMock) as mock_files: + mock_files.return_value = [] + + # Run command + result: click.testing.Result = cli_runner.invoke(lost_files_s3_db, ["ls"]) + assert result.exit_code == 0 + + # Verify output -- no lost files + _, err = capfd.readouterr() + assert "Searching for lost files in project" not in err + assert "No project specified, searching for lost files in all units." in err + for u in models.Unit.query.all(): + assert f"Listing lost files in unit: {u.public_id}" in err + for p in u.projects: + assert f"Safespring location for project '{p.public_id}': sto2" in err + assert f"Safespring location for project '{p.public_id}': sto4" not in err + assert f"No lost files for unit '{u.public_id}'" in err + # --------------------------------------------------------------------------------------- + + # Use sto2 -- sto4_endpoint_added, project created after, but not all info is available -- + for u in models.Unit.query.all(): + u.sto4_start_time = current_time() - relativedelta(hours=1) + for p in u.projects: + assert p.date_created > u.sto4_start_time + db.session.commit() + + # Mock project.files -- no files + with patch("dds_web.database.models.Project.files", new_callable=PropertyMock) as mock_files: + mock_files.return_value = [] + + # Run command + result: click.testing.Result = cli_runner.invoke(lost_files_s3_db, ["ls"]) + assert result.exit_code == 0 + + # Verify output -- no lost files + _, err = capfd.readouterr() + assert "Searching for lost files in project" not in err + assert "No project specified, searching for lost files in all units." in err + for u in models.Unit.query.all(): + assert f"Listing lost files in unit: {u.public_id}" in err + for p in u.projects: + assert f"Safespring location for project '{p.public_id}': sto2" not in err + assert f"Safespring location for project '{p.public_id}': sto4" not in err + assert f"No lost files for unit '{u.public_id}'" in err + # --------------------------------------------------------------------------------------- + + # Use sto4 -- sto4_endpoint_added, project created after, and all info is available ----- + for u in models.Unit.query.all(): + u.sto4_start_time = current_time() - relativedelta(hours=1) + for p in u.projects: + assert p.date_created > u.sto4_start_time + u.sto4_endpoint = "endpoint" + u.sto4_name = "name" + u.sto4_access = "access" + u.sto4_secret = "secret" + + db.session.commit() + # Mock project.files -- no files with patch("dds_web.database.models.Project.files", new_callable=PropertyMock) as mock_files: mock_files.return_value = [] @@ -352,7 +710,51 @@ def test_list_lost_files_no_lost_files_total(client, cli_runner, boto3_session, assert "No project specified, searching for lost files in all units." in err for u in models.Unit.query.all(): assert f"Listing lost files in unit: {u.public_id}" in err + for p in u.projects: + assert f"Safespring location for project '{p.public_id}': sto2" not in err + assert f"Safespring location for project '{p.public_id}': sto4" in err assert f"No lost files for unit '{u.public_id}'" in err + # --------------------------------------------------------------------------------------- + + # Use sto4 for all but one -------------------------------------------------------------- + for u in models.Unit.query.all(): + u.sto4_start_time = current_time() - relativedelta(hours=1) + for p in u.projects: + assert p.date_created > u.sto4_start_time + u.sto4_endpoint = "endpoint" + u.sto4_name = "name" + u.sto4_access = "access" + u.sto4_secret = "secret" + + unit_no_sto4_endpoint = models.Unit.query.first() + unit_no_sto4_endpoint_id = unit_no_sto4_endpoint.public_id + unit_no_sto4_endpoint.sto4_endpoint = None + db.session.commit() + + # Mock project.files -- no files + with patch("dds_web.database.models.Project.files", new_callable=PropertyMock) as mock_files: + mock_files.return_value = [] + + # Run command + result: click.testing.Result = cli_runner.invoke(lost_files_s3_db, ["ls"]) + assert result.exit_code == 0 + + # Verify output -- no lost files + _, err = capfd.readouterr() + assert "Searching for lost files in project" not in err + assert "No project specified, searching for lost files in all units." in err + for u in models.Unit.query.all(): + assert f"Listing lost files in unit: {u.public_id}" in err + for p in u.projects: + if u.public_id == unit_no_sto4_endpoint_id: + assert f"One or more sto4 variables are missing for unit {u.public_id}." in err + assert f"Safespring location for project '{p.public_id}': sto2" not in err + assert f"Safespring location for project '{p.public_id}': sto4" not in err + else: + assert f"Safespring location for project '{p.public_id}': sto2" not in err + assert f"Safespring location for project '{p.public_id}': sto4" in err + assert f"No lost files for unit '{u.public_id}'" in err + # --------------------------------------------------------------------------------------- def test_list_lost_files_missing_in_s3_in_project(client, cli_runner, boto3_session, capfd): @@ -440,6 +842,91 @@ def test_add_missing_bucket_not_missing(client, cli_runner, boto3_session, capfd project: models.Project = models.Project.query.first() assert project + # Use sto2 -- sto4_start_time not set -------------------------------------------- + assert not project.responsible_unit.sto4_start_time + + # Run command + result: click.testing.Result = cli_runner.invoke( + lost_files_s3_db, ["add-missing-bucket", "--project-id", project.public_id] + ) + assert result.exit_code == 0 + + # Verify output + _, err = capfd.readouterr() + assert ( + f"Bucket for project '{project.public_id}' found; Bucket not missing. Will not create bucket." + in err + ) + assert f"Safespring location for project '{project.public_id}': sto2" in err + # --------------------------------------------------------------------------------- + + # Use sto2 -- sto4_start_time set, but project created before --------------------- + # Set start time + project.responsible_unit.sto4_start_time = current_time() + db.session.commit() + + # Verify + assert project.responsible_unit.sto4_start_time + assert project.date_created < project.responsible_unit.sto4_start_time + + # Run command + result: click.testing.Result = cli_runner.invoke( + lost_files_s3_db, ["add-missing-bucket", "--project-id", project.public_id] + ) + assert result.exit_code == 0 + + # Verify output + _, err = capfd.readouterr() + assert ( + f"Bucket for project '{project.public_id}' found; Bucket not missing. Will not create bucket." + in err + ) + assert f"Safespring location for project '{project.public_id}': sto2" in err + # --------------------------------------------------------------------------------- + + # Use sto2 -- sto4_start_time set, project created after, but not all vars set ---- + # Set start time + project.responsible_unit.sto4_start_time = current_time() - relativedelta(hours=1) + db.session.commit() + + # Verify + unit = project.responsible_unit + assert unit.sto4_start_time + assert project.date_created > unit.sto4_start_time + assert not all([unit.sto4_endpoint, unit.sto4_name, unit.sto4_access, unit.sto4_secret]) + + # Run command + result: click.testing.Result = cli_runner.invoke( + lost_files_s3_db, ["add-missing-bucket", "--project-id", project.public_id] + ) + assert result.exit_code == 1 + + # Verify output + _, err = capfd.readouterr() + assert ( + f"Bucket for project '{project.public_id}' found; Bucket not missing. Will not create bucket." + not in err + ) + assert f"Safespring location for project '{project.public_id}': sto2" not in err + assert f"Safespring location for project '{project.public_id}': sto4" not in err + assert f"One or more sto4 variables are missing for unit {unit.public_id}." in err + + # --------------------------------------------------------------------------------- + + # Use sto4 -- sto4_start_time set, project created after and all vars set + # Set start time + project.responsible_unit.sto4_endpoint = "endpoint" + project.responsible_unit.sto4_name = "name" + project.responsible_unit.sto4_access = "access" + project.responsible_unit.sto4_secret = "secret" + db.session.commit() + + # Verify + unit = project.responsible_unit + assert unit.sto4_start_time + assert project.date_created > unit.sto4_start_time + assert all([unit.sto4_endpoint, unit.sto4_name, unit.sto4_access, unit.sto4_secret]) + # Run command result: click.testing.Result = cli_runner.invoke( lost_files_s3_db, ["add-missing-bucket", "--project-id", project.public_id] @@ -452,6 +939,10 @@ def test_add_missing_bucket_not_missing(client, cli_runner, boto3_session, capfd f"Bucket for project '{project.public_id}' found; Bucket not missing. Will not create bucket." in err ) + assert f"Safespring location for project '{project.public_id}': sto2" not in err + assert f"Safespring location for project '{project.public_id}': sto4" in err + assert f"One or more sto4 variables are missing for unit {unit.public_id}." not in err + # --------------------------------------------------------------------------------- # lost_files_s3_db -- delete_lost_files @@ -492,6 +983,9 @@ def test_delete_lost_files_deleted(client, cli_runner, boto3_session, capfd): num_project_files = len(project.files) assert num_project_files > 0 + # Use sto2 -- sto4_start_time not set ----------------------------------- + assert not project.responsible_unit.sto4_start_time + # Run command result: click.testing.Result = cli_runner.invoke( lost_files_s3_db, ["delete", "--project-id", project.public_id] @@ -502,6 +996,85 @@ def test_delete_lost_files_deleted(client, cli_runner, boto3_session, capfd): _, err = capfd.readouterr() assert f"Files deleted from S3: 0" in err assert f"Files deleted from DB: {num_project_files}" in err + assert f"Safespring location for project '{project.public_id}': sto2" in err + # ------------------------------------------------------------------------ + + # Use sto2 -- start_time set, but project created before ----------------- + # Set start_time + project.responsible_unit.sto4_start_time = current_time() + db.session.commit() + + # Verify + unit = project.responsible_unit + assert unit.sto4_start_time + assert project.date_created < unit.sto4_start_time + + # Run command + result: click.testing.Result = cli_runner.invoke( + lost_files_s3_db, ["delete", "--project-id", project.public_id] + ) + assert result.exit_code == 0 + + # Verify output - files deleted + _, err = capfd.readouterr() + assert f"Files deleted from S3: 0" in err + assert f"Files deleted from DB: 0" in err # Already deleted + assert f"Safespring location for project '{project.public_id}': sto2" in err + # ------------------------------------------------------------------------ + + # Use sto2 -- start_time set, project created after, but all vars not set + # Set start_time + project.responsible_unit.sto4_start_time = current_time() - relativedelta(hours=1) + db.session.commit() + + # Verify + unit = project.responsible_unit + assert unit.sto4_start_time + assert project.date_created > unit.sto4_start_time + assert not all([unit.sto4_endpoint, unit.sto4_name, unit.sto4_access, unit.sto4_secret]) + + # Run command + result: click.testing.Result = cli_runner.invoke( + lost_files_s3_db, ["delete", "--project-id", project.public_id] + ) + assert result.exit_code == 1 + + # Verify output - files deleted + _, err = capfd.readouterr() + assert f"Files deleted from S3: 0" not in err + assert f"Files deleted from DB: 0" not in err + assert f"Safespring location for project '{project.public_id}': sto2" not in err + assert f"One or more sto4 variables are missing for unit {unit.public_id}." in err + # ------------------------------------------------------------------------ + + # Use sto4 - start_time set, project created after and all vars set ------ + # Set start_time + project.responsible_unit.sto4_endpoint = "endpoint" + project.responsible_unit.sto4_name = "name" + project.responsible_unit.sto4_access = "access" + project.responsible_unit.sto4_secret = "secret" + db.session.commit() + + # Verify + unit = project.responsible_unit + assert unit.sto4_start_time + assert project.date_created > unit.sto4_start_time + assert all([unit.sto4_endpoint, unit.sto4_name, unit.sto4_access, unit.sto4_secret]) + + # Run command + result: click.testing.Result = cli_runner.invoke( + lost_files_s3_db, ["delete", "--project-id", project.public_id] + ) + assert result.exit_code == 0 + + # Verify output - files deleted + _, err = capfd.readouterr() + assert f"Files deleted from S3: 0" in err + assert f"Files deleted from DB: 0" in err # Aldready deleted + assert f"Safespring location for project '{project.public_id}': sto2" not in err + assert f"Safespring location for project '{project.public_id}': sto4" in err + assert f"One or more sto4 variables are missing for unit {unit.public_id}." not in err + # ------------------------------------------------------------------------ def test_delete_lost_files_sqlalchemyerror(client, cli_runner, boto3_session, capfd): diff --git a/tests/test_init.py b/tests/test_init.py index b6ff8ae69..76be58dcd 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -308,14 +308,6 @@ def test_block_if_maintenance_active_none_approved_users(client: flask.testing.F assert response.status_code == http.HTTPStatus.SERVICE_UNAVAILABLE assert response.json and response.json.get("message") == "Maintenance of DDS is ongoing." - # ProjectBusy - "/proj/busy" - response = client.put( - DDSEndpoint.PROJECT_BUSY, - headers=token, - ) - assert response.status_code == http.HTTPStatus.SERVICE_UNAVAILABLE - assert response.json and response.json.get("message") == "Maintenance of DDS is ongoing." - # ProjectInfo - "/proj/info" response = client.get( DDSEndpoint.PROJECT_INFO, @@ -631,13 +623,6 @@ def test_block_if_maintenance_active_superadmin_ok(client: flask.testing.FlaskCl ) assert response.status_code == http.HTTPStatus.FORBIDDEN - # ProjectBusy - "/proj/busy" - response = client.put( - DDSEndpoint.PROJECT_BUSY, - headers=token, - ) - assert response.status_code == http.HTTPStatus.FORBIDDEN - # ProjectInfo - "/proj/info" response = client.get( DDSEndpoint.PROJECT_INFO, diff --git a/tests/test_project_creation.py b/tests/test_project_creation.py index ebee642d8..cef08f656 100644 --- a/tests/test_project_creation.py +++ b/tests/test_project_creation.py @@ -12,6 +12,7 @@ # Own from dds_web import db from dds_web.database import models +from dds_web.utils import current_time import tests @@ -745,3 +746,91 @@ def test_create_project_invalid_characters(client, boto3_session): .first() ) assert not new_project + + +def test_create_project_sto2(client, boto3_session, capfd): + """Create a project in sto2.""" + # Make sure there are 3 unit admins + create_unit_admins(num_admins=2) + current_unit_admins = models.UnitUser.query.filter_by(unit_id=1, is_admin=True).count() + assert current_unit_admins == 3 + + # Use sto2 -- all sto4 vars not set --------------------- + unit: models.Unit = models.Unit.query.filter_by(id=1).first() + assert unit + assert not all( + [ + unit.sto4_start_time, + unit.sto4_endpoint, + unit.sto4_name, + unit.sto4_access, + unit.sto4_secret, + ] + ) + + # Create project + response = client.post( + tests.DDSEndpoint.PROJECT_CREATE, + headers=tests.UserAuth(tests.USER_CREDENTIALS["unituser"]).token(client), + json=proj_data, + ) + assert response.status_code == http.HTTPStatus.OK + + # Verify that new project is created + new_project = ( + db.session.query(models.Project) + .filter(models.Project.description == proj_data["description"]) + .first() + ) + assert new_project + + # Verify logging + _, err = capfd.readouterr() + assert f"Safespring location for project '{new_project.public_id}': sto2" in err + + +def test_create_project_sto4(client, boto3_session, capfd): + """Create a project in sto4.""" + # Make sure there are 3 unit admins + create_unit_admins(num_admins=2) + current_unit_admins = models.UnitUser.query.filter_by(unit_id=1, is_admin=True).count() + assert current_unit_admins == 3 + + # Use sto4 + unit: models.Unit = models.Unit.query.filter_by(id=1).first() + assert unit + unit.sto4_start_time = current_time() + unit.sto4_endpoint = "endpoint" + unit.sto4_name = "name" + unit.sto4_access = "access" + unit.sto4_secret = "secret" + db.session.commit() + assert all( + [ + unit.sto4_start_time, + unit.sto4_endpoint, + unit.sto4_name, + unit.sto4_access, + unit.sto4_secret, + ] + ) + + # Create project + response = client.post( + tests.DDSEndpoint.PROJECT_CREATE, + headers=tests.UserAuth(tests.USER_CREDENTIALS["unituser"]).token(client), + json=proj_data, + ) + assert response.status_code == http.HTTPStatus.OK + + # Verify that new project is created + new_project = ( + db.session.query(models.Project) + .filter(models.Project.description == proj_data["description"]) + .first() + ) + assert new_project + + # Verify logging + _, err = capfd.readouterr() + assert f"Safespring location for project '{new_project.public_id}': sto4" in err diff --git a/tests/test_utils.py b/tests/test_utils.py index a999285ea..bc6864db7 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1433,3 +1433,80 @@ def test_list_lost_files_in_project_overlap( f"Entry {x.key} ({project.public_id}, {project.responsible_unit}) not found in database (but found in s3)" not in err ) + + +# use_sto4 + + +def test_use_sto4_return_false(client: flask.testing.FlaskClient): + """Test that use_sto4 returns False.""" + # Imports + from dds_web.utils import use_sto4, current_time + from dds_web.errors import S3InfoNotFoundError + + # Return False if sto4_start_time not set -------------------------- + # Get project + project: models.Project = models.Project.query.first() + + # Get unit + unit: models.Unit = project.responsible_unit + assert not unit.sto4_start_time + + # Run function + result: bool = use_sto4(unit_object=unit, project_object=project) + assert result is False + # ------------------------------------------------------------------- + + # Return False if sto4_start_time is set, but project created before + # Set sto4_start_time + unit.sto4_start_time = current_time() + db.session.commit() + + # Verify + assert project.date_created < unit.sto4_start_time + + # Run function + result: bool = use_sto4(unit_object=unit, project_object=project) + assert result is False + # ------------------------------------------------------------------- + + # Return False if sto4_start_time is set, project created after, + # but not all variables are set + unit.sto4_start_time = current_time() - relativedelta(hours=1) + db.session.commit() + + # Verify + assert project.date_created > unit.sto4_start_time + + # Run function + with pytest.raises(S3InfoNotFoundError) as err: + result: bool = use_sto4(unit_object=unit, project_object=project) + assert result is False + assert f"One or more sto4 variables are missing for unit {unit.public_id}." in str(err.value) + # ------------------------------------------------------------------- + + +def test_use_sto4_return_true(client: flask.testing.FlaskClient): + """Test that use_sto4 returns False.""" + # Imports + from dds_web.utils import use_sto4, current_time + + # Get project + project: models.Project = models.Project.query.first() + + # Unit + unit: models.Unit = project.responsible_unit + + # Return True if sto4_start_time is set, + # project is created after sto4_start_time was added, + # and all variables are set + unit.sto4_start_time = current_time() - relativedelta(hours=1) + unit.sto4_endpoint = "endpoint" + unit.sto4_name = "name" + unit.sto4_access = "access" + unit.sto4_secret = "secret" + db.session.commit() + + # Run function + result: bool = use_sto4(unit_object=unit, project_object=project) + assert result is True diff --git a/tests/test_version.py b/tests/test_version.py index 2f3624c1a..6e9338b1b 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -2,4 +2,4 @@ def test_version(): - assert version.__version__ == "2.4.0" + assert version.__version__ == "2.5.0"