diff --git a/.github/ISSUE_TEMPLATE/release_cleanup.md b/.github/ISSUE_TEMPLATE/release_cleanup.md new file mode 100644 index 00000000..6f64279d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/release_cleanup.md @@ -0,0 +1,31 @@ +--- +name: Release Cleanup +about: Minor tasks and checklist for maintainer to cleanup and prepare a release +title: 'Cleanup and prepare RELEASE_VERSION' +labels: documentation, internal +assignees: 'mikkonie' + +--- + +## Minor Tasks + +TBA + +## Issues to Add in CHANGELOG + +TBA + +## Release Checklist + +- [ ] Review code style and cleanup +- [ ] Review and update docs entries +- [ ] Update `SODAR_API_DEFAULT_VERSION` and `SODAR_API_ALLOWED_VERSIONS` +- [ ] Update Vue app version with `npm version` +- [ ] Update version in CHANGELOG and SODAR Release Notes doc +- [ ] Update version in docs conf.py +- [ ] Ensure both SODAR and SODAR Core API versioning is correct in API docs +- [ ] Ensure docs can be built without errors + +## Notes + +N/A diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 65392dc6..a79b36fe 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -67,9 +67,9 @@ jobs: pip install -r requirements/local.txt pip install -r requirements/test.txt - name: Setup Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v3 with: - node-version: 16.x + node-version: 18.x - name: Build and run Vue app run: | npm ci --prefix samplesheets/vueapp diff --git a/AUTHORS.rst b/AUTHORS.rst index f2d40c14..212da91b 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -6,5 +6,6 @@ Credits * Oliver Stolpe * Dzmitry Hramyka * Mathias Kuhring +* Thomas Sell * Franziska Schumann * Tim Garrels diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9a008de4..f90d6837 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,124 @@ Changelog for the SODAR project. Loosely follows the `Keep a Changelog `_ guidelines. +v0.14.0 (2023-09-27) +==================== + +Added +----- + +- **General** + - Release cleanup issue template (#1797) + - LDAP settings for TLS and user filter (#1803) +- **Irodsbackend** + - ``get_trash_path()`` helper (#1658) + - iRODS trash statistics for siteinfo (#1658) +- **Irodsinfo** + - ``IrodsEnvRetrieveAPIView`` for retrieving iRODS environment (#1685) +- **Landingzones** + - Landing zone updating (#1267) + - "Nothing to do" check for landing zone validation and moving (#339) + - iRODS path clipboard copying button in iRODS collection list modal (#1282) + - ``constants`` module for zone constants (#1398) + - Assay link from zone assay icon (#1747) + - Missing permission tests (#1739) +- **Samplesheets** + - User setting for study and assay table height (#1283) + - Study table cache disabling (#1639) + - ``SHEETS_ENABLE_STUDY_TABLE_CACHE`` setting (#1639) + - ``cytof`` assay plugin (#1642) + - New ISA-Tab templates from ``cubi-isa-templates`` (#1697, #1757) + - General iRODS access ticket management for assay collections (#804, #1717) + - Disabled row delete button tooltips (#1731) + - ``IrodsDataRequest`` REST API views (#1588, #1706, #1734, #1735, #1736) + - Davrods links in iRODS delete request list (#1339) + - Batch accepting and rejecting for iRODS delete requests (#1340, #1751) + - Cookiecutter prompt support in sheet templates (#1726) + - "Create" tag for sheet versions (#1296) + - Template tag tests (#1723) + - iRODS file count in sheet overview tab (#1295) + - ``get_url()`` helpers for ``Investigation``, ``Study`` and ``Assay`` models (#1748) + - ``normalizesheets`` management command for sheet cleanup (#1661) + - Boolean field support in sheet templates (#1757) + - iRODS access ticket REST API views (#1707, #1800, #1801) +- **Taskflowbackend** + - ``BatchCalculateChecksumTask`` iRODS task (#1634) + - Automated generation of missing checksums in ``landing_zone_move`` (#1634, #1767) + - Cleanup of trash collections in testing (#1658) + - ``TaskflowPermissionTestBase`` base test class (#1718) + - Taskflow session timeout management (#1768) + - ``TASKFLOW_IRODS_CONN_TIMEOUT`` Django setting (#1768) + +Changed +------- + +- **General** + - Upgrade to django-sodar-core v0.13.2 (#1617, #1720, #1775, #1792) + - Upgrade to cubi-isa-templates v0.1.0 (#1757) + - Upgrade to python-irodsclient v1.1.8 (#1538) + - Upgrade Python dependencies (#1620) + - Upgrade Vue app dependencies (#1620) + - Upgrade to nodejs v18 (#1765, #1766) + - Update deprecated Nodejs install method in Docker and dev (#1769) + - Timeline event names and descriptions if called from syncmodifyapi (#1761) + - Update tour help (#1583) + - Enable setting ``ADMINS`` via environment variable (#1796) + - Update ``ADMINS`` default value (#1796) +- **Irodsadmin** + - Output ``irodsorphans`` results during execution (#1319) + - Order ``irodsorphans`` results by project (#1741) +- **Landingzones** + - Move iRODS object helpers to ``TaskflowTestMixin`` (#1699) + - Enable superuser landing zone controls for locked zones (#1607) + - Add ``DELETING`` to locked states in UI (#1657) + - Query for landing zone status in batch (#1684, #1752) + - Create expected collections if zone sync is called from syncmodifyapi (#1761) + - Define and use zone status constants (#1398) +- **Samplesheets** + - Sample sheet table viewport background color (#1692) + - Contract sheet table height to fit content (#1693) + - Hide internal fields from ISA-Tab templates (#1698, #1733) + - Refactor ``IrodsDataRequest`` model and tests (#1706) + - Update ``get_sheets_url()`` helper to only handle ``Project`` objects (#1771) + - Display full path under assay for iRODS data requests in UI (#1749) + - Return full path under assay from ``IrodsDataRequest.get_short_path()`` (#1749) + - Make ``request`` optional in ``SheetVersionMixin.save_version()`` +- **Taskflowbackend** + - Move iRODS object helpers from ``LandingZoneTaskflowMixin`` (#1699) + - Move iRODS test cleanup to ``TaskflowTestMixin.clear_irods_test_data()`` (#1722) + - Refactor base test classes (#1722) + +Fixed +----- + +- **General** + - Local Chromedriver install failure (#1753, bihealth/sodar-core#1255) +- **Ontologyaccess** + - Batch import tests failing from forbidden obolibrary access (#1694) +- **Samplesheets** + - ``perform_project_sync()`` crash with no iRODS collections created (#1687) + - iRODS delete request modification UI view permission checks failing for non-creator contributors (#1737) + - Investigation object ref broken in timeline ``sheet_replace`` events (#1774) + - External links column width estimation crash in table rendering (#1787) + - Comment field editing with semicolon in data (#1790) + - Ontology URLs not encoded if passed as query string in wrapper template (#1762) + +Removed +------- + +- **Landingzones** + - Unused ``data_tables`` references from templates (#1710) + - ``get_zone_samples_url()`` template tag (#1748) +- **Samplesheets** + - ``SHEETS_TABLE_HEIGHT`` Django setting (#1283) + - Duplicate ``IrodsAccessTicketMixin`` from ``test_views_ajax`` (#1703) + - ``IRODS_DATA_REQUEST_STATUS_CHOICES`` constant (#1706) + - ``HIDDEN_SHEET_TEMPLATE_FIELDS`` constant (#1733) + - ``sheet_export*`` timeline events (#1773) + - ``SHEETS_ENABLED_TEMPLATES`` Django setting (#1756) + - ``tumor_normal_triplets`` ISA-Tab template (#1757) + + v0.13.4 (2023-05-15) ==================== diff --git a/config/settings/base.py b/config/settings/base.py index ebb1c0cb..ac8738fa 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -53,6 +53,7 @@ ] THIRD_PARTY_APPS = [ 'crispy_forms', # Form layouts + 'crispy_bootstrap4', # Bootstrap4 theme for Crispy 'rules.apps.AutodiscoverRulesConfig', # Django rules engine 'djangoplugins', # Django plugins 'pagedown', # For markdown @@ -100,6 +101,7 @@ 'samplesheets.assayapps.meta_ms.apps.MetaMsConfig', 'samplesheets.assayapps.microarray.apps.MicroarrayConfig', 'samplesheets.assayapps.pep_ms.apps.PepMsConfig', + 'samplesheets.assayapps.cytof.apps.CytofConfig', # Landingzones config sub-apps 'landingzones.configapps.bih_proteomics_smb.apps.BihProteomicsSmbConfig', # Admin apps @@ -144,8 +146,8 @@ # MANAGER CONFIGURATION # ------------------------------------------------------------------------------ -ADMINS = [("""Mikko Nieminen""", 'mikko.nieminen@bih-charite.de')] - +# Provide ADMINS as: Name:email,Name:email +ADMINS = [x.split(':') for x in env.list('ADMINS', default=[])] # See: https://docs.djangoproject.com/en/3.2/ref/settings/#managers MANAGERS = ADMINS @@ -212,6 +214,7 @@ 'projectroles.context_processors.urls_processor', 'projectroles.context_processors.site_app_processor', 'projectroles.context_processors.app_alerts_processor', + 'projectroles.context_processors.sidebar_processor', ], }, } @@ -355,7 +358,6 @@ # Default values LDAP_DEFAULT_CONN_OPTIONS = {ldap.OPT_REFERRALS: 0} - LDAP_DEFAULT_FILTERSTR = '(sAMAccountName=%(user)s)' LDAP_DEFAULT_ATTR_MAP = { 'first_name': 'givenName', 'last_name': 'sn', @@ -366,12 +368,22 @@ AUTH_LDAP_SERVER_URI = env.str('AUTH_LDAP_SERVER_URI', None) AUTH_LDAP_BIND_DN = env.str('AUTH_LDAP_BIND_DN', None) AUTH_LDAP_BIND_PASSWORD = env.str('AUTH_LDAP_BIND_PASSWORD', None) - AUTH_LDAP_CONNECTION_OPTIONS = LDAP_DEFAULT_CONN_OPTIONS + AUTH_LDAP_START_TLS = env.str('AUTH_LDAP_START_TLS', False) + AUTH_LDAP_CA_CERT_FILE = env.str('AUTH_LDAP_CA_CERT_FILE', None) + AUTH_LDAP_CONNECTION_OPTIONS = {**LDAP_DEFAULT_CONN_OPTIONS} + if AUTH_LDAP_CA_CERT_FILE is not None: + AUTH_LDAP_CONNECTION_OPTIONS[ + ldap.OPT_X_TLS_CACERTFILE + ] = AUTH_LDAP_CA_CERT_FILE + AUTH_LDAP_CONNECTION_OPTIONS[ldap.OPT_X_TLS_NEWCTX] = 0 + AUTH_LDAP_USER_FILTER = env.str( + 'AUTH_LDAP_USER_FILTER', '(sAMAccountName=%(user)s)' + ) AUTH_LDAP_USER_SEARCH = LDAPSearch( env.str('AUTH_LDAP_USER_SEARCH_BASE', None), ldap.SCOPE_SUBTREE, - LDAP_DEFAULT_FILTERSTR, + AUTH_LDAP_USER_FILTER, ) AUTH_LDAP_USER_ATTR_MAP = LDAP_DEFAULT_ATTR_MAP AUTH_LDAP_USERNAME_DOMAIN = env.str('AUTH_LDAP_USERNAME_DOMAIN', None) @@ -391,12 +403,22 @@ AUTH_LDAP2_SERVER_URI = env.str('AUTH_LDAP2_SERVER_URI', None) AUTH_LDAP2_BIND_DN = env.str('AUTH_LDAP2_BIND_DN', None) AUTH_LDAP2_BIND_PASSWORD = env.str('AUTH_LDAP2_BIND_PASSWORD', None) - AUTH_LDAP2_CONNECTION_OPTIONS = LDAP_DEFAULT_CONN_OPTIONS + AUTH_LDAP2_START_TLS = env.str('AUTH_LDAP2_START_TLS', False) + AUTH_LDAP2_CA_CERT_FILE = env.str('AUTH_LDAP2_CA_CERT_FILE', None) + AUTH_LDAP2_CONNECTION_OPTIONS = {**LDAP_DEFAULT_CONN_OPTIONS} + if AUTH_LDAP2_CA_CERT_FILE is not None: + AUTH_LDAP2_CONNECTION_OPTIONS[ + ldap.OPT_X_TLS_CACERTFILE + ] = AUTH_LDAP2_CA_CERT_FILE + AUTH_LDAP2_CONNECTION_OPTIONS[ldap.OPT_X_TLS_NEWCTX] = 0 + AUTH_LDAP2_USER_FILTER = env.str( + 'AUTH_LDAP2_USER_FILTER', '(sAMAccountName=%(user)s)' + ) AUTH_LDAP2_USER_SEARCH = LDAPSearch( env.str('AUTH_LDAP2_USER_SEARCH_BASE', None), ldap.SCOPE_SUBTREE, - LDAP_DEFAULT_FILTERSTR, + AUTH_LDAP2_USER_FILTER, ) AUTH_LDAP2_USER_ATTR_MAP = LDAP_DEFAULT_ATTR_MAP AUTH_LDAP2_USERNAME_DOMAIN = env.str('AUTH_LDAP2_USERNAME_DOMAIN') @@ -586,7 +608,7 @@ def set_logging(level=None): # General API settings -SODAR_API_DEFAULT_VERSION = '0.13.4' +SODAR_API_DEFAULT_VERSION = '0.14.0' SODAR_API_ALLOWED_VERSIONS = [ '0.7.0', '0.7.1', @@ -605,6 +627,7 @@ def set_logging(level=None): '0.13.2', '0.13.3', '0.13.4', + '0.14.0', ] SODAR_API_MEDIA_TYPE = 'application/vnd.bihealth.sodar+json' SODAR_API_DEFAULT_HOST = env.url( @@ -614,6 +637,10 @@ def set_logging(level=None): # Projectroles app settings PROJECTROLES_SITE_MODE = env.str('PROJECTROLES_SITE_MODE', 'SOURCE') +PROJECTROLES_TEMPLATE_INCLUDE_PATH = env.path( + 'PROJECTROLES_TEMPLATE_INCLUDE_PATH', + os.path.join(APPS_DIR, 'templates', 'include'), +) PROJECTROLES_SECRET_LENGTH = env.int('PROJECTROLES_SECRET_LENGTH', 32) PROJECTROLES_INVITE_EXPIRY_DAYS = env.int('PROJECTROLES_INVITE_EXPIRY_DAYS', 14) PROJECTROLES_SEND_EMAIL = env.bool('PROJECTROLES_SEND_EMAIL', False) @@ -712,6 +739,8 @@ def set_logging(level=None): # Taskflow backend settings +# Connection timeout for taskflowbackend flows (other sessions not affected) +TASKFLOW_IRODS_CONN_TIMEOUT = env.int('TASKFLOW_IRODS_CONN_TIMEOUT', 480) TASKFLOW_LOCK_RETRY_COUNT = env.int('TASKFLOW_LOCK_RETRY_COUNT', 2) TASKFLOW_LOCK_RETRY_INTERVAL = env.int('TASKFLOW_LOCK_RETRY_INTERVAL', 3) TASKFLOW_LOCK_ENABLED = True @@ -740,10 +769,12 @@ def set_logging(level=None): SHEETS_ALLOW_CRITICAL = env.bool('SHEETS_ALLOW_CRITICAL', False) # Temporary, see issue #556 SHEETS_ENABLE_CACHE = True +# Enable study table cache +SHEETS_ENABLE_STUDY_TABLE_CACHE = env.bool( + 'SHEETS_ENABLE_STUDY_TABLE_CACHE', True +) # iRODS file query limit SHEETS_IRODS_LIMIT = env.int('SHEETS_IRODS_LIMIT', 50) -# Study/assay table height -SHEETS_TABLE_HEIGHT = env.int('SHEETS_TABLE_HEIGHT', 400) # Minimum edit config version SHEETS_CONFIG_VERSION = '0.8.0' # Min default column width @@ -774,17 +805,6 @@ def set_logging(level=None): os.path.join(ROOT_DIR, 'samplesheets/config/ext_links.json'), ) -# HACK: Supported cubi-tk templates, excluding ones which altamISA cannot parse -SHEETS_ENABLED_TEMPLATES = [ - 'bulk_rnaseq', - 'generic', - 'germline', - 'microarray', - 'ms_meta_biocrates', - 'single_cell_rnaseq', - 'tumor_normal_triplets', -] - # Remote sample sheet sync interval in minutes SHEETS_SYNC_INTERVAL = env.int('SHEETS_SYNC_INTERVAL', 5) diff --git a/config/settings/production.py b/config/settings/production.py index 4285ee45..9d86f543 100644 --- a/config/settings/production.py +++ b/config/settings/production.py @@ -144,6 +144,9 @@ # ------------------------------------------------------------------------------ +# Samplesheets app settings +SHEETS_ENABLE_STUDY_TABLE_CACHE = True + # Plugin settings ENABLED_BACKEND_PLUGINS = env.list( 'ENABLED_BACKEND_PLUGINS', diff --git a/config/settings/test.py b/config/settings/test.py index ac9bf9c0..76566c48 100644 --- a/config/settings/test.py +++ b/config/settings/test.py @@ -18,6 +18,11 @@ # Note: This key only used for development and testing. SECRET_KEY = env('DJANGO_SECRET_KEY', default='CHANGEME!!!') +# MANAGER CONFIGURATION +# ------------------------------------------------------------------------------ +ADMINS = [('Admin User', 'admin@example.com')] +MANAGERS = ADMINS + # Mail settings # ------------------------------------------------------------------------------ EMAIL_HOST = 'localhost' @@ -98,6 +103,7 @@ # Samplesheets app settings SHEETS_ENABLE_CACHE = False # Temporarily disabled to fix CI, see issue #556 +SHEETS_ENABLE_STUDY_TABLE_CACHE = True SHEETS_EXTERNAL_LINK_PATH = os.path.join( ROOT_DIR, 'samplesheets/tests/config/ext_links.json' ) diff --git a/config/urls.py b/config/urls.py index 884df90d..a497400f 100644 --- a/config/urls.py +++ b/config/urls.py @@ -33,11 +33,7 @@ def handler500(request, *args, **argv): view=auth_views.LoginView.as_view(template_name='users/login.html'), name='login', ), - path( - route='logout/', - view=auth_views.logout_then_login, - name='logout', - ), + path(route='logout/', view=auth_views.logout_then_login, name='logout'), # User Profile URLs path('user/', include('userprofile.urls')), # Auth diff --git a/docker/Dockerfile b/docker/Dockerfile index 9df45803..edb24b77 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -25,15 +25,19 @@ RUN apt-get update && \ apt-get install -y apt-utils gcc ldap-utils libldap2-dev libsasl2-dev \ make postgresql-client wget +# Install Nodejs v18 +RUN apt-get install -y ca-certificates curl gnupg && \ + mkdir -p /etc/apt/keyrings && \ + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg +RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_18.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list +RUN apt-get update && \ + apt-get install nodejs -y + # Install Python dependencies RUN cd /usr/src/app && \ pip install --no-cache-dir -r requirements/production.txt && \ pip install --no-cache-dir -r requirements/local.txt -# Install modern nodejs -RUN curl -sL https://deb.nodesource.com/setup_16.x | bash - && \ - apt-get install nodejs - # Install npm dependencies RUN cd /usr/src/app/samplesheets/vueapp && \ mkdir -p /usr/src/app/samplesheets/vueapp/dist && \ diff --git a/docs_manual/source/_include/sheets_zip_warning.rst b/docs_manual/source/_include/sheets_zip_warning.rst new file mode 100644 index 00000000..90b84937 --- /dev/null +++ b/docs_manual/source/_include/sheets_zip_warning.rst @@ -0,0 +1,6 @@ +.. warning:: + + When using Microsoft Windows, please note that the built-in Zip archiver + will fail to properly handle ISA-Tab files due to naming conventions. We + recommend `NanaZip `_ + for working with ISA-Tab Zip archives under Windows. diff --git a/docs_manual/source/_static/admin/admin_dropdown.png b/docs_manual/source/_static/admin/admin_dropdown.png index 13f19335..21739ed5 100644 Binary files a/docs_manual/source/_static/admin/admin_dropdown.png and b/docs_manual/source/_static/admin/admin_dropdown.png differ diff --git a/docs_manual/source/_static/app_samplesheets/irods_del_list.png b/docs_manual/source/_static/app_samplesheets/irods_del_list.png index 9ba1c1c9..c5373d18 100644 Binary files a/docs_manual/source/_static/app_samplesheets/irods_del_list.png and b/docs_manual/source/_static/app_samplesheets/irods_del_list.png differ diff --git a/docs_manual/source/_static/app_samplesheets/irods_ticket_form.png b/docs_manual/source/_static/app_samplesheets/irods_ticket_form.png index bd954b50..0354a597 100644 Binary files a/docs_manual/source/_static/app_samplesheets/irods_ticket_form.png and b/docs_manual/source/_static/app_samplesheets/irods_ticket_form.png differ diff --git a/docs_manual/source/_static/app_samplesheets/irods_ticket_list.png b/docs_manual/source/_static/app_samplesheets/irods_ticket_list.png index 638bd15c..65ee0699 100644 Binary files a/docs_manual/source/_static/app_samplesheets/irods_ticket_list.png and b/docs_manual/source/_static/app_samplesheets/irods_ticket_list.png differ diff --git a/docs_manual/source/_static/app_samplesheets/sheet_delete.png b/docs_manual/source/_static/app_samplesheets/sheet_delete.png new file mode 100644 index 00000000..6448bd59 Binary files /dev/null and b/docs_manual/source/_static/app_samplesheets/sheet_delete.png differ diff --git a/docs_manual/source/_static/app_samplesheets/sheet_delete_data.png b/docs_manual/source/_static/app_samplesheets/sheet_delete_data.png new file mode 100644 index 00000000..b6fa5412 Binary files /dev/null and b/docs_manual/source/_static/app_samplesheets/sheet_delete_data.png differ diff --git a/docs_manual/source/_static/app_samplesheets/sheet_ops_no_irods.png b/docs_manual/source/_static/app_samplesheets/sheet_ops_no_irods.png new file mode 100644 index 00000000..1a7f052f Binary files /dev/null and b/docs_manual/source/_static/app_samplesheets/sheet_ops_no_irods.png differ diff --git a/docs_manual/source/_static/sodar_ui/alert_list.png b/docs_manual/source/_static/sodar_ui/alert_list.png index 3774323a..30f0f7c4 100644 Binary files a/docs_manual/source/_static/sodar_ui/alert_list.png and b/docs_manual/source/_static/sodar_ui/alert_list.png differ diff --git a/docs_manual/source/_static/sodar_ui/home.png b/docs_manual/source/_static/sodar_ui/home.png index a2d97d25..48d190c8 100644 Binary files a/docs_manual/source/_static/sodar_ui/home.png and b/docs_manual/source/_static/sodar_ui/home.png differ diff --git a/docs_manual/source/_static/sodar_ui/project_members.png b/docs_manual/source/_static/sodar_ui/project_members.png index b017e6a0..dde573fe 100644 Binary files a/docs_manual/source/_static/sodar_ui/project_members.png and b/docs_manual/source/_static/sodar_ui/project_members.png differ diff --git a/docs_manual/source/_static/sodar_ui/project_members_inherit.png b/docs_manual/source/_static/sodar_ui/project_members_inherit.png new file mode 100644 index 00000000..634d929a Binary files /dev/null and b/docs_manual/source/_static/sodar_ui/project_members_inherit.png differ diff --git a/docs_manual/source/_static/sodar_ui/project_members_promote.png b/docs_manual/source/_static/sodar_ui/project_members_promote.png new file mode 100644 index 00000000..93837c8d Binary files /dev/null and b/docs_manual/source/_static/sodar_ui/project_members_promote.png differ diff --git a/docs_manual/source/_static/sodar_ui/user_profile.png b/docs_manual/source/_static/sodar_ui/user_profile.png index 099c71bb..aa863188 100644 Binary files a/docs_manual/source/_static/sodar_ui/user_profile.png and b/docs_manual/source/_static/sodar_ui/user_profile.png differ diff --git a/docs_manual/source/admin_commands.rst b/docs_manual/source/admin_commands.rst index c70875dd..cc59b702 100644 --- a/docs_manual/source/admin_commands.rst +++ b/docs_manual/source/admin_commands.rst @@ -31,8 +31,8 @@ These commands originate in SODAR Core. More information can be found in the ``syncgroups`` Synchronize user groups. ``syncmodifyapi`` - Submit missing project metadata to iRODS. Generally should only be used in - development. + Synchronize project metadata and user access in iRODS. Generally should only + be used in development. ``syncremote`` Synchronize project and user data from a remote site if remote project sync is enabled. @@ -54,6 +54,10 @@ operations regarding sample sheets, landing zones, iRODS data and ontologies. Return list of landing zones last modified over two weeks ago. ``irodsorphans`` Find orphans in iRODS project collections. +``normalizesheets`` + Clean up and normalize previously imported sample sheets for + non-standard data or other issues. Also updates render tables and creates a + backup ISA-Tab version of the normalized sheets. ``syncnames`` Synchronize alternative names for sample sheet material search. ``syncstudytables`` diff --git a/docs_manual/source/admin_settings.rst b/docs_manual/source/admin_settings.rst index 4e36f3c2..5a34af63 100644 --- a/docs_manual/source/admin_settings.rst +++ b/docs_manual/source/admin_settings.rst @@ -111,12 +111,15 @@ iRODS Settings Taskflow Backend Settings ------------------------- +``TASKFLOW_IRODS_CONN_TIMEOUT`` + Connection timeout for taskflows in seconds, other SODAR iRODS sessions are + not affected (int, default: 480). ``TASKFLOW_LOCK_RETRY_COUNT`` Retry count for project lock retrieval for Taskflow operations (int, - default: 2) + default: 2). ``TASKFLOW_LOCK_RETRY_INTERVAL`` Retry interval for project lock retrieval for Taskflow operations (int, - default: 3) + default: 3). iRODS WebDAV Settings --------------------- @@ -154,8 +157,8 @@ Sample Sheets Settings Allow critical altamISA warnings on import (boolean). ``SHEETS_IRODS_LIMIT`` iRODS file query limit (integer). -``SHEETS_TABLE_HEIGHT`` - Default study/assay table height. +``SHEETS_ENABLE_STUDY_TABLE_CACHE`` + Enable caching of study tables unless set false (boolean). ``SHEETS_MIN_COLUMN_WIDTH`` Minimum default column width in study/assay tables (integer). ``SHEETS_MAX_COLUMN_WIDTH`` diff --git a/docs_manual/source/admin_ui.rst b/docs_manual/source/admin_ui.rst index 538b8d2a..b3ba53d1 100644 --- a/docs_manual/source/admin_ui.rst +++ b/docs_manual/source/admin_ui.rst @@ -52,6 +52,8 @@ These new applications are as follows: `Site Info `_ Display information related to this SODAR instance, including statistics, enabled applications and back-end server settings. +`All Timeline Events `_ + Browse a list of all timeline events on the site. Django Admin Access the Django admin UI. diff --git a/docs_manual/source/api_documentation.rst b/docs_manual/source/api_documentation.rst index 8d0edfeb..b3dbf395 100644 --- a/docs_manual/source/api_documentation.rst +++ b/docs_manual/source/api_documentation.rst @@ -45,7 +45,7 @@ expected version. .. code-block:: console - Accept: application/vnd.bihealth.sodar+json; version=0.13.3 + Accept: application/vnd.bihealth.sodar+json; version=0.14.0 Specific sections of the SODAR API may require their own accept header. See the exact header requirement in the respective documentation on each section of the diff --git a/docs_manual/source/api_examples.rst b/docs_manual/source/api_examples.rst index 6aa6e91e..52a9706b 100644 --- a/docs_manual/source/api_examples.rst +++ b/docs_manual/source/api_examples.rst @@ -41,9 +41,9 @@ the SODAR API: # Token authorization header (required) auth_header = {'Authorization': 'token {}'.format(api_token)} # Use core_headers for project management API endpoints - core_headers = {**auth_header, 'Accept': 'application/vnd.bihealth.sodar-core+json; version=0.12.0'} + core_headers = {**auth_header, 'Accept': 'application/vnd.bihealth.sodar-core+json; version=0.13.2'} # Use sodar_headers for sample sheet and landing zone API endpoints - sodar_headers = {**auth_header, 'Accept': 'application/vnd.bihealth.sodar+json; version=0.13.4'} + sodar_headers = {**auth_header, 'Accept': 'application/vnd.bihealth.sodar+json; version=0.14.0'} .. note:: diff --git a/docs_manual/source/api_irodsinfo.rst b/docs_manual/source/api_irodsinfo.rst new file mode 100644 index 00000000..f61bf57e --- /dev/null +++ b/docs_manual/source/api_irodsinfo.rst @@ -0,0 +1,25 @@ +.. _api_irodsinfo: + +Irods Info API +^^^^^^^^^^^^^^ + +The REST API for the iRODS Info app is described in this document. + + +API Views +========= + +.. currentmodule:: irodsinfo.views_api + +.. autoclass:: IrodsEnvRetrieveAPIView + + +Versioning +========== + +For accept header versioning, the following header is expected in the current +SODAR version: + +.. code-block:: console + + Accept: application/vnd.bihealth.sodar+json; version=0.14.0 diff --git a/docs_manual/source/api_landingzones.rst b/docs_manual/source/api_landingzones.rst index 422f1916..4292713f 100644 --- a/docs_manual/source/api_landingzones.rst +++ b/docs_manual/source/api_landingzones.rst @@ -17,6 +17,8 @@ API Views .. autoclass:: ZoneCreateAPIView +.. autoclass:: ZoneUpdateAPIView + .. autoclass:: ZoneSubmitDeleteAPIView .. autoclass:: ZoneSubmitMoveAPIView @@ -30,4 +32,4 @@ SODAR version: .. code-block:: console - Accept: application/vnd.bihealth.sodar+json; version=0.13.4 + Accept: application/vnd.bihealth.sodar+json; version=0.14.0 diff --git a/docs_manual/source/api_projectroles.rst b/docs_manual/source/api_projectroles.rst index 1441649b..cbe0f4b0 100644 --- a/docs_manual/source/api_projectroles.rst +++ b/docs_manual/source/api_projectroles.rst @@ -23,4 +23,4 @@ in the current SODAR version: .. code-block:: console - Accept: application/vnd.bihealth.sodar-core+json; version=0.12.0 + Accept: application/vnd.bihealth.sodar-core+json; version=0.13.2 diff --git a/docs_manual/source/api_samplesheets.rst b/docs_manual/source/api_samplesheets.rst index ccc65a28..83448b20 100644 --- a/docs_manual/source/api_samplesheets.rst +++ b/docs_manual/source/api_samplesheets.rst @@ -11,18 +11,54 @@ API Views .. currentmodule:: samplesheets.views_api -.. autoclass:: InvestigationRetrieveAPIView +Sample Sheet Management +----------------------- -.. autoclass:: IrodsCollsCreateAPIView +.. autoclass:: InvestigationRetrieveAPIView .. autoclass:: SheetImportAPIView .. autoclass:: SheetISAExportAPIView +iRODS Data Objects and Collections +---------------------------------- + +.. autoclass:: IrodsCollsCreateAPIView + .. autoclass:: SampleDataFileExistsAPIView .. autoclass:: ProjectIrodsFileListAPIView +iRODS Access Tickets +-------------------- + +.. autoclass:: IrodsAccessTicketRetrieveAPIView + +.. autoclass:: IrodsAccessTicketListAPIView + +.. autoclass:: IrodsAccessTicketCreateAPIView + +.. autoclass:: IrodsAccessTicketUpdateAPIView + +.. autoclass:: IrodsAccessTicketDestroyAPIView + +iRODS Data Requests +------------------- + +.. autoclass:: IrodsDataRequestRetrieveAPIView + +.. autoclass:: IrodsDataRequestListAPIView + +.. autoclass:: IrodsDataRequestCreateAPIView + +.. autoclass:: IrodsDataRequestUpdateAPIView + +.. autoclass:: IrodsDataRequestDestroyAPIView + +.. autoclass:: IrodsDataRequestAcceptAPIView + +.. autoclass:: IrodsDataRequestRejectAPIView + Versioning ========== @@ -32,4 +68,4 @@ SODAR version: .. code-block:: console - Accept: application/vnd.bihealth.sodar+json; version=0.13.4 + Accept: application/vnd.bihealth.sodar+json; version=0.14.0 diff --git a/docs_manual/source/app_landingzones_create.rst b/docs_manual/source/app_landingzones_create.rst index 63306f2b..45da7b8f 100644 --- a/docs_manual/source/app_landingzones_create.rst +++ b/docs_manual/source/app_landingzones_create.rst @@ -71,3 +71,13 @@ see the zone status and move further with file uploads. The next sections will provide instructions on browsing your landing zones and how to proceed with your file uploads. + + +Landing Zone Update +^^^^^^^^^^^^^^^^^^^ + +Landing zones can be updated by following the :guilabel:`Update Zone` link on the +dropdown menu on the right hand side of the zone list. This opens up the same +form as in the creation process, with the fields pre-filled with the current +values. Currently the only fields that can be updated are description and user message. +Once you have made your changes, click :guilabel:`Update` to save them. diff --git a/docs_manual/source/app_landingzones_transfer.rst b/docs_manual/source/app_landingzones_transfer.rst index 7d72d98c..f12890a9 100644 --- a/docs_manual/source/app_landingzones_transfer.rst +++ b/docs_manual/source/app_landingzones_transfer.rst @@ -49,15 +49,18 @@ that assay. File Checksums -------------- -Checksums for each file should be calculated when uploading. In iCommands, this -is usually done with the ``-k`` argument when uploading, or afterwards using the -``ichksum`` command. - -In addition to the calculated checksum, an MD5 checksum file should accompany -each file when uploaded to the server to verify the original checksum. The file -should be named with a ``.md5`` suffix following the name of the data file. -In other words, a file named ``filename.bam`` should be uploaded together with -a checksum file called ``filename.bam.md5`` in the same collection. +SODAR requires for an MD5 checksum file to accompany each file when uploaded to +the server. This file is used to verify the original checksum against the one +calculated in iRODS once the upload is complete. The file should be named with a +``.md5`` suffix following the name of the data file. E.g. a file named +``filename.bam`` should be uploaded together with a checksum file called +``filename.bam.md5`` in the same collection. + +From SODAR v0.14 onwards, iRODS checksums not present after the upload are +automatically calculated prior to validating the landing zone. This means +uploading with the ``-k`` argument or separately calling ``ichksum`` are no +longer required. The calculation step may take some time with large landing +zones. Collection Structure -------------------- @@ -68,9 +71,9 @@ set true when creating the zone, these expected collections are created automatically. If collections are left empty in the landing zone, they will not be created in the sample repository. -SODAR allows uploading data into root level collections other than the expected -ones. However, these will **not** be visible in the Sample Sheets user -interface. Thus, this is not recommended. +If the *Restrict Collections* option is unset, SODAR allows uploading data into +root level collections other than the expected ones. However, these will **not** +be visible in the Sample Sheets user interface. Thus, this is not recommended. There are three common root level collections for all assays: @@ -118,7 +121,8 @@ Clicking the link will temporarily lock the landing zone for read-only access and start the validation process in the background. Duration of validation depends on the amount of files in your zone. You can monitor the status of this process in the landing zone list view. You will also receive an alert once the -validation is done. +validation is done. In the validation phase, missing iRODS checksums are also +calculated so they can be compared to the corresponding ``.md5`` files. .. figure:: _static/app_landingzones/zone_status_validating.png :align: center @@ -159,11 +163,11 @@ Moving Files Once you have finished uploading files into your landing zone and wish to transfer the files into the read-only sample data repository, you should open -the dropdown next to your landing zones and select ``Validate and Move``. This -will trigger the validation process as described above and if successful, -automatically proceed to move the files under the assay. As with validation this -is done in the background and you can monitor the process in the landing zone -list. +the dropdown next to your landing zones and select +:guilabel:`Validate and Move`. This will trigger the validation process as +described above and if successful, automatically proceed to move the files under +the assay. As with validation this is done in the background and you can monitor +the process in the landing zone list. .. hint:: diff --git a/docs_manual/source/app_samplesheets_browse.rst b/docs_manual/source/app_samplesheets_browse.rst index fb106c74..7ba18010 100644 --- a/docs_manual/source/app_samplesheets_browse.rst +++ b/docs_manual/source/app_samplesheets_browse.rst @@ -123,6 +123,11 @@ Files Cells in file columns link out to iRODS files if present. For more details, see the "iRODS File Linking" section. +.. hint:: + + The maximum height of study and assay tables can be set in your user + settings in the :ref:`ui_user_profile`. + Toggling Column Visibility ========================== diff --git a/docs_manual/source/app_samplesheets_create.rst b/docs_manual/source/app_samplesheets_create.rst index b6448009..c17ce192 100644 --- a/docs_manual/source/app_samplesheets_create.rst +++ b/docs_manual/source/app_samplesheets_create.rst @@ -35,7 +35,15 @@ upload them at once. .. note:: When uploading multiple files instead of a zip archive, all files for the - investigation must be present or the import will fail! + investigation must be present and in the same directory or the import will + fail. + +.. note:: + + Importing ISA-Tab files with empty study or assay tables is not allowed. + Study tables must also contain source and sample materials. + +.. include:: _include/sheets_zip_warning.rst .. figure:: _static/app_samplesheets/sheet_import.png :align: center @@ -43,15 +51,10 @@ upload them at once. ISA-Tab import form -In case of a successful import, you will be redirected to the main sample sheets +After a successful import, you will be redirected to the main sample sheets view, where you should see the study and assay tables for your imported sample sheets. -.. note:: - - Importing ISA-Tab files with empty study or assay tables is not allowed. - Study tables must also contain source and sample materials. - Parser Warnings --------------- diff --git a/docs_manual/source/app_samplesheets_delete.rst b/docs_manual/source/app_samplesheets_delete.rst new file mode 100644 index 00000000..29ade593 --- /dev/null +++ b/docs_manual/source/app_samplesheets_delete.rst @@ -0,0 +1,62 @@ +.. _app_samplesheets_delete: + +Deleting Sample Sheets +^^^^^^^^^^^^^^^^^^^^^^ + +To delete sample sheets from a project, open the :guilabel:`Sheet Operations` +dropdown and select :guilabel:`Delete Sheets`. If iRODS collections have been +created for the project, this dropdown item will appear as +:guilabel:`Delete Sheets and Data`. + +.. figure:: _static/app_samplesheets/sheet_ops_no_irods.png + :align: center + :scale: 80% + + Sheet Operations dropdown + + +Deletion Without iRODS Data +=========================== + +If no data has been uploaded into iRODS, the sample sheets can be deleted by any +project member with the role of contributor and above. You will be presented +with a confirmation form, in which you have to type the full host name of the +SODAR instance you are working on to initiate deletion. + +.. figure:: _static/app_samplesheets/sheet_delete.png + :align: center + :scale: 75% + + Sheet deletion confirmation form + +.. warning:: + + This action will delete the sample sheets, related display and editing + configurations and possible previously saved versions. This action can not + be undone! + + +Deletion With iRODS Data +======================== + +If files have been uploaded into the project sample data repository via landing +zones, deleting the sample sheets is only allowed for users with the owner or +delegate role. The confirmation form will present a warning regarding the +deletion of iRODS files along with the sample sheets. + +.. figure:: _static/app_samplesheets/sheet_delete_data.png + :align: center + :scale: 75% + + Sheet deletion confirmation form with iRODS data + +If you have contributor access to a project and wish to delete the sheets when +data has been uploaded, you can either request an owner or delegate to handle +deletion, or submit :ref:`app_samplesheets_irods_delete` and delete the sheets +yourself after the requests have been accepted. + +.. warning:: + + In addition to deleting the sample sheets and saved versions, this action + will also delete all project files from iRODS. This action can not be + undone! diff --git a/docs_manual/source/app_samplesheets_export.rst b/docs_manual/source/app_samplesheets_export.rst index 46075be0..3d6a4fb1 100644 --- a/docs_manual/source/app_samplesheets_export.rst +++ b/docs_manual/source/app_samplesheets_export.rst @@ -19,6 +19,8 @@ archive containing all the ISA-Tab files as tab separated values (TSV). The export is fully ISA-Tab compatible and can be used with other software supporting the model. +.. include:: _include/sheets_zip_warning.rst + Export Excel Table ================== diff --git a/docs_manual/source/app_samplesheets_irods_delete.rst b/docs_manual/source/app_samplesheets_irods_delete.rst index 6e165a38..1f6ddbb1 100644 --- a/docs_manual/source/app_samplesheets_irods_delete.rst +++ b/docs_manual/source/app_samplesheets_irods_delete.rst @@ -50,22 +50,26 @@ requests in the project as a project owner or delegate, open the .. figure:: _static/app_samplesheets/irods_del_list.png :align: center - :scale: 70% + :scale: 60% iRODS delete request list -The list provides a button for copying the iRODS path into the clipboard, status -information for the requests as well as dropdowns allowing you to either update -or delete your requests. On the top of the page you can see a *Create Request* -link for manual creation. +The list displays the label and status for existing requests. Buttons for +copying the iRODS path into clipboard and opening the data object or collection +in WebDAV are provided for each request. The request dropdown contains +operations for updating, deleting, accepting and/or rejecting requests depending +on your role in the project. The :guilabel:`Request Operations` dropdown on the +top of the view contains options for manually creating a new requests as well +as accepting and rejecting requests multiple requests at once. Manual Request Creation ======================= -Clicking the :guilabel:`Create Request` button takes you to a simple form where -you can create a delete request by manually entering an iRODS path and an -optional description. +Selecting the :guilabel:`Create Request` option in the +:guilabel:`Request Operations` dropdown takes you to a form in which you can +create a delete request by manually entering an iRODS path. An optional +description can also be provided. .. figure:: _static/app_samplesheets/irods_del_form.png :align: center @@ -99,3 +103,19 @@ user will be informed of rejection. Accepting delete requests will delete the associated file(s) from iRODS with no possibility for undoing the action! Each request should be reviewed carefully. + + +Accepting and Rejecting Multiple Requests +========================================= + +In addition to accepting or rejecting requests one by one, you can also accept +or reject multiple requests at once. This is done by selecting the requests you +want to accept or reject by clicking the checkboxes on the leftmost column of +the request list. Once you have selected the requests, click the +:guilabel:`Request Operations` dropdown and select either +:guilabel:`Accept Selected` or :guilabel:`Reject Selected`. + +.. note:: + + Batch accepting or rejeting requests for entire collections is disabled. + They must be accepted or rejected individually from the request dropdown. diff --git a/docs_manual/source/app_samplesheets_irods_ticket.rst b/docs_manual/source/app_samplesheets_irods_ticket.rst index e405e7c4..f1940709 100644 --- a/docs_manual/source/app_samplesheets_irods_ticket.rst +++ b/docs_manual/source/app_samplesheets_irods_ticket.rst @@ -3,40 +3,25 @@ iRODS Access Tickets ^^^^^^^^^^^^^^^^^^^^ -The Sample Sheets application allows you to create anonymous iRODS access -tickets to specific collections in the sample data repository. This enables -providing publicly accessible URLs to these collections for e.g. integrating -data with other software. +The Sample Sheets application allows you to create iRODS access tickets. The +tickets enable read-only access to specific collections in a project's sample +data repository without the need for a login or project membership. This can be +used to provide URLs for simple links to iRODS collections for e.g. enabling +access to SODAR data from other software. .. warning:: - Anyone with the ticket or URL and network access to your iRODS server can - access these collections, regardless of their project access! Care should be - taken in what is shared publicly and to whom tickets are provided. + Anyone with the URL and network access to your iRODS server can access these + collections regardless of their project roles. Care should be taken in what + is shared publicly and to whom tickets are provided. -Currently, creating tickets is supported for setting up -`track hubs `_ for -`UCSC Genome Browser `_ integration. - -You can create a track hub by uploading files under a collection under -``TrackHubs`` using Landing Zones. Thus, if you want to create a track hub named -``YourHub``, files should go under the collection ``TrackHubs/YourHub``. For -more information on landing zone uploads, see the Landing Zones documentation. - -After the upload, your track hub should be visible in the assay shortcuts. - -.. figure:: _static/app_samplesheets/irods_ticket_hub.png - :align: center - Track hub in assay shortcuts +Browsing Access Tickets +======================= -Once the track hub is available, you can create an access ticket for it in the -Sample Sheets app. Open the :guilabel:`Sheet Operations` dropdown and select -:guilabel:`iRODS Access Tickets` to open a list of access tickets for track hubs -in the project. The anonymous URL for each ticket can be copied to the clipboard -using the button next to the ticket label. In the right hand side dropdown for -each ticket, you can either update its details or delete it. Access is revoked -for deleted tickets. +To browse access tickets in a project, open the :guilabel:`Sheet Operations` +dropdown and select :guilabel:`iRODS Access Tickets`. The view displays a list +of tickets created in the project. .. figure:: _static/app_samplesheets/irods_ticket_list.png :align: center @@ -44,24 +29,100 @@ for deleted tickets. iRODS access ticket list -To create a new ticket, click the :guilabel:`Create Ticket` button. This opens a -simple form where you must choose the track hub path as well as set an optional -ticket label and expiry date. The label is for referencing the purpose of the -ticket: tickets with no label will be listed by their creation date. If no -expiry date is set, the ticket will be valid until manually revoked. +For each ticket, the list displays the following information: + +Name + Collection name and label for the ticket. The name works as a link to the + collection in Davrods. A button for copying the ticket link with the + access token included is also included. +Ticket + The token string of the access ticket. +User + The user who created the ticket. +Created + Date and time of ticket creation. +Expires + Expiry date for the ticket, or *"Never"* if no expiry has been set. -It is possible to create multiple tickets for a single track hub if there is -need to e.g. revoke access to ticket users at different times. + +Creating Access Tickets +======================= + +With a sufficient role in a project (contributor or above), you can create +access tickets for any collection in the project within the following +constraints: + +- The collection must exist. +- The collection must belong to the project in question. +- The collection must be within an assay collection. +- The collection must **not** be an assay root collection. +- There must not be another active ticket for the same collection. + +To create a ticket, navigate to the access ticket list of the desired project +and click on :guilabel:`Create Ticket`. This will open the form for ticket +creation. .. figure:: _static/app_samplesheets/irods_ticket_form.png :align: center - :scale: 75% + :scale: 60% iRODS access ticket creation form -A link to the WebDAV URL for the most recent valid access ticket is displayed in -assay shortcuts next to the existing assay, as displayed in the screenshot -below. +The form contains the following items: + +Path + Full iRODS path for the collection for which the ticket should be created. + See constraints above. +Label + Optional text label for the ticket. This will be displayed for the ticket + to e.g. inform other users of the purpose for which the ticket was created. +Expiry Date + Optional date for ticket expiry. + + +Updating Access Tickets +======================= + +To update an existing access ticket, open the dropdown menu on the right hand +side of the ticket list and select :guilabel:`Update Ticket`. In the form, you +can edit the label and the expiry date for the ticket. The path can not be +edited. To enable ticket access to another iRODS collection, you need to create +another ticket. + + +Deleting Access Tickets +======================= + +To delete an access ticket, open the dropdown menu associated with a ticket in +the ticket list and select :guilabel:`Delete Ticket`. After confirming the +deletion, the collection the ticket targeted can no longer be accessed with the +token string. + + +Managing Tickets for UCSC Track Hubs +==================================== + +Tickets for +`track hubs `_ for +`UCSC Genome Browser `_ integration are a special +case, as they are also visible in the sample sheets GUI. + +If you upload a collection with files under an assay collection called +``TrackHubs`` using Landing Zones, the track hub collection will be visible in +the assay shortcuts. E.g. if you want to create a track hub named +``YourTrackHub``, files should go under the collection +``TrackHubs/YourTrackHub``. Once files are uploaded, validated and moved from +the landing zone, the collection will be displayed in the GUI. + +.. figure:: _static/app_samplesheets/irods_ticket_hub.png + :align: center + + Track hub in assay shortcuts + +Once you create an access ticket for the track hub collection, a button for +accessing the collection with the ticket link is automatically added to the +assay shortcut. The URL can also be copied into the clipboard from this link. + .. figure:: _static/app_samplesheets/irods_ticket_hub_link.png :align: center @@ -70,6 +131,6 @@ below. .. note:: - Currently SODAR only supports anonymous access tickets for track hub - collections. This functionality may be expanded to other sample repository - collections in a future SODAR release. + GUI links for access tickets for collections other than track hubs will be + introduced in a later SODAR release. For now, the tickets can be viewed in + the access ticket list. diff --git a/docs_manual/source/app_samplesheets_version.rst b/docs_manual/source/app_samplesheets_version.rst index 915d479d..64ad19d6 100644 --- a/docs_manual/source/app_samplesheets_version.rst +++ b/docs_manual/source/app_samplesheets_version.rst @@ -29,6 +29,8 @@ Most Recent The most recently updated version. Import Version imported into SODAR from existing ISA-Tab files. +Create + Version created from Template. Edit Version edited within the Sample Sheets app of SODAR. Restore diff --git a/docs_manual/source/conf.py b/docs_manual/source/conf.py index 0ecbcf56..ff821ffe 100644 --- a/docs_manual/source/conf.py +++ b/docs_manual/source/conf.py @@ -26,7 +26,7 @@ author = 'BIH Core Unit Bioinformatics' # The full version, including alpha/beta/rc tags -release = '0.13.4' +release = '0.14.0' # -- General configuration --------------------------------------------------- diff --git a/docs_manual/source/data_transfer_irods.rst b/docs_manual/source/data_transfer_irods.rst index 5cc0918f..c581d71e 100644 --- a/docs_manual/source/data_transfer_irods.rst +++ b/docs_manual/source/data_transfer_irods.rst @@ -27,12 +27,6 @@ access the data from elsewhere on the network, you need to install the `official installation instructions `_ for more information. -.. note:: - - On Ubuntu 20.04, installing iCommands is not officially supported at the - time of writing. For workarounds, - `see this discussion `_. - To configure your iCommands connection, open the :ref:`ui_irods_info` application. In the app, click the :guilabel:`Download Configuration` button to download a configuration file @@ -53,11 +47,6 @@ You will be prompted for your password, which is the same one you use to access this web site. After this, you should be successfully logged on to iRODS and can access data on the storage in your terminal. -.. note:: - - When using ``iput`` or ``irsync`` to upload data into the SODAR iRODS - server, you must use the ``-k`` argument to enable checksum generation. - See `iRODS documentation `_ for iCommands reference. diff --git a/docs_manual/source/dev_apps.rst b/docs_manual/source/dev_apps.rst index 72ecddcd..b5b7c273 100644 --- a/docs_manual/source/dev_apps.rst +++ b/docs_manual/source/dev_apps.rst @@ -220,6 +220,8 @@ within assays based on the assay type. They are placed under The following assay sub-apps currently exist: +cytof + Protein expression profiling / mass cytometry assay app. dna_sequencing DNA sequencing assay app. generic_raw diff --git a/docs_manual/source/dev_guide.rst b/docs_manual/source/dev_guide.rst index 5ce09bf2..067c9526 100644 --- a/docs_manual/source/dev_guide.rst +++ b/docs_manual/source/dev_guide.rst @@ -14,16 +14,30 @@ Make sure to base your work branch on the ``dev`` branch. This branch is used for development and is always the latest "bleeding edge" version of SODAR. The ``main`` branch is only used for merging stable releases. -When naming your work branches, prefix them with the issue name, e.g. -``123-your-new-feature`` or ``123-bug-being-fixed``. It is recommended to keep -the branch names short and concise. +When naming your work branches, prefix them with the issue ID, preferably +followed by a verb depicting the action: "add", "update", "fix", "remove", +"refactor", "upgrade", "deprecate" or something else if none of these ar +applicable. +The rest of the branch name should *concisely* represent the change. It is not +necessary (and often not recommended) to include the entire name of the issue +as they may be verbose. + +If a branch and pull request tackles multiple issues at once, including the ID +of the most major issue is enough. + +Examples of recommended branch names: + +- ``123-add-zone-polarity-reversing`` +- ``456-fix-contact-cell-rendering`` +- ``789-refactor-irodsbackend-tests`` Commits ======= It is recommended to use short but descriptive commit messages and always -include the related issue ID(s) in the message. Examples: +include the related issue ID(s) in the message. Starting them with the verb +depicting the action is desirable. Examples: - ``add local irods auth api view (#1263)`` - ``fix ontology column config tooltip hiding (#1379)`` @@ -35,6 +49,9 @@ Pull Requests Please add the related issue ID(s) to the title of your pull request and ensure the pull request is set against the ``dev`` branch. +It is strongly recommended to use descriptive commit messages even in work +commits that are to be squashed in merging. This will aid the review process. + Before submitting a pull request for review, ensure the following: - You have followed code conventions (see :ref:`dev_guide_code`). @@ -94,17 +111,8 @@ Sphinx and other requirements installed. .. code-block:: bash $ cd docs - $ make html - -Note that in some cases such as editing the index, changes may not be visible -unless you build the docs from scratch. In that case, first remove previously -built files with ``rm -rf build``. - -When updating the ``CHANGELOG`` file, the following conventions should be -followed: + $ rm -rf build && make html -- Split updates into the Added/Changed/Fixed/Removed categories. -- Under each category, mark updates under the related app if applicable, - otherwise use *General*. -- Write brief but descriptive descriptions followed by issue ID(s). Previous - entries serve as examples. +It is recommended to **not** update the ``CHANGELOG`` file in pull requests. +This will be done by the maintainers when preparing a release in order to avoid +unnecessary merge/rebase conflicts. diff --git a/docs_manual/source/dev_install.rst b/docs_manual/source/dev_install.rst index 5ea2b010..dd750d2c 100644 --- a/docs_manual/source/dev_install.rst +++ b/docs_manual/source/dev_install.rst @@ -189,14 +189,14 @@ set in your environment variables. --------------------------------- To enable the Sample Sheets Vue.js app in development, you need to install its -prerequisites using NPM. First install the NPM dependencies using the following +prerequisites. First, install Nodejs and Vue dependencies using the following command: .. code-block:: bash $ sudo utility/install_vue_dev.sh -Once NPM has been set up, install the app requirements: +Once the dependencies have been set up, install the app requirements: .. code-block:: bash diff --git a/docs_manual/source/index.rst b/docs_manual/source/index.rst index e87a6253..536bcc23 100644 --- a/docs_manual/source/index.rst +++ b/docs_manual/source/index.rst @@ -52,9 +52,9 @@ Table of Contents ui_api_tokens ui_user_profile ui_project_overview - ui_project_timeline ui_project_members ui_project_update + ui_project_timeline ui_alerts .. toctree:: @@ -70,6 +70,7 @@ Table of Contents app_samplesheets_version app_samplesheets_irods_ticket app_samplesheets_irods_delete + app_samplesheets_delete app_samplesheets_sync .. toctree:: @@ -117,6 +118,7 @@ Table of Contents Project Management API Sample Sheets API Landing Zones API + iRODS Info API api_examples .. toctree:: diff --git a/docs_manual/source/metadata_advanced.rst b/docs_manual/source/metadata_advanced.rst index b530451f..a5c0cd5a 100644 --- a/docs_manual/source/metadata_advanced.rst +++ b/docs_manual/source/metadata_advanced.rst @@ -90,6 +90,7 @@ SODAR currently supports the following assay plugins: - **Generic Raw Data Plugin** - **Metabolite Profiling / Mass Spectrometry** - **Microarray** +- **Protein Expression Profiling / Mass Cytometry** - **Protein Expression Profiling / Mass Spectrometry** Common links as well as plugin specific links are detailed below. @@ -182,3 +183,21 @@ Protein Expression Profiling / Mass Spectrometry Plugin * Files are linked to ``RawData`` under the assay. - Used with measurement type / technology type * protein expression profiling / mass spectrometry + +Protein Expression Profiling / Mass Cytometry Plugin +------------------------------------------------------- + +- Internal name: ``samplesheets_assay_cytof`` +- Additional assay shortcuts + * N/A +- Row-specific links + * Rows with an **Assay Name** set in the **mass cytometry** process are + linked to ``{Assay Name}`` to created one collection per measurement run. +- Inline links + * *Barcode key* and *Antibody panel* process parameter values are linked + to ``MiscFiles`` + * *Report file* process parameter/comment values are linked + to ``{Assay Name}`` + * *Raw Data Files* and *Derived Data Files* are linked to ``{Assay Name}`` +- Used with measurement type / technology type + * protein expression profiling / mass cytometry diff --git a/docs_manual/source/metadata_recording.rst b/docs_manual/source/metadata_recording.rst index a6c76a2a..3b3c7368 100644 --- a/docs_manual/source/metadata_recording.rst +++ b/docs_manual/source/metadata_recording.rst @@ -380,15 +380,22 @@ with appropriate ontology identifier and sources. 4. Uploading ============ -The upload/integration of metadata into a SODAR project can be facilitated by -CUBI (e.g. after validation) or any project member with appropriate rights -(owner, delegate, contributor). +Uploading metadata into a SODAR project can be facilitated by CUBI (e.g. after +validation) or any project member with appropriate role (owner, delegate, or +contributor). -All related ISA-Tab files need be bundled as a one-file zip archive. Then, in -the corresponding SODAR project go to **Sample Sheets**, **Sheet Operations**, -and **Add/Replace ISA-Tab** to upload the metadata. +To upload sample sheets into SODAR, first navigate into the **Sample Sheets** +application within the corresponding project. In the +:guilabel:`Sheet Operations` dropdown, select :guilabel:`Import ISA-Tab`. If you +are replacing existing sheets in the project, this option will appear as +:guilabel:`Replace ISA-Tab`. -After uploading, it is recommended to compare/validate the number of -study/assay rows between the SODAR project and ISA-Tab files to exclude +In the import form, the ISA-Tab TSV files can either be imported as separate +files, or a Zip archive containing all of the files in the same directory. + +.. include:: _include/sheets_zip_warning.rst + +After uploading, it is recommended to compare and validate the number of +study and assay rows between the SODAR project and ISA-Tab files to exclude mistakes in metadata recording, in particular with respect to splitting and pooling. diff --git a/docs_manual/source/sodar_release_notes.rst b/docs_manual/source/sodar_release_notes.rst index edf3c892..ab424b68 100644 --- a/docs_manual/source/sodar_release_notes.rst +++ b/docs_manual/source/sodar_release_notes.rst @@ -8,6 +8,39 @@ list of changes in current and previous releases, see the :ref:`full changelog`. +v0.14.0 (2023-09-27) +==================== + +Major feature update. + +- Add general read-only iRODS access tickets for assay collections +- Add support for additional sample sheet templates +- Add landing zone updating +- Add automated checksum calculation in landing zone validation and moving +- Add iRODS delete request REST API views +- Add iRODS delete request batch handling in UI +- Add iRODS access ticket REST API views +- Add iRODS environment retrieval REST API view +- Add cytof assay plugin +- Add "create" tag for sample sheet versions +- Add user setting for maximum sample sheet table height +- Add "normalizesheets" management command to clean up existing sample sheets +- Improve sheet template creation form +- Landingzones UI improvements +- Sample sheet table resizing and rendering improvements +- Add study table cache disabling +- Minor updates and bug fixes +- Upgrade to SODAR Core v0.13.2 +- SODAR Core v0.13 updates: full role inheritance, finder role, etc. + +Migration Guide +--------------- + +Upon deploying this release on an existing instance, admins must run the +``syncmodifyapi`` management command. This will update project user access in +iRODS according to the role inheritance update introduced in SODAR Core v0.13. + + v0.13.4 (2023-05-15) ==================== diff --git a/docs_manual/source/ui_alerts.rst b/docs_manual/source/ui_alerts.rst index 1d5327a2..0f46e639 100644 --- a/docs_manual/source/ui_alerts.rst +++ b/docs_manual/source/ui_alerts.rst @@ -26,10 +26,10 @@ viewing them. Alert badge on the title bar -The alert list will display your alerts in a chronological order with the newest -alert on top. The colour of the alert will define its type: blue for -information, green for a successful operation, yellow for a warning and red for -an error. +The "Active App Alerts" list will display your alerts in a chronological order +with the newest alert on top. The colour of the alert will define its type: blue +for information, green for a successful operation, yellow for a warning and red +for an error. The left hand side of each alert displays an icon for the application raising the alert as well as the alert timestamp. The name of the project is also @@ -39,17 +39,22 @@ project overview page. The right hand side of each alert contains one or two buttons. The arrow button directs you to the project view related to the alert message before dismissing the alert. The close button dismisses the alert and keeps you in the alert list -view. You can also click the :guilabel:`Dismiss All` button to dismiss all of -your alerts. +view. -You can also access the alert list by accessing the user dropdown on the top -right corner of the site and selecting :guilabel:`App Alerts`. +On the top right corner of the view you can find the +:guilabel:`Alert Operations` dropdown. From the dropdown, you can select +:guilabel:`View Dismissed` to see your previously dismissed alerts, or +:guilabel:`Dismiss All` to dismiss all currently active alerts. + +In addition to the alert badge, you can access the alert list by accessing the +user dropdown on the top right corner of the site and selecting +:guilabel:`App Alerts`. .. figure:: _static/sodar_ui/alert_list.png :align: center :scale: 70% - Alert list + List of active app alerts .. _ui_alerts_admin: diff --git a/docs_manual/source/ui_project_members.rst b/docs_manual/source/ui_project_members.rst index 95e8ec14..68a719f7 100644 --- a/docs_manual/source/ui_project_members.rst +++ b/docs_manual/source/ui_project_members.rst @@ -10,7 +10,7 @@ through this view. .. figure:: _static/sodar_ui/project_members.png :align: center - :scale: 50% + :scale: 60% Project members view @@ -18,26 +18,58 @@ through this view. Member Roles ============ -In SODAR, a single *role* at a time can be assigned to a user within a project. -The following types of roles are available: +In SODAR, a single *role* at a time can be assigned to a user within a category +or a project. Role assignment works similarly between categories and projects +except for special cases we will be detailed. With the exception of these +special cases, "project" will be used to refer to either a category or a +project. + +The following types of roles are available, ordered by descending +level of rights and functionality within each project: Project Owner - Full access to project, with the ability to assign roles including delegates - and transferring ownership to another user. + Full access to project data and functionality. Ability to assign roles, + including delegates. Can transfer ownership to another user. Project Delegate - Full access to project with the exception of modifying owner or delegate - roles. This role is assigned by a project owner. + Full access to project data and functionality, with the exception of + modifying owner or delegate roles. This role can only be assigned by a + project owner. Project Contributor - User with access to create and modify data within a project, e.g. uploading - files and editing sample sheets, with some limitations. For example, - modifying project meta data or user roles is not allowed. + User with access to create and modify data within a project. This includes + e.g. uploading files and editing sample sheets, with some limitations. For + example, modifying project metadata or user roles is not allowed. Project Guest Read-only access to project data. +Project Finder + A role assignable only to users in categories. A user with a finder role can + see child categories and projects along with their member lists without + gaining access to project data or project apps. This is usable for e.g. + members of staff in the organization maintaining the SODAR instance or a + specific category structure. This role allows them to see which categories + and projects exists and who manages them, in order to have a full picture of + the category and project structure. Actual project access can then be + requested from a project owner or delegate. + + +Role Inheritance +================ + +Roles are inherited from parent categories. A role inherited from a parent +category can be promoted for the current category or project. The promoted role +is then also inherited by child projects in case of a category. Demoting +inherited roles is not allowed. Inherited roles are marked in the member list +with a downwards arrow along with a link to the category from which the role is +inherited. + +Each project must have exactly one "local" (non-inherited) owner. The amount of +allowed delegates is set by the server administrators. The number of inherited +owners and delegates are not limited. For contributors and guests, the amount +per project or category is not limited. + +.. figure:: _static/sodar_ui/project_members_inherit.png + :align: center -Project owner roles are inherited from parent categories. One owner is allowed -per project, with the exception of inherited owners. The amount of allowed -delegates is set by the server administrators. For contributors and guests, -the amount per project is not limited. + Inherited member role Adding Members @@ -57,7 +89,7 @@ You are presented with a form to select a user and a role for them. The user field works as a search box, where you can start typing a person's name or email address and available options will be presented as you type. If the user is not yet a SODAR user, you can also type an email address and be redirected to the -separate invitation form. +member invitation form. .. figure:: _static/sodar_ui/project_members_add.png :align: center @@ -65,9 +97,9 @@ separate invitation form. Member adding form -If email is enabled on the SODAR server, an email notification is sent to the -user being added into the project. You can preview this email by clicking the -:guilabel:`Preview` button. To assign the role, click the :guilabel:`Add` +If email sending is enabled on the SODAR server, an email notification is sent +to the user being added into the project. You can preview this email by clicking +the :guilabel:`Preview` button. To assign the role, click the :guilabel:`Add` button. @@ -85,6 +117,20 @@ the membership from a user, you can click :guilabel:`Remove Member`. Member update dropdown +In case of an inherited member, you can see the :guilabel:`Promote Member` +option instead of the regular updating link. As described before, inherited +members can only be promoted to a higher role. The link opens a form similar to +user updating, only allowing you to select a role of higher rank than the +current inherited one. If you wish to demote an inherited user or remove their +access entirely, you should follow the inherited category link in the member +list and remove the role from a parent category. + +.. figure:: _static/sodar_ui/project_members_promote.png + :align: center + :scale: 80% + + Member promote dropdown + Modifying the project owner works slightly differently. In the dropdown next to the owner in the member list, you will see a :guilabel:`Transfer Ownership` option. This will present you a form where you can select a new owner from the @@ -99,7 +145,7 @@ functionality is only available for users currently set as the project owner. These dropdowns also contain a :guilabel:`History` link, which will take you to the :ref:`Timeline ` application to view the history of the -user's membership(s) in this project. +user's membership in this project. Inviting Members diff --git a/docs_manual/source/ui_project_overview.rst b/docs_manual/source/ui_project_overview.rst index 317ee327..e2bbe4b7 100644 --- a/docs_manual/source/ui_project_overview.rst +++ b/docs_manual/source/ui_project_overview.rst @@ -7,9 +7,9 @@ Once you navigate into a project, you can see the project overview on the right hand side of the screen. The sidebar on the left hand side has been expanded to display links related to the project. -Furthermore, you can see a navigation breadcrumb for quickly returning to a -parent category, as well as the project title bar displaying the project title, -description and some general links. +Below the site title bar there is a navigation breadcrumb. It can be used to +easily return to a parent category. The project title bar displays the project +title, description and project-specific links. .. figure:: _static/sodar_ui/project_detail.png :align: center diff --git a/docs_manual/source/ui_user_profile.rst b/docs_manual/source/ui_user_profile.rst index 58aa3b23..cf39797d 100644 --- a/docs_manual/source/ui_user_profile.rst +++ b/docs_manual/source/ui_user_profile.rst @@ -3,18 +3,23 @@ User Profile ^^^^^^^^^^^^ -The user profile screen displays information regarding your account. You can -modify global settings for your account by clicking the -:guilabel:`Update Settings` button. +The user profile screen displays information regarding your account. .. figure:: _static/sodar_ui/user_profile.png :align: center - :scale: 55% + :scale: 65% User profile view -The following user settings are available: +Through the user profile, you can modify global user-specific settings for your +account by clicking the :guilabel:`Update Settings` button. The following user +settings are available: +Sample Sheet Table Height + Choose the maximum height of study and assay tables in the sample sheets app + from a set of options. In browsing mode, table height will fit the table + content if the height of content is lower than the setting. In edit mode, + the chosen table height will be maintained regardless of content. Display Project UUID Copying Link Enabling this will add an icon next to the project title on each project view. Clicking it will copy the project identifier (UUID) into the @@ -24,3 +29,8 @@ Additional Email is enabled on the server, notification emails will be sent to these addresses in addition to the default user email. Separate multiple addresses with the semicolon character (``;``). + +If local users are enabled on the site and you have a local SODAR account, the +profile also includes the :guilabel:`Update User` button. This opens a form in +which you can update your details and password. This form is **not** available +for users authenticating with an existing user account via LDAP or SAML. diff --git a/irodsadmin/management/commands/irodsorphans.py b/irodsadmin/management/commands/irodsorphans.py index 76bf8e95..b90bae38 100644 --- a/irodsadmin/management/commands/irodsorphans.py +++ b/irodsadmin/management/commands/irodsorphans.py @@ -1,17 +1,20 @@ """Irodsorphans management command""" import re +import sys + +from itertools import chain from django.core.management.base import BaseCommand -from django.db.models import Q from django.template.defaultfilters import filesizeformat # Projectroles dependency from projectroles.management.logging import ManagementCommandLogger -from projectroles.models import Project +from projectroles.models import Project, PROJECT_TYPE_PROJECT from projectroles.plugins import get_backend_api # Landingzones dependency +from landingzones.constants import ZONE_STATUS_MOVED, ZONE_STATUS_DELETED from landingzones.models import LandingZone # Samplesheets dependency @@ -24,184 +27,274 @@ table_builder = SampleSheetTableBuilder() -def get_assay_collections(assays, irods_backend): - """Return a list of all assay collection names.""" - return [irods_backend.get_path(a) for a in assays] - - -def get_assay_subcollections(studies, irods_backend): - """Return a list of all assay row colletion names.""" - collections = [] - for study in studies: - try: - study_tables = table_builder.get_study_tables(study) - except Exception as ex: - logger.error( - 'Study table building exception for "{}" ' - 'in project "{}" ({}): {}'.format( - study.get_display_name(), - study.investigation.project.title, - study.investigation.project.sodar_uuid, - ex, - ) - ) - continue +# Local constants +DELETED = '' +ERROR = '' + + +class Command(BaseCommand): + """Command to find orphans in iRODS collections.""" + + help = 'Find orphans in iRODS project collections.' - for assay in study.assays.all(): - assay_table = study_tables['assays'][str(assay.sodar_uuid)] - assay_plugin = assay.get_plugin() - assay_path = irods_backend.get_path(assay) + def __init__(self): + super().__init__() + self.irods_backend = get_backend_api('omics_irods') - if assay_plugin: - for row in assay_table['table_data']: - row_path = assay_plugin.get_row_path( - row, assay_table, assay, assay_path + def _get_assay_collections(self, assays): + """Return a list of all assay collection names.""" + return [self.irods_backend.get_path(a) for a in assays] + + def _get_assay_subcollections(self, studies): + """Return a list of all assay row collection names.""" + collections = [] + for study in studies: + try: + study_tables = table_builder.get_study_tables(study) + except Exception as ex: + logger.error( + 'Study table building exception for "{}" ' + 'in project "{}" ({}): {}'.format( + study.get_display_name(), + study.investigation.project.title, + study.investigation.project.sodar_uuid, + ex, ) - if row_path not in collections: - collections.append(row_path) - shortcuts = assay_plugin.get_shortcuts(assay) - if shortcuts: - for shortcut in shortcuts: - collections.append(shortcut['path']) - - # Add default expected subcollections of assay collection - collections.append(assay_path + '/' + TRACK_HUBS_COLL) - collections.append(assay_path + '/' + RESULTS_COLL) - collections.append(assay_path + '/' + MISC_FILES_COLL) - return collections - - -def get_study_collections(studies, irods_backend): - """Return a list of all study collection names.""" - return [irods_backend.get_path(s) for s in studies] - - -def get_zone_collections(irods_backend): - """ - Return a list of all landing zone collection names that are not MOVED or - DELETED. - """ - return [ - irods_backend.get_path(lz) - for lz in LandingZone.objects.filter( - ~(Q(status='MOVED') & Q(status='DELETED')) + ) + continue + + for assay in study.assays.all(): + assay_table = study_tables['assays'][str(assay.sodar_uuid)] + assay_plugin = assay.get_plugin() + assay_path = self.irods_backend.get_path(assay) + + if assay_plugin: + for row in assay_table['table_data']: + row_path = assay_plugin.get_row_path( + row, assay_table, assay, assay_path + ) + if row_path not in collections: + collections.append(row_path) + shortcuts = assay_plugin.get_shortcuts(assay) + if shortcuts: + for shortcut in shortcuts: + collections.append(shortcut['path']) + + # Add default expected subcollections of assay collection + collections.append(assay_path + '/' + TRACK_HUBS_COLL) + collections.append(assay_path + '/' + RESULTS_COLL) + collections.append(assay_path + '/' + MISC_FILES_COLL) + return collections + + def _get_study_collections(self, studies): + """Return a list of all study collection names.""" + return [self.irods_backend.get_path(s) for s in studies] + + def _get_zone_collections(self): + """ + Return a list of all landing zone collection names that are not MOVED or + DELETED. + """ + return [ + self.irods_backend.get_path(lz) + for lz in LandingZone.objects.exclude( + status__in=[ZONE_STATUS_MOVED, ZONE_STATUS_DELETED] + ) + ] + + def _get_project_collections(self): + """Return a list of all study collection names.""" + return [ + self.irods_backend.get_path(p) + for p in Project.objects.all().order_by('full_title') + ] + + def _is_zone(self, collection): + """ + Check if a given collection matches the format of path to a landing zone + collection. + """ + projects_path = self.irods_backend.get_projects_path() + pattern = ( + r'^' + + projects_path + + r'/([a-f0-9]{2})/\1[a-f0-9]{6}-([a-f0-9]{4}-){3}[a-f0-9]{12}/' + r'landing_zones' + ) + return re.search(r'{}'.format(pattern), collection.path) and re.search( + r'^\d{8}_\d{6}', collection.name + ) + + def _is_assay_or_study(self, collection): + """ + Check if a given collection matches the format of path to a study or + assay collection. + """ + projects_path = self.irods_backend.get_projects_path() + pattern = ( + r'^' + + projects_path + + r'/([a-f0-9]{2})/\1[a-f0-9]{6}-([a-f0-9]{4}-){3}[a-f0-9]{12}/.*/' + r'(assay|study)_[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}$' + ) + return re.search(pattern, collection.path) + + def _is_assay_orphan(self, collection): + """ + Check if a given collection matches the format of path to a study or + assay orphan. + """ + projects_path = self.irods_backend.get_projects_path() + pattern = ( + r'^' + + projects_path + + r'/([a-f0-9]{2})/\1[a-f0-9]{6}-([a-f0-9]{4}-){3}[a-f0-9]{12}/.*/' + r'(assay|study)_[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}' ) - ] - - -def get_project_collections(irods_backend): - """Return a list of all study collection names.""" - return [irods_backend.get_path(p) for p in Project.objects.all()] - - -def is_zone(collection): - """ - Check if a given collection matches the format of path to a landing zone - collection. - """ - return '/landing_zones/' in collection.path and re.search( - r'^\d{8}_\d{6}', collection.name - ) - - -def is_assay_or_study(collection): - """ - Check if a given collection matches the format of path to a study or assay - collection. - """ - return re.match( - r'(assay|study)_[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}', - collection.name, - ) - - -def is_project(collection): - """ - Check if a given collection matches the format of path to a project - collection. - """ - return re.search( - r'projects/([a-f0-9]{2})/\1[a-f0-9]{6}-([a-f0-9]{4}-){3}[a-f0-9]{12}$', - collection.path, - ) - - -def get_orphans(irods, irods_backend, expected, assays): - """ - Return a list of orphans in a given irods session that are not in a given - list of expected collections. - """ - orphans = [] - collections = irods.collections.get('/{}/projects'.format(irods.zone)) - - for collection in irods_backend.get_colls_recursively(collections): - if ( - is_zone(collection) - or is_assay_or_study(collection) - or is_project(collection) - ): - if collection.path not in expected: - orphans.append(collection.path) - - for assay in assays: - if not assay.get_plugin(): - continue - with irods_backend.get_session() as irods: - for collection in irods_backend.get_child_colls( - irods, irods_backend.get_path(assay) + return re.search(pattern, collection.path) + + def _is_project(self, projects_path, collection): + """ + Check if a given collection matches the format of path to a project + collection under the projects path. + """ + pattern = ( + r'^' + + projects_path + + r'/([a-f0-9]{2})/\1[a-f0-9]{6}-([a-f0-9]{4}-){3}[a-f0-9]{12}$' + ) + return re.search(r'{}'.format(pattern), collection.path) + + def _sort_colls_on_projects(self, collections): + """Helper function to sort collections based on project list""" + colls_with_project = [] + colls_no_project = [] + temp_paths = [] + + # Create a set of valid project paths based on project UUIDs + valid_project_paths = [ + self.irods_backend.get_path(p) + for p in Project.objects.filter(type=PROJECT_TYPE_PROJECT).order_by( + 'full_title' + ) + ] + + # Get the actual path to the projects collection + project_path = self.irods_backend.get_projects_path() + depth = len(project_path.split('/')) + 1 + for coll in collections: + pattern = ( + r'^' + + project_path + + r'/([a-f0-9]{2})/\1[a-f0-9]{6}-([a-f0-9]{4}-){3}[a-f0-9]{12}' + ) + match = re.search(r'{}'.format(pattern), coll.path) + uuid = match.string.split('/')[depth] if match else '' + if ( + uuid + and any(uuid in path for path in valid_project_paths) + and coll.path not in temp_paths ): - if collection.path not in expected: - orphans.append(collection.path) - return orphans + colls_with_project.append(coll) + temp_paths.append(coll.path) + elif coll.path not in temp_paths: + colls_no_project.append(coll) + temp_paths.append(coll.path) + + # Sort collections with project path based on project list + sorted_colls = sorted( + colls_with_project, + key=lambda coll: next( + ( + i + for i, path in enumerate(valid_project_paths) + if ( + coll.path.split('/')[depth] + if len(coll.path.split('/')) > depth + else '' + ) + in path + ), + float('inf'), + ), + ) + return sorted_colls + colls_no_project + + def _get_orphans(self, irods, expected, assays): + """ + Return a list of orphans in a given irods session that are not in a given + list of expected collections. + """ + # Get a sorted list of all project collections + project_collections = sorted( + self.irods_backend.get_colls_recursively( + irods.collections.get('/{}/projects'.format(irods.zone)) + ), + key=lambda coll: coll.path, + ) + assay_collections = list( + chain.from_iterable( + self.irods_backend.get_child_colls( + irods, self.irods_backend.get_path(a) + ) + for a in assays + if a.get_plugin() + ) + ) + assay_coll_paths = [coll.path for coll in assay_collections] + # Sort collections by project full_title + sorted_collections = self._sort_colls_on_projects( + project_collections + assay_collections + ) -def get_output(orphans, irods_backend, irods): - lines = [] - for orphan in orphans: - stats = irods_backend.get_object_stats(irods, orphan) - m = re.search(r'/projects/([^/]{2})/(\1[^/]+)', orphan) + projects_path = self.irods_backend.get_projects_path() + for collection in sorted_collections: + if ( + self._is_zone(collection) + or self._is_assay_or_study(collection) + or self._is_project(projects_path, collection) + or collection.path in assay_coll_paths + ) and collection.path not in expected: + self._write_orphan(collection.path, irods) + + def _write_orphan(self, path, irods): + stats = self.irods_backend.get_object_stats(irods, path) + projects_path = self.irods_backend.get_projects_path() + pattern = projects_path + r'/([^/]{2})/(\1[^/]+)' + m = re.search(pattern, path) if m: uuid = m.group(2) try: project = Project.objects.get(sodar_uuid=uuid) title = project.full_title except Project.DoesNotExist: - title = '' + title = DELETED else: - uuid = '' - title = '' - lines.append( + uuid = ERROR + title = ERROR + sys.stdout.write( ';'.join( [ uuid, title, - orphan, + path, str(stats['file_count']), filesizeformat(stats['total_size']).replace(u'\xa0', ' '), ] ) + + '\n' ) - return lines - - -class Command(BaseCommand): - """Command to find orphans in iRODS collections.""" - - help = 'Find orphans in iRODS project collections.' def handle(self, *args, **options): - irods_backend = get_backend_api('omics_irods') studies = list(Study.objects.all()) - assays = list(Assay.objects.all()) + assays = list(Assay.objects.all().order_by()) expected = ( - *get_assay_collections(assays, irods_backend), - *get_study_collections(studies, irods_backend), - *get_zone_collections(irods_backend), - *get_project_collections(irods_backend), - *get_assay_subcollections(studies, irods_backend), + *self._get_assay_collections(assays), + *self._get_study_collections(studies), + *self._get_zone_collections(), + *self._get_project_collections(), + *self._get_assay_subcollections(studies), ) - with irods_backend.get_session() as irods: - orphans = get_orphans(irods, irods_backend, expected, assays) - output = get_output(orphans, irods_backend, irods) - if output: - self.stdout.write('\n'.join(output)) + with self.irods_backend.get_session() as irods: + self._get_orphans(irods, expected, assays) diff --git a/irodsadmin/tests/test_commands.py b/irodsadmin/tests/test_commands.py index fe1a4cb3..56fdc713 100644 --- a/irodsadmin/tests/test_commands.py +++ b/irodsadmin/tests/test_commands.py @@ -2,6 +2,7 @@ import io import os +import sys import uuid from django.core.management import call_command @@ -10,9 +11,12 @@ # Projectroles dependency from projectroles.constants import SODAR_CONSTANTS -from projectroles.models import Role from projectroles.plugins import get_backend_api -from projectroles.tests.test_models import ProjectMixin, RoleAssignmentMixin +from projectroles.tests.test_models import ( + ProjectMixin, + RoleMixin, + RoleAssignmentMixin, +) # Landingzones dependency from landingzones.tests.test_models import LandingZoneMixin @@ -20,40 +24,45 @@ # Samplesheets dependency from samplesheets.tests.test_io import SampleSheetIOMixin, SHEET_DIR -from irodsadmin.management.commands import irodsorphans +from irodsadmin.management.commands.irodsorphans import Command, DELETED # SODAR constants PROJECT_ROLE_OWNER = SODAR_CONSTANTS['PROJECT_ROLE_OWNER'] +PROJECT_TYPE_CATEGORY = SODAR_CONSTANTS['PROJECT_TYPE_CATEGORY'] PROJECT_TYPE_PROJECT = SODAR_CONSTANTS['PROJECT_TYPE_PROJECT'] # Local constants SHEET_PATH = SHEET_DIR + 'i_small.zip' ZONE_TITLE = '20180503_172456_test_zone' ZONE_DESC = 'description' +DUMMY_UUID = '11111111-1111-1111-1111-111111111111' +# TODO: Modify this to use taskflow test base class TestIrodsOrphans( - ProjectMixin, SampleSheetIOMixin, - RoleAssignmentMixin, LandingZoneMixin, + RoleAssignmentMixin, + ProjectMixin, + RoleMixin, TestCase, ): """Tests for the irodsorphans management command""" def setUp(self): - super().setUp() + # Init roles + self.init_roles() # Init super user self.user = self.make_user('user') self.user.is_superuser = True self.user.is_staff = True self.user.save() - # Init roles - self.role_owner = Role.objects.get_or_create(name=PROJECT_ROLE_OWNER)[0] # Init project with owner self.project = self.make_project( - 'TestProject', PROJECT_TYPE_PROJECT, None + 'TestProject', + PROJECT_TYPE_PROJECT, + None, ) self.owner_as = self.make_assignment( self.project, self.user, self.role_owner @@ -87,18 +96,14 @@ def setUp(self): self.irods_backend.get_path(self.landing_zone) ) + # Set up the command + self.irodsorphans = Command() self.expected_collections = ( - *irodsorphans.get_assay_collections( - [self.assay], self.irods_backend - ), - *irodsorphans.get_study_collections( - [self.study], self.irods_backend - ), - *irodsorphans.get_zone_collections(self.irods_backend), - *irodsorphans.get_project_collections(self.irods_backend), - *irodsorphans.get_assay_subcollections( - [self.study], self.irods_backend - ), + *self.irodsorphans._get_assay_collections([self.assay]), + *self.irodsorphans._get_study_collections([self.study]), + *self.irodsorphans._get_zone_collections(), + *self.irodsorphans._get_project_collections(), + *self.irodsorphans._get_assay_subcollections([self.study]), ) def tearDown(self): @@ -108,35 +113,41 @@ def tearDown(self): self.irods.cleanup() super().tearDown() + @staticmethod + def catch_stdout(): + """Catch stdout from irodsorphans management command""" + out = io.StringIO() + sys.stdout = out + call_command('irodsorphans', stdout=out) + output = out.getvalue() + sys.stdout = sys.__stdout__ + return output + def test_get_assay_collections(self): """Test get_assay_collections()""" self.assertListEqual( - irodsorphans.get_assay_collections( - [self.assay], self.irods_backend - ), + self.irodsorphans._get_assay_collections([self.assay]), [self.irods_backend.get_path(self.assay)], ) def test_get_study_collections(self): """Test get_study_collections()""" self.assertListEqual( - irodsorphans.get_study_collections( - [self.study], self.irods_backend - ), + self.irodsorphans._get_study_collections([self.study]), [self.irods_backend.get_path(self.study)], ) def test_get_zone_collections(self): """Test get_zone_collections()""" self.assertListEqual( - irodsorphans.get_zone_collections(self.irods_backend), + self.irodsorphans._get_zone_collections(), [self.irods_backend.get_path(self.landing_zone)], ) def test_get_project_collections(self): """Test get_project_collections()""" self.assertListEqual( - irodsorphans.get_project_collections(self.irods_backend), + self.irodsorphans._get_project_collections(), [self.irods_backend.get_path(self.project)], ) @@ -144,9 +155,7 @@ def test_get_assay_subcollections(self): """Test get_assay_subcollections()""" assay_path = self.irods_backend.get_path(self.assay) self.assertListEqual( - irodsorphans.get_assay_subcollections( - [self.study], self.irods_backend - ), + self.irodsorphans._get_assay_subcollections([self.study]), [ assay_path + '/0815-N1-DNA1', assay_path + '/0815-T1-DNA1', @@ -161,54 +170,59 @@ def test_is_zone(self): collection = self.irods.collections.get( self.irods_backend.get_path(self.landing_zone) ) - self.assertTrue(irodsorphans.is_zone(collection)) + self.assertTrue(self.irodsorphans._is_zone(collection)) def test_is_assay_or_study_with_assay(self): """Test is_assay_or_study() with assay""" collection = self.irods.collections.get( self.irods_backend.get_path(self.assay) ) - self.assertTrue(irodsorphans.is_assay_or_study(collection)) + self.assertTrue(self.irodsorphans._is_assay_or_study(collection)) def test_is_assay_or_study_with_study(self): """Test is_assay_or_study() with study""" collection = self.irods.collections.get( self.irods_backend.get_path(self.study) ) - self.assertTrue(irodsorphans.is_assay_or_study(collection)) + self.assertTrue(self.irodsorphans._is_assay_or_study(collection)) def test_is_project(self): """Test is_project()""" collection = self.irods.collections.get( self.irods_backend.get_path(self.project) ) - self.assertTrue(irodsorphans.is_project(collection)) + projects_path = self.irods_backend.get_projects_path() + self.assertTrue( + self.irodsorphans._is_project(projects_path, collection) + ) def test_is_zone_invalid(self): """Test is_zone() with a non-landingzone collection""" collection = self.irods.collections.get( self.irods_backend.get_path(self.project) ) - self.assertFalse(irodsorphans.is_zone(collection)) + self.assertFalse(self.irodsorphans._is_zone(collection)) def test_is_assay_or_study_invalid(self): """Test is_assay_or_study() with non-assay/study collection""" collection = self.irods.collections.get( self.irods_backend.get_path(self.project) ) - self.assertFalse(irodsorphans.is_assay_or_study(collection)) + self.assertFalse(self.irodsorphans._is_assay_or_study(collection)) def test_get_orphans_none(self): """Test get_orphans() with no orphans available""" - self.assertListEqual( - irodsorphans.get_orphans( - self.irods, - self.irods_backend, - self.expected_collections, - [self.assay], - ), - [], + # Capture stdout + out = io.StringIO() + sys.stdout = out + self.irodsorphans._get_orphans( + self.irods, + self.expected_collections, + [self.assay], ) + output = out.getvalue() + sys.stdout = sys.__stdout__ + self.assertEqual(output, '') def test_get_orphans_assay(self): """Test get_orphans() with orphan assay""" @@ -216,15 +230,25 @@ def test_get_orphans_assay(self): self.irods_backend.get_path(self.study), str(uuid.uuid4()) ) self.irods.collections.create(orphan_path) - self.assertListEqual( - irodsorphans.get_orphans( - self.irods, - self.irods_backend, - self.expected_collections, - [self.assay], - ), - [orphan_path], + # Capture stdout + out = io.StringIO() + sys.stdout = out + self.irodsorphans._get_orphans( + self.irods, + self.expected_collections, + [self.assay], + ) + output = out.getvalue() + sys.stdout = sys.__stdout__ + expected = ( + str(self.project.sodar_uuid) + + ';' + + self.project.title + + ';' + + orphan_path + + ';0;0 bytes\n' ) + self.assertEqual(output, expected) def test_get_orphans_study(self): """Test get_orphans() with orphan study""" @@ -232,17 +256,27 @@ def test_get_orphans_study(self): self.irods_backend.get_path(self.project), str(uuid.uuid4()) ) self.irods.collections.create(orphan_path) - self.assertListEqual( - irodsorphans.get_orphans( - self.irods, - self.irods_backend, - self.expected_collections, - [self.assay], - ), - [orphan_path], + # Capture stdout + out = io.StringIO() + sys.stdout = out + self.irodsorphans._get_orphans( + self.irods, + self.expected_collections, + [self.assay], + ) + output = out.getvalue() + sys.stdout = sys.__stdout__ + expected = ( + str(self.project.sodar_uuid) + + ';' + + self.project.title + + ';' + + orphan_path + + ';0;0 bytes\n' ) + self.assertEqual(output, expected) - def test_get_orphans_zone(self): + def test_get_output_zone(self): """Test get_orphans() with orphan landing zone""" collection = '20201031_123456' orphan_path = '{}/landing_zones/{}/{}/{}'.format( @@ -252,17 +286,28 @@ def test_get_orphans_zone(self): collection, ) self.irods.collections.create(orphan_path) - self.assertListEqual( - irodsorphans.get_orphans( - self.irods, - self.irods_backend, - self.expected_collections, - [self.assay], - ), - [orphan_path], + + # Capture stdout + out = io.StringIO() + sys.stdout = out + self.irodsorphans._get_orphans( + self.irods, + self.expected_collections, + [self.assay], + ) + output = out.getvalue() + sys.stdout = sys.__stdout__ + expected = ( + str(self.project.sodar_uuid) + + ';' + + self.project.title + + ';' + + orphan_path + + ';0;0 bytes\n' ) + self.assertEqual(output, expected) - def test_get_orphans_project(self): + def test_get_output_project(self): """Test get_orphans() with orphan project""" collection = 'aa/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' orphan_path = '{}/{}'.format( @@ -272,55 +317,49 @@ def test_get_orphans_project(self): collection, ) self.irods.collections.create(orphan_path) - self.assertListEqual( - irodsorphans.get_orphans( - self.irods, - self.irods_backend, - self.expected_collections, - [self.assay], - ), - [orphan_path], + + # Capture stdout + out = io.StringIO() + sys.stdout = out + self.irodsorphans._get_orphans( + self.irods, + self.expected_collections, + [self.assay], + ) + output = out.getvalue() + sys.stdout = sys.__stdout__ + expected = ( + collection[3:] + ';' + DELETED + ';' + orphan_path + ';0;0 bytes\n' ) + self.assertEqual(output, expected) - def test_get_orphans_assay_subs(self): + def test_get_output_assay_subs(self): """Test get_orphans() with orphan assay subcollections""" collection = 'UnexpectedCollection' orphan_path = '{}/{}'.format( self.irods_backend.get_path(self.assay), collection ) self.irods.collections.create(orphan_path) - self.assertListEqual( - irodsorphans.get_orphans( - self.irods, - self.irods_backend, - self.expected_collections, - [self.assay], - ), - [orphan_path], - ) - def test_get_output(self): - """Test get_output()""" - orphan_path = '{}/assay_{}'.format( - self.irods_backend.get_path(self.study), str(uuid.uuid4()) - ) - self.irods.collections.create(orphan_path) - orphans = irodsorphans.get_orphans( + # Capture stdout + out = io.StringIO() + sys.stdout = out + self.irodsorphans._get_orphans( self.irods, - self.irods_backend, self.expected_collections, [self.assay], ) - self.assertListEqual( - irodsorphans.get_output(orphans, self.irods_backend, self.irods), - [ - '{};{};{};0;0 bytes'.format( - str(self.project.sodar_uuid), - self.project.full_title, - orphan_path, - ) - ], + output = out.getvalue() + sys.stdout = sys.__stdout__ + expected = ( + str(self.project.sodar_uuid) + + ';' + + self.project.title + + ';' + + orphan_path + + ';0;0 bytes\n' ) + self.assertEqual(output, expected) def test_get_output_deleted_project(self): """Test get_output() with a deleted project""" @@ -334,27 +373,29 @@ def test_get_output_deleted_project(self): ) self.irods.collections.create(orphan_path) - orphans = irodsorphans.get_orphans( + # Capture stdout + out = io.StringIO() + sys.stdout = out + self.irodsorphans._get_orphans( self.irods, - self.irods_backend, self.expected_collections, [self.assay], ) - self.assertListEqual( - irodsorphans.get_output(orphans, self.irods_backend, self.irods), - [ - '{};;{};0;0 bytes'.format( - project_uuid, - orphan_path, - ) - ], + output = out.getvalue() + sys.stdout = sys.__stdout__ + expected = ( + project_uuid + ';' + DELETED + ';' + orphan_path + ';0;0 bytes\n' ) + self.assertEqual(output, expected) def test_command_no_orphans(self): """Test command with no orphans""" out = io.StringIO() + sys.stdout = out call_command('irodsorphans', stdout=out) - self.assertEqual('', out.getvalue()) + output = out.getvalue() + sys.stdout = sys.__stdout__ + self.assertEqual(output, '') def test_command_orphan_assay(self): """Test command with orphan assay""" @@ -362,14 +403,13 @@ def test_command_orphan_assay(self): self.irods_backend.get_path(self.study), str(uuid.uuid4()) ) self.irods.collections.create(orphan_path) - out = io.StringIO() - call_command('irodsorphans', stdout=out) + output = self.catch_stdout() expected = '{};{};{};0;0 bytes\n'.format( str(self.project.sodar_uuid), self.project.full_title, orphan_path, ) - self.assertEqual(expected, out.getvalue()) + self.assertEqual(output, expected) def test_command_orphan_study(self): """Test command with orphan study""" @@ -377,14 +417,13 @@ def test_command_orphan_study(self): self.irods_backend.get_path(self.project), str(uuid.uuid4()) ) self.irods.collections.create(orphan_path) - out = io.StringIO() - call_command('irodsorphans', stdout=out) + output = self.catch_stdout() expected = '{};{};{};0;0 bytes\n'.format( str(self.project.sodar_uuid), self.project.full_title, orphan_path, ) - self.assertEqual(expected, out.getvalue()) + self.assertEqual(output, expected) def test_command_orphan_zone(self): """Test command with orphan landing zone""" @@ -396,14 +435,13 @@ def test_command_orphan_zone(self): collection, ) self.irods.collections.create(orphan_path) - out = io.StringIO() - call_command('irodsorphans', stdout=out) + output = self.catch_stdout() expected = '{};{};{};0;0 bytes\n'.format( str(self.project.sodar_uuid), self.project.full_title, orphan_path, ) - self.assertEqual(expected, out.getvalue()) + self.assertEqual(output, expected) def test_command_orphan_project(self): """Test command with orphan project""" @@ -416,12 +454,11 @@ def test_command_orphan_project(self): collection, ) self.irods.collections.create(orphan_path) - out = io.StringIO() - call_command('irodsorphans', stdout=out) - expected = '{};;{};0;0 bytes\n'.format( - project_uuid, orphan_path + output = self.catch_stdout() + expected = '{};{};{};0;0 bytes\n'.format( + project_uuid, DELETED, orphan_path ) - self.assertEqual(expected, out.getvalue()) + self.assertEqual(output, expected) def test_command_orphan_assay_sub(self): """Test command with orphan assay subcollection""" @@ -430,14 +467,13 @@ def test_command_orphan_assay_sub(self): self.irods_backend.get_path(self.assay), collection ) self.irods.collections.create(orphan_path) - out = io.StringIO() - call_command('irodsorphans', stdout=out) + output = self.catch_stdout() expected = '{};{};{};0;0 bytes\n'.format( str(self.project.sodar_uuid), self.project.full_title, orphan_path, ) - self.assertEqual(expected, out.getvalue()) + self.assertEqual(output, expected) def test_command_multiple(self): """Test command with multiple orphans""" @@ -453,8 +489,7 @@ def test_command_multiple(self): collection, ) self.irods.collections.create(orphan_path2) - out = io.StringIO() - call_command('irodsorphans', stdout=out) + output = self.catch_stdout() expected = '{};{};{};0;0 bytes\n'.format( str(self.project.sodar_uuid), self.project.full_title, @@ -465,4 +500,31 @@ def test_command_multiple(self): self.project.full_title, orphan_path, ) - self.assertEqual(expected, out.getvalue()) + self.assertEqual(output, expected) + + def test_command_ordering(self): + """Test ordering of orphans in command output""" + orphan_path = '{}/sample_data/study_{}'.format( + self.irods_backend.get_path(self.project), str(uuid.uuid4()) + ) + self.irods.collections.create(orphan_path) + + project2 = self.make_project('TestProject2', PROJECT_TYPE_PROJECT, None) + self.make_assignment(project2, self.user, self.role_owner) + orphan_path2 = '{}/sample_data/study_{}'.format( + self.irods_backend.get_path(project2), str(uuid.uuid4()) + ) + self.irods.collections.create(orphan_path2) + + output = self.catch_stdout() + expected = '{};{};{};0;0 bytes\n'.format( + str(self.project.sodar_uuid), + self.project.full_title, + orphan_path, + ) + expected += '{};{};{};0;0 bytes\n'.format( + str(project2.sodar_uuid), + project2.full_title, + orphan_path2, + ) + self.assertEqual(output, expected) diff --git a/irodsbackend/api.py b/irodsbackend/api.py index ee6e3806..aa00807b 100644 --- a/irodsbackend/api.py +++ b/irodsbackend/api.py @@ -2,6 +2,7 @@ import logging import math +import os import random import re import string @@ -53,7 +54,8 @@ 'irods_encryption_salt_size', 'irods_port', ] -USER_GROUP_PREFIX = 'omics_project_' +USER_GROUP_TEMPLATE = 'omics_project_{uuid}' +TRASH_COLL_NAME = 'trash' PATH_PARENT_SUBSTRING = '/..' ERROR_PATH_PARENT = 'Use of parent not allowed in path' ERROR_PATH_UNSET = 'Path is not set' @@ -146,7 +148,7 @@ def _send_request(cls, irods, api_id, *args): :return: Response :raise: Exception if iRODS is not initialized """ - msg_body = TicketAdminRequest(irods)(*args) + msg_body = TicketAdminRequest(*args) msg = iRODSMessage( 'RODS_API_REQ', msg=msg_body, int_info=api_number[api_id] ) @@ -350,6 +352,11 @@ def get_projects_path(cls): """Return the SODAR projects collection path""" return cls.get_root_path() + '/projects' + @classmethod + def get_trash_path(cls): + """Return the trash path in the current zone""" + return '/' + os.path.join(settings.IRODS_ZONE, TRASH_COLL_NAME) + @classmethod def get_uuid_from_path(cls, path, obj_type): """ @@ -393,7 +400,7 @@ def get_user_group_name(cls, project): else: cls._validate_project(project) project_uuid = project.sodar_uuid - return '{}{}'.format(USER_GROUP_PREFIX, project_uuid) + return USER_GROUP_TEMPLATE.format(uuid=project_uuid) # TODO: Add tests @classmethod diff --git a/irodsbackend/plugins.py b/irodsbackend/plugins.py index 742fe2a2..a9e57f68 100644 --- a/irodsbackend/plugins.py +++ b/irodsbackend/plugins.py @@ -78,6 +78,9 @@ def get_statistics(self): project_stats = irods_backend.get_object_stats( irods, irods_backend.get_projects_path() ) + trash_stats = irods_backend.get_object_stats( + irods, irods_backend.get_trash_path() + ) except Exception: return {} return { @@ -86,5 +89,9 @@ def get_statistics(self): 'value': filesizeformat(project_stats['total_size']), 'description': 'Total file size including sample repositories ' 'and landing zones.', - } + }, + 'irods_trash_size': { + 'label': 'Data in iRODS Trash', + 'value': filesizeformat(trash_stats['total_size']), + }, } diff --git a/irodsbackend/static/irodsbackend/js/irodsbackend.js b/irodsbackend/static/irodsbackend/js/irodsbackend.js index 1f6a1798..c2c88e04 100644 --- a/irodsbackend/static/irodsbackend/js/irodsbackend.js +++ b/irodsbackend/static/irodsbackend/js/irodsbackend.js @@ -24,7 +24,7 @@ var updateCollectionStats = function() { method: 'POST', dataType: 'json', data: d, - contentType: "application/x-www-form-urlencoded; charset=UTF-8", //should be default + contentType: "application/x-www-form-urlencoded; charset=UTF-8", traditional: true }).done(function (data) { $('span.sodar-irods-stats').each(function () { @@ -52,7 +52,6 @@ var updateCollectionStats = function() { } }; - /*************************************** Toggling buttons in one row function ***************************************/ @@ -158,35 +157,38 @@ var updateButtons = function() { } }; +/************************ + Copy path display method +*************************/ +function displayCopyStatus(elem) { + elem.addClass('text-warning'); + var realTitle = elem.tooltip().attr('data-original-title'); + elem.attr('title', 'Copied!') + .tooltip('_fixTitle') + .tooltip('show') + .attr('title', realTitle) + .tooltip('_fixTitle'); + elem.delay(250).queue(function() { + elem.removeClass('text-warning').dequeue(); + }); +} +/********************** + Modal copy path method + **********************/ +function copyModalPath(path, id) { + navigator.clipboard.writeText(path); + displayCopyStatus($('#' + id)); +} $(document).ready(function() { - /*************** - Init Clipboards - ***************/ + // Init Clipboards new ClipboardJS('.sodar-irods-copy-btn'); - - /****************** - Copy link handling - ******************/ + // Add copy link handler $('.sodar-irods-copy-btn').click(function () { - $(this).addClass('text-warning'); - if ($(this).attr('data-table') !== '1') { - var realTitle = $(this).tooltip().attr('data-original-title'); - $(this).attr('title', 'Copied!') - .tooltip('_fixTitle') - .tooltip('show') - .attr('title', realTitle) - .tooltip('_fixTitle'); - } - $(this).delay(250).queue(function() { - $(this).removeClass('text-warning').dequeue(); - }); + displayCopyStatus($(this)); }); - - /*********************** - Update collection stats - ***********************/ + // Update collection stats updateCollectionStats(); if ($('table.sodar-lz-table').length === 0) { updateButtons(); @@ -197,7 +199,6 @@ $(document).ready(function() { statsSec = window.irodsbackendStatusInterval; } var statsInterval = statsSec * 1000; - // Poll and update active collections setInterval(function () { if ($('table.sodar-lz-table').length === 0) { @@ -206,35 +207,21 @@ $(document).ready(function() { updateCollectionStats(); }, statsInterval); - /*************** - Link list Popup - ***************/ + // Collection dir list modal $('.sodar-irods-popup-list-btn').click(function() { var listUrl = $(this).attr('data-list-url'); var irodsPath = $(this).attr('data-irods-path'); var irodsPathLength = irodsPath.split('/').length; var webDavUrl = $(this).attr('data-webdav-url'); var body = ''; - var showChecksumCol = false; - if (typeof(window.irodsShowChecksumCol) !== 'undefined') { - showChecksumCol = window.irodsShowChecksumCol; - } - $('.modal-title').text('Files in iRODS: ' + irodsPath.split('/').pop()); - $.ajax({ - url: listUrl, - method: 'GET', - dataType: 'json' - }).done(function (data) { - // console.log(data); // DEBUG - + $.ajax({url: listUrl, method: 'GET', dataType: 'json'}).done(function (data) { + // console.log(data); // DEBUG if (data['irods_data'].length > 0) { body += ''; body += ''; - if (showChecksumCol === true) { - body += ''; - } + body += ''; body += ''; $.each(data['irods_data'], function (i, obj) { @@ -248,31 +235,35 @@ $(document).ready(function() { objLink += obj['name'] + ''; var colSpan = '1'; - var icon = 'mdi:file-document-outline'; - var toolTip = 'File'; - if (obj['type'] === 'coll') { - colSpan = '4'; - icon = 'mdi:folder-open'; - toolTip = 'Collection'; - } - var iconHtml = ''; - + var icon = obj['type'] === 'coll' ? 'mdi:folder-open' : 'mdi:file-document-outline'; + var toolTip = obj['type'] === 'coll' ? 'Collection' : 'File'; + var elemId = 'sodar-irods-copy-btn-' + i.toString(); + var copyButton = ''; + var iconHtml = ''; body += ''; + if (obj['type'] === 'obj') { body += ''; body += ''; + } else { + body += ''; } + body += ''; body += ''; }); } else { @@ -296,7 +287,6 @@ $(document).ready(function() { $('#sodar-modal-wait').modal('hide'); $('#sodar-modal').modal('show'); }); - // Set waiting content and toggle modal $('#sodar-modal-wait').modal('show'); }); diff --git a/irodsbackend/templates/irodsbackend/_irods_buttons.html b/irodsbackend/templates/irodsbackend/_irods_buttons.html index c29d95ec..5c96a405 100644 --- a/irodsbackend/templates/irodsbackend/_irods_buttons.html +++ b/irodsbackend/templates/irodsbackend/_irods_buttons.html @@ -7,7 +7,6 @@ {# irods_colls: Whether iRODS collections exist (boolean) #} {# irods_path: full iRODS path to link to #} {# list_url: SODAR URL for querying file list #} -{# data_table: Whether the file is included within a data table (boolean) #} {# show_file_list: Whether to show the file list popup (boolean) #} {# disable_all: Show but disable buttons if True (boolean) #} @@ -23,9 +22,7 @@ data-webdav-url="{{ irods_webdav_url }}" role="submit" {# NOTE: Modal not triggered here as data is async, see JQuery #} - {% if not data_table %} - data-tooltip="tooltip" data-placement="top" - {% endif %} + data-tooltip="tooltip" data-placement="top" title="List files" {% if not irods_colls or disable_all %} disabled{% endif %}> @@ -34,10 +31,7 @@ - diff --git a/irodsbackend/tests/test_api.py b/irodsbackend/tests/test_api.py index 0a0c1554..01db8615 100644 --- a/irodsbackend/tests/test_api.py +++ b/irodsbackend/tests/test_api.py @@ -6,8 +6,12 @@ from test_plus.test import TestCase # Projectroles dependency -from projectroles.models import Role, SODAR_CONSTANTS -from projectroles.tests.test_models import ProjectMixin, RoleAssignmentMixin +from projectroles.models import SODAR_CONSTANTS +from projectroles.tests.test_models import ( + ProjectMixin, + RoleMixin, + RoleAssignmentMixin, +) # Samplesheets dependency from samplesheets.tests.test_io import SampleSheetIOMixin, SHEET_DIR @@ -17,13 +21,13 @@ from irodsbackend.api import ( IrodsAPI, - USER_GROUP_PREFIX, + USER_GROUP_TEMPLATE, ERROR_PATH_PARENT, ERROR_PATH_UNSET, ) -# Global constants +# SODAR constants PROJECT_ROLE_OWNER = SODAR_CONSTANTS['PROJECT_ROLE_OWNER'] PROJECT_ROLE_DELEGATE = SODAR_CONSTANTS['PROJECT_ROLE_DELEGATE'] PROJECT_ROLE_CONTRIBUTOR = SODAR_CONSTANTS['PROJECT_ROLE_CONTRIBUTOR'] @@ -47,10 +51,11 @@ class TestIrodsbackendAPI( - ProjectMixin, - RoleAssignmentMixin, SampleSheetIOMixin, LandingZoneMixin, + ProjectMixin, + RoleMixin, + RoleAssignmentMixin, TestCase, ): """Tests for the API in the irodsbackend app""" @@ -60,14 +65,7 @@ def setUp(self): self.user = self.make_user('user') self.user.save() # Init roles - self.role_owner = Role.objects.get_or_create(name=PROJECT_ROLE_OWNER)[0] - self.role_delegate = Role.objects.get_or_create( - name=PROJECT_ROLE_DELEGATE - )[0] - self.role_contributor = Role.objects.get_or_create( - name=PROJECT_ROLE_CONTRIBUTOR - )[0] - self.role_guest = Role.objects.get_or_create(name=PROJECT_ROLE_GUEST)[0] + self.init_roles() # Init project with owner self.project = self.make_project( 'TestProject', PROJECT_TYPE_PROJECT, None @@ -75,12 +73,10 @@ def setUp(self): self.owner_as = self.make_assignment( self.project, self.user, self.role_owner ) - # Import investigation self.investigation = self.import_isa_from_file(SHEET_PATH, self.project) self.study = self.investigation.studies.first() self.assay = self.study.assays.first() - # Create LandingZone self.landing_zone = self.make_landing_zone( title=ZONE_TITLE, @@ -91,7 +87,6 @@ def setUp(self): configuration=None, config_data={}, ) - self.irods_backend = IrodsAPI() def test_format_env(self): @@ -137,7 +132,7 @@ def test_sanitize_path(self): self.assertEqual(ex, ERROR_PATH_PARENT) def test_get_path_project(self): - """Test get_irods_path() with a Project object""" + """Test get_irods_path() with Project object""" expected = '/{zone}/projects/{uuid_prefix}/{uuid}'.format( zone=IRODS_ZONE, uuid_prefix=str(self.project.sodar_uuid)[:2], @@ -148,7 +143,7 @@ def test_get_path_project(self): @override_settings(IRODS_ROOT_PATH=IRODS_ROOT_PATH) def test_get_path_project_root_path(self): - """Test get_irods_path() with a Project object and root path""" + """Test get_irods_path() with Project object and root path""" expected = '/{zone}/projects/{root_path}/{uuid_prefix}/{uuid}'.format( zone=IRODS_ZONE, root_path=IRODS_ROOT_PATH, @@ -159,7 +154,7 @@ def test_get_path_project_root_path(self): self.assertEqual(expected, path) def test_get_path_study(self): - """Test get_irods_path() with a Study object""" + """Test get_irods_path() with Study object""" expected = ( '/{zone}/projects/{uuid_prefix}/{uuid}/{sample_coll}' '/{study}'.format( @@ -174,7 +169,7 @@ def test_get_path_study(self): self.assertEqual(expected, path) def test_get_path_assay(self): - """Test get_irods_path() with an Assay object""" + """Test get_irods_path() with Assay object""" expected = ( '/{zone}/projects/{uuid_prefix}/{uuid}/{sample_coll}' '/{study}/{assay}'.format( @@ -190,7 +185,7 @@ def test_get_path_assay(self): self.assertEqual(expected, path) def test_get_path_zone(self): - """Test get_irods_path() with a LandingZone object""" + """Test get_irods_path() with LandingZone object""" expected = ( '/{zone}/projects/{uuid_prefix}/{uuid}/{zone_dir}' '/{user}/{study_assay}/{zone_title}'.format( @@ -209,7 +204,7 @@ def test_get_path_zone(self): self.assertEqual(expected, path) def test_get_sample_path(self): - """Test get_sample_path() with a Project object""" + """Test get_sample_path() with Project object""" expected = '/{zone}/projects/{uuid_prefix}/{uuid}/{sample_coll}'.format( zone=IRODS_ZONE, uuid_prefix=str(self.project.sodar_uuid)[:2], @@ -220,7 +215,7 @@ def test_get_sample_path(self): self.assertEqual(expected, path) def test_get_sample_path_no_project(self): - """Test get_sample_path() with a wrong type of object (should fail)""" + """Test get_sample_path() with wrong object type (should fail)""" with self.assertRaises(ValueError): self.irods_backend.get_sample_path(self.study) @@ -246,27 +241,33 @@ def test_get_projects_path_with_root_path(self): expected = '/{}/{}/projects'.format(IRODS_ZONE, IRODS_ROOT_PATH) self.assertEqual(self.irods_backend.get_projects_path(), expected) + def test_get_trash_pathg(self): + """Test get_trash_path()""" + self.assertEqual( + self.irods_backend.get_trash_path(), '/{}/trash'.format(IRODS_ZONE) + ) + def test_get_uuid_from_path_assay(self): - """Test get_uuid_from_path() with an assay path""" + """Test get_uuid_from_path() with assay path""" path = self.irods_backend.get_path(self.assay) uuid = self.irods_backend.get_uuid_from_path(path, 'assay') self.assertEqual(uuid, str(self.assay.sodar_uuid)) def test_get_uuid_from_path_project(self): - """Test get_uuid_from_path() for project UUID with an assay path""" + """Test get_uuid_from_path() for project UUID with assay path""" path = self.irods_backend.get_path(self.assay) uuid = self.irods_backend.get_uuid_from_path(path, 'project') self.assertEqual(uuid, str(self.project.sodar_uuid)) def test_get_uuid_from_path_wrong_type(self): - """Test get_uuid_from_path() with an invalid type (should fail)""" + """Test get_uuid_from_path() with invalid type (should fail)""" path = self.irods_backend.get_path(self.study) with self.assertRaises(ValueError): self.irods_backend.get_uuid_from_path(path, 'investigation') def test_get_uuid_from_path_wrong_path(self): - """Test get_uuid_from_path() with a path not containing the uuid (should fail)""" + """Test get_uuid_from_path() on path without uuid (should fail)""" path = self.irods_backend.get_path(self.project) uuid = self.irods_backend.get_uuid_from_path(path, 'study') self.assertIsNone(uuid) @@ -279,24 +280,24 @@ def test_get_uuid_from_path_root_path(self): self.assertEqual(uuid, str(self.assay.sodar_uuid)) def test_get_user_group_name(self): - """Test get_user_group_name() with a Project object""" + """Test get_user_group_name() with Project object""" self.assertEqual( self.irods_backend.get_user_group_name(self.project), - '{}{}'.format(USER_GROUP_PREFIX, self.project.sodar_uuid), + USER_GROUP_TEMPLATE.format(uuid=self.project.sodar_uuid), ) def test_get_user_group_name_uuid(self): - """Test get_user_group_name() with an UUID object""" + """Test get_user_group_name() with UUID object""" self.assertEqual( self.irods_backend.get_user_group_name(self.project.sodar_uuid), - '{}{}'.format(USER_GROUP_PREFIX, self.project.sodar_uuid), + USER_GROUP_TEMPLATE.format(uuid=self.project.sodar_uuid), ) def test_get_user_group_name_uuid_str(self): - """Test get_user_group_name() with an UUID string""" + """Test get_user_group_name() with UUID string""" self.assertEqual( self.irods_backend.get_user_group_name( str(self.project.sodar_uuid) ), - '{}{}'.format(USER_GROUP_PREFIX, self.project.sodar_uuid), + USER_GROUP_TEMPLATE.format(uuid=self.project.sodar_uuid), ) diff --git a/irodsbackend/tests/test_api_taskflow.py b/irodsbackend/tests/test_api_taskflow.py index de3cdb37..c5c0b8ac 100644 --- a/irodsbackend/tests/test_api_taskflow.py +++ b/irodsbackend/tests/test_api_taskflow.py @@ -18,7 +18,7 @@ from samplesheets.tests.test_views_taskflow import SampleSheetTaskflowMixin # Taskflowbackend dependency -from taskflowbackend.tests.base import TaskflowbackendTestBase +from taskflowbackend.tests.base import TaskflowViewTestBase # SODAR constants @@ -48,7 +48,7 @@ class TestIrodsBackendAPITaskflow( SampleSheetIOMixin, LandingZoneMixin, SampleSheetTaskflowMixin, - TaskflowbackendTestBase, + TaskflowViewTestBase, ): """Tests for the API in the irodsbackend app with Taskflow and iRODS""" diff --git a/irodsbackend/tests/test_permissions.py b/irodsbackend/tests/test_permissions_taskflow.py similarity index 51% rename from irodsbackend/tests/test_permissions.py rename to irodsbackend/tests/test_permissions_taskflow.py index ab593d59..27d9eec7 100644 --- a/irodsbackend/tests/test_permissions.py +++ b/irodsbackend/tests/test_permissions_taskflow.py @@ -4,17 +4,13 @@ # Projectroles dependency from projectroles.models import SODAR_CONSTANTS -from projectroles.tests.test_permissions import TestPermissionMixin # Samplesheets dependency -from samplesheets.tests.test_io import ( - SampleSheetIOMixin, - SHEET_DIR, -) +from samplesheets.tests.test_io import SampleSheetIOMixin, SHEET_DIR from samplesheets.tests.test_views_taskflow import SampleSheetTaskflowMixin # Taskflowbackend dependency -from taskflowbackend.tests.base import TaskflowbackendTestBase +from taskflowbackend.tests.base import TaskflowPermissionTestBase # SODAR constants @@ -28,100 +24,101 @@ NON_PROJECT_PATH = '/sodarZone/projects' -class TestIrodsbackendPermissions( - TestPermissionMixin, +class IrodsbackendPermissionsTestBase( SampleSheetIOMixin, SampleSheetTaskflowMixin, - TaskflowbackendTestBase, + TaskflowPermissionTestBase, ): - """Tests for irodsbackend API view permissions""" + """Base class for irodsbackend API view permission tests""" def setUp(self): super().setUp() - # Init users - self.superuser = self.user # HACK - self.anonymous = None - self.user_owner = self.make_user('user_owner') - self.user_delegate = self.make_user('user_delegate') - self.user_contributor = self.make_user('user_contributor') - self.user_guest = self.make_user('user_guest') - self.user_no_roles = self.make_user('user_no_roles') - - # Set up project with taskflow - self.project, self.owner_as = self.make_project_taskflow( - title='TestProject', - type=PROJECT_TYPE_PROJECT, - parent=self.category, - owner=self.user_owner, - description='description', - ) - - # Set up assignments with taskflow - self.delegate_as = self.make_assignment_taskflow( - self.project, self.user_delegate, self.role_delegate - ) - self.contributor_as = self.make_assignment_taskflow( - self.project, self.user_contributor, self.role_contributor - ) - self.guest_as = self.make_assignment_taskflow( - self.project, self.user_guest, self.role_guest - ) - # Set up investigation self.investigation = self.import_isa_from_file(SHEET_PATH, self.project) # Create iRODS collections self.make_irods_colls(self.investigation) - # Set up test paths self.project_path = self.irods_backend.get_path(self.project) self.sample_path = self.irods_backend.get_sample_path(self.project) - def test_stats_get(self): - """Test stats API view GET""" - url = self.irods_backend.get_url( + +class TestIrodsStatisticsAjaxView(IrodsbackendPermissionsTestBase): + """Tests for IrodsStatisticsAjaxView permissions""" + + def setUp(self): + super().setUp() + self.url = self.irods_backend.get_url( view='stats', project=self.project, path=self.sample_path ) + + def test_get(self): + """Test IrodsStatisticsAjaxView GET""" good_users = [ self.superuser, - self.user_owner_cat, # Inherited owner + self.user_owner_cat, # Inherited + self.user_delegate_cat, # Inherited + self.user_contributor_cat, # Inherited + self.user_guest_cat, # Inherited self.user_owner, self.user_delegate, self.user_contributor, self.user_guest, ] - bad_users = [self.user_no_roles, self.anonymous] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 403) + bad_users = [self.user_finder_cat, self.user_no_roles, self.anonymous] + self.assert_response(self.url, good_users, 200) + self.assert_response(self.url, bad_users, 403) self.project.set_public() - self.assert_response(url, self.user_no_roles, 200) - self.assert_response(url, self.anonymous, 403) + self.assert_response(self.url, self.user_no_roles, 200) + self.assert_response(self.url, self.anonymous, 403) @override_settings(PROJECTROLES_ALLOW_ANONYMOUS=True) - def test_stats_get_anon(self): - """Test stats API view with anonymous access""" + def test_get_anon(self): + """Test GET with anonymous access""" self.project.set_public() - url = self.irods_backend.get_url( - view='stats', project=self.project, path=self.sample_path - ) - self.assert_response(url, self.anonymous, 200) + self.assert_response(self.url, self.anonymous, 200) + + def test_get_archive(self): + """Test GET with archived project""" + self.project.set_archive() + good_users = [ + self.superuser, + self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, + self.user_guest_cat, + self.user_owner, + self.user_delegate, + self.user_contributor, + self.user_guest, + ] + bad_users = [self.user_finder_cat, self.user_no_roles, self.anonymous] + self.assert_response(self.url, good_users, 200) + self.assert_response(self.url, bad_users, 403) + self.project.set_public() + self.assert_response(self.url, self.user_no_roles, 200) + self.assert_response(self.url, self.anonymous, 403) @override_settings(PROJECTROLES_ALLOW_ANONYMOUS=True) - def test_stats_get_anon_no_perms(self): - """Test stats API view with anonymous access and no perms to collection""" + def test_get_anon_no_perms(self): + """Test GET with anonymous access and no collection perms""" self.project.set_public() url = self.irods_backend.get_url( view='stats', project=self.project, path=self.project_path ) self.assert_response(url, self.anonymous, 403) - def test_stats_get_not_in_project(self): - """Test stats API view GET with path not in project""" + def test_get_not_in_project(self): + """Test GET with path not in project""" url = self.irods_backend.get_url( view='stats', project=self.project, path=NON_PROJECT_PATH ) bad_users = [ self.superuser, self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, self.user_owner, self.user_delegate, self.user_contributor, @@ -131,21 +128,24 @@ def test_stats_get_not_in_project(self): ] self.assert_response(url, bad_users, 400) - def test_stats_get_no_perms(self): - """Test stats API view GET without collection perms""" + def test_get_no_perms(self): + """Test GET without collection perms""" test_path = self.project_path + '/' + TEST_COLL_NAME self.irods.collections.create(test_path) # NOTE: No perms given - url = self.irods_backend.get_url( view='stats', project=self.project, path=test_path ) good_users = [ self.superuser, self.user_owner_cat, + self.user_delegate_cat, self.user_owner, self.user_delegate, ] bad_users = [ + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, self.user_contributor, self.user_guest, self.user_no_roles, @@ -154,8 +154,8 @@ def test_stats_get_no_perms(self): self.assert_response(url, good_users, 200) self.assert_response(url, bad_users, 403) - def test_stats_post(self): - """Test stats API view POST""" + def test_post(self): + """Test POST""" url = self.irods_backend.get_url( view='stats', project=self.project, @@ -163,67 +163,101 @@ def test_stats_post(self): method='POST', ) post_data = {'paths': [self.sample_path]} - good_users = [ self.superuser, self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, + self.user_guest_cat, self.user_owner, self.user_delegate, self.user_contributor, self.user_guest, ] - bad_users = [self.user_no_roles, self.anonymous] + bad_users = [self.user_finder_cat, self.user_no_roles, self.anonymous] self.assert_response( url, good_users, 200, method='POST', data=post_data ) self.assert_response(url, bad_users, 403, method='POST', data=post_data) - def test_list_get(self): - """Test object list API view GET""" - url = self.irods_backend.get_url( + +class TestIrodsObjectListAjaxView(IrodsbackendPermissionsTestBase): + """Tests for IrodsObjectListAjaxView permissions""" + + def setUp(self): + super().setUp() + self.url = self.irods_backend.get_url( view='list', project=self.project, path=self.sample_path, md5=0 ) + + def test_get(self): + """Test IrodsObjectListAjaxView GET""" good_users = [ self.superuser, self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, + self.user_guest_cat, self.user_owner, self.user_delegate, self.user_contributor, self.user_guest, ] - bad_users = [self.user_no_roles, self.anonymous] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 403) + bad_users = [self.user_finder_cat, self.user_no_roles, self.anonymous] + self.assert_response(self.url, good_users, 200) + self.assert_response(self.url, bad_users, 403) self.project.set_public() - self.assert_response(url, self.user_no_roles, 200) - self.assert_response(url, self.anonymous, 403) + self.assert_response(self.url, self.user_no_roles, 200) + self.assert_response(self.url, self.anonymous, 403) @override_settings(PROJECTROLES_ALLOW_ANONYMOUS=True) - def test_list_get_anon(self): - """Test object list API view GET with anonymous access""" - url = self.irods_backend.get_url( - view='list', project=self.project, path=self.sample_path, md5=0 - ) + def test_get_anon(self): + """Test GET with anonymous access""" + self.project.set_public() + self.assert_response(self.url, self.anonymous, 200) + + def test_get_archive(self): + """Test GET with archived project""" + self.project.set_archive() + good_users = [ + self.superuser, + self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, + self.user_guest_cat, + self.user_owner, + self.user_delegate, + self.user_contributor, + self.user_guest, + ] + bad_users = [self.user_finder_cat, self.user_no_roles, self.anonymous] + self.assert_response(self.url, good_users, 200) + self.assert_response(self.url, bad_users, 403) self.project.set_public() - self.assert_response(url, self.anonymous, 200) + self.assert_response(self.url, self.user_no_roles, 200) + self.assert_response(self.url, self.anonymous, 403) @override_settings(PROJECTROLES_ALLOW_ANONYMOUS=True) - def test_list_get_anon_no_perms(self): - """Test object list API view GET with anonymous access and no permission""" + def test_get_anon_no_perms(self): + """Test GET with anonymous access and no permission""" url = self.irods_backend.get_url( view='list', project=self.project, path=self.project_path, md5=0 ) self.project.set_public() self.assert_response(url, self.anonymous, 403) - def test_list_get_not_in_project(self): - """Test object list GET with path not in project""" + def test_get_not_in_project(self): + """Test GET with path not in project""" url = self.irods_backend.get_url( view='list', project=self.project, path=NON_PROJECT_PATH, md5=0 ) bad_users = [ self.superuser, self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, self.user_owner, self.user_delegate, self.user_contributor, @@ -233,21 +267,24 @@ def test_list_get_not_in_project(self): ] self.assert_response(url, bad_users, 400) - def test_list_get_no_perms(self): - """Test object list GET without collection perms""" + def test_get_no_perms(self): + """Test GET without collection perms""" test_path = self.project_path + '/' + TEST_COLL_NAME self.irods.collections.create(test_path) # NOTE: No perms given - url = self.irods_backend.get_url( view='list', project=self.project, path=test_path, md5=0 ) good_users = [ self.superuser, self.user_owner_cat, + self.user_delegate_cat, self.user_owner, self.user_delegate, ] bad_users = [ + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, self.user_contributor, self.user_guest, self.user_no_roles, diff --git a/irodsbackend/tests/test_plugins_taskflow.py b/irodsbackend/tests/test_plugins_taskflow.py new file mode 100644 index 00000000..d0ed75d5 --- /dev/null +++ b/irodsbackend/tests/test_plugins_taskflow.py @@ -0,0 +1,70 @@ +"""Tests for plugins in the irodsbackend app with Taskflow enabled""" + +import os + +from django.conf import settings + +# Projectroles dependency +from projectroles.models import SODAR_CONSTANTS +from projectroles.plugins import BackendPluginPoint + +# Taskflowbackend dependency +from taskflowbackend.tests.base import TaskflowViewTestBase + + +# SODAR constants +PROJECT_TYPE_PROJECT = SODAR_CONSTANTS['PROJECT_TYPE_PROJECT'] + +# Local constants +TEST_COLL = 'test' +TEST_FILE = 'test.txt' + + +class TestGetStatistics(TaskflowViewTestBase): + """Tests for get_statistics()""" + + def setUp(self): + super().setUp() + self.plugin = BackendPluginPoint.get_plugin('omics_irods') + # Make project with owner in Taskflow + self.project, self.owner_as = self.make_project_taskflow( + title='TestProject', + type=PROJECT_TYPE_PROJECT, + parent=self.category, + owner=self.user, + description='description', + public_guest_access=False, + ) + # Set up test collection + self.test_path = os.path.join( + self.irods_backend.get_path(self.project), TEST_COLL + ) + self.test_coll = self.irods.collections.create(self.test_path) + # Set up rods user trash collection if not there + self.trash_path = os.path.join( + self.irods_backend.get_trash_path(), 'home', settings.IRODS_USER + ) + if not self.irods.collections.exists(self.trash_path): + self.irods.collections.create(self.trash_path) + self.trash_coll = self.irods.collections.get(self.trash_path) + + def test_no_files(self): + """Test get_statistics() with no files""" + stats = self.plugin.get_statistics() + # NOTE: filesizeformat() returns non-breakable whitespaces + self.assertEqual(stats['irods_data_size']['value'], '0\xa0bytes') + self.assertEqual(stats['irods_trash_size']['value'], '0\xa0bytes') + + def test_project_file(self): + """Test get_statistics() with file under project collection""" + self.make_irods_object(self.test_coll, TEST_FILE) + stats = self.plugin.get_statistics() + self.assertEqual(stats['irods_data_size']['value'], '1.0\xa0KB') + self.assertEqual(stats['irods_trash_size']['value'], '0\xa0bytes') + + def test_trash_file(self): + """Test get_statistics() with file under trash collection""" + self.make_irods_object(self.trash_coll, TEST_FILE) + stats = self.plugin.get_statistics() + self.assertEqual(stats['irods_data_size']['value'], '0\xa0bytes') + self.assertEqual(stats['irods_trash_size']['value'], '1.0\xa0KB') diff --git a/irodsbackend/tests/test_signals_taskflow.py b/irodsbackend/tests/test_signals_taskflow.py index 07acfdd2..62871a9c 100644 --- a/irodsbackend/tests/test_signals_taskflow.py +++ b/irodsbackend/tests/test_signals_taskflow.py @@ -1,62 +1,37 @@ """Signals tests for the irodsbackend app""" -from irods.exception import CollectionDoesNotExist, UserDoesNotExist -from irods.models import UserGroup +from irods.exception import UserDoesNotExist from irods.user import iRODSUser from django.conf import settings -from django.core.exceptions import ImproperlyConfigured from django.test import override_settings -from test_plus import TestCase - -# Projectroles dependency -from projectroles.plugins import get_backend_api - -from irodsbackend.api import IrodsAPI +# Taskflowbackend dependency +from taskflowbackend.tests.base import TaskflowViewTestBase USER_NAME = 'test_user' -class TestCreateIrodsUser(TestCase): - """Test the create_irods_user signal""" +class TestCreateIrodsUser(TaskflowViewTestBase): + """Tests for create_irods_user signal""" def setUp(self): - # Ensure TASKFLOW_TEST_MODE is True to avoid data loss - if not settings.TASKFLOW_TEST_MODE: - raise ImproperlyConfigured( - 'TASKFLOW_TEST_MODE not True, testing with SODAR Taskflow ' - 'disabled' - ) - self.taskflow = get_backend_api('taskflow', force=True) - self.user = self.make_user(USER_NAME) - self.irods_backend = IrodsAPI() - self.irods = self.irods_backend.get_session_obj() - - def tearDown(self): - self.taskflow.cleanup() - with self.assertRaises(CollectionDoesNotExist): - self.irods.collections.get(self.irods_backend.get_projects_path()) - for user in self.irods.query(UserGroup).all(): - self.assertIn( - user[UserGroup.name], settings.TASKFLOW_TEST_PERMANENT_USERS - ) - self.irods.cleanup() - super().tearDown() + super().setUp() + self.user_new = self.make_user(USER_NAME) def test_create(self): """Test create_irods_user by logging in""" with self.assertRaises(UserDoesNotExist): self.irods.users.get(USER_NAME) - with self.login(self.user): + with self.login(self.user_new): self.assertIsInstance(self.irods.users.get(USER_NAME), iRODSUser) def test_create_user_exists(self): """Test create_irods_user with an existing user""" self.irods.users.create(USER_NAME, 'rodsuser', settings.IRODS_ZONE) self.assertIsInstance(self.irods.users.get(USER_NAME), iRODSUser) - with self.login(self.user): + with self.login(self.user_new): # No crash should happen self.assertIsInstance(self.irods.users.get(USER_NAME), iRODSUser) @@ -65,6 +40,6 @@ def test_create_auth_disabled(self): """Test create_irods_user with IRODS_SODAR_AUTH disabled""" with self.assertRaises(UserDoesNotExist): self.irods.users.get(USER_NAME) - with self.login(self.user): + with self.login(self.user_new): with self.assertRaises(UserDoesNotExist): self.irods.users.get(USER_NAME) diff --git a/irodsbackend/tests/test_views.py b/irodsbackend/tests/test_views.py index 2c815bee..94576260 100644 --- a/irodsbackend/tests/test_views.py +++ b/irodsbackend/tests/test_views.py @@ -1,6 +1,7 @@ """Tests for views in the irodsbackend app""" import base64 +import os from irods.test.helpers import make_object @@ -11,12 +12,13 @@ from test_plus.test import TestCase # Projectroles dependency -from projectroles.models import Role, SODAR_CONSTANTS -from projectroles.tests.test_models import ProjectMixin, RoleAssignmentMixin -from projectroles.plugins import get_backend_api +from projectroles.models import SODAR_CONSTANTS +# Taskflowbackend dependency +from taskflowbackend.tests.base import TaskflowViewTestBase -# Global constants + +# SODAR constants PROJECT_ROLE_OWNER = SODAR_CONSTANTS['PROJECT_ROLE_OWNER'] PROJECT_ROLE_DELEGATE = SODAR_CONSTANTS['PROJECT_ROLE_DELEGATE'] PROJECT_ROLE_CONTRIBUTOR = SODAR_CONSTANTS['PROJECT_ROLE_CONTRIBUTOR'] @@ -38,54 +40,23 @@ LOCAL_USER_PASS = 'password' -class TestViewsBase(ProjectMixin, RoleAssignmentMixin, TestCase): - """Base class for view testing""" +class IrodsbackendViewTestBase(TaskflowViewTestBase): + """Base class for irodsbackend UI view testing""" def setUp(self): + super().setUp() self.req_factory = RequestFactory() - # Get iRODS backend - self.irods_backend = get_backend_api('omics_irods') - self.assertIsNotNone(self.irods_backend) - # Get iRODS session - self.irods = self.irods_backend.get_session_obj() - - # Init roles - self.role_owner = Role.objects.get_or_create(name=PROJECT_ROLE_OWNER)[0] - self.role_delegate = Role.objects.get_or_create( - name=PROJECT_ROLE_DELEGATE - )[0] - self.role_contributor = Role.objects.get_or_create( - name=PROJECT_ROLE_CONTRIBUTOR - )[0] - self.role_guest = Role.objects.get_or_create(name=PROJECT_ROLE_GUEST)[0] - - # Init superuser - self.user = self.make_user('superuser') - self.user.is_staff = True - self.user.is_superuser = True - self.user.save() - - # Init project with owner - self.project = self.make_project( - 'TestProject', PROJECT_TYPE_PROJECT, None - ) - self.owner_as = self.make_assignment( - self.project, self.user, self.role_owner + # Init project with owner in taskflow + self.project, self.owner_as = self.make_project_taskflow( + 'TestProject', PROJECT_TYPE_PROJECT, self.category, self.user ) - - # Build path for test collection - self.project_path = self.irods_backend.get_path(self.project) - self.irods_path = self.project_path + '/' + IRODS_TEMP_COLL # Create test collection in iRODS + self.project_path = self.irods_backend.get_path(self.project) + self.irods_path = os.path.join(self.project_path, IRODS_TEMP_COLL) self.irods_coll = self.irods.collections.create(self.irods_path) - def tearDown(self): - if self.irods.collections.exists(self.project_path): - self.irods.collections.get(self.project_path).remove(force=True) - self.irods.cleanup() - -class TestIrodsStatisticsAjaxView(TestViewsBase): +class TestIrodsStatisticsAjaxView(IrodsbackendViewTestBase): """Tests for the landing zone collection statistics Ajax view""" def setUp(self): @@ -328,18 +299,9 @@ def test_post_one_empty_coll(self): ) -class TestIrodsObjectListAjaxView(TestViewsBase): +class TestIrodsObjectListAjaxView(IrodsbackendViewTestBase): """Tests for the landing zone data object listing Ajax view""" - def setUp(self): - super().setUp() - # Build path for test collection - self.irods_path = ( - self.irods_backend.get_path(self.project) + '/' + IRODS_TEMP_COLL - ) - # Create test collection in iRODS - self.irods_coll = self.irods.collections.create(self.irods_path) - def test_get_empty_coll(self): """Test GET for listing an empty collection in iRODS""" with self.login(self.user): @@ -356,7 +318,6 @@ def test_get_empty_coll(self): def test_get_coll_obj(self): """Test GET for listing a collection with a data object""" - # Put data object in iRODS obj_path = self.irods_path + '/' + IRODS_OBJ_NAME data_obj = make_object(self.irods, obj_path, IRODS_OBJ_CONTENT) @@ -381,7 +342,6 @@ def test_get_coll_obj(self): def test_get_coll_md5(self): """Test GET for listing a collection with a data object and md5""" - # Put data object in iRODS obj_path = self.irods_path + '/' + IRODS_OBJ_NAME make_object(self.irods, obj_path, IRODS_OBJ_CONTENT) # Put MD5 data object in iRODS @@ -403,7 +363,6 @@ def test_get_coll_md5(self): def test_get_coll_md5_no_file(self): """Test GET with md5 set True but no md5 file""" - # Put data object in iRODS obj_path = self.irods_path + '/' + IRODS_OBJ_NAME make_object(self.irods, obj_path, IRODS_OBJ_CONTENT) @@ -424,7 +383,6 @@ def test_get_coll_not_found(self): """Test GET for listing a collection which doesn't exist""" fail_path = self.irods_path + '/' + IRODS_FAIL_COLL self.assertEqual(self.irods.collections.exists(fail_path), False) - with self.login(self.user): response = self.client.get( self.irods_backend.get_url( @@ -438,7 +396,6 @@ def test_get_coll_not_in_project(self): self.assertEqual( self.irods.collections.exists(IRODS_NON_PROJECT_PATH), True ) - with self.login(self.user): response = self.client.get( self.irods_backend.get_url( @@ -456,7 +413,6 @@ def test_get_no_access(self): self.make_assignment( self.project, new_user, self.role_contributor ) # No taskflow - with self.login(new_user): response = self.client.get( self.irods_backend.get_url( diff --git a/irodsinfo/tests/test_permissions.py b/irodsinfo/tests/test_permissions.py index 1a0f620d..12d3f246 100644 --- a/irodsinfo/tests/test_permissions.py +++ b/irodsinfo/tests/test_permissions.py @@ -3,32 +3,22 @@ from django.urls import reverse # Projectroles dependency -from projectroles.tests.test_permissions import TestPermissionBase +from projectroles.tests.test_permissions import TestSiteAppPermissionBase -class TestIrodsinfoPermissions(TestPermissionBase): +class TestIrodsinfoPermissions(TestSiteAppPermissionBase): """Tests for irodsinfo UI view permissions""" - def setUp(self): - # Create users - self.superuser = self.make_user('superuser') - self.superuser.is_superuser = True - self.superuser.is_staff = True - self.superuser.save() - self.regular_user = self.make_user('regular_user') - # No user - self.anonymous = None - - def test_irods_info(self): - """Test permissions for IrodsInfoView""" + def test_get_irods_info(self): + """Test IrodsInfoView GET""" url = reverse('irodsinfo:info') good_users = [self.superuser, self.regular_user] bad_users = [self.anonymous] self.assert_response(url, good_users, 200) self.assert_response(url, bad_users, 302) - def test_irods_config(self): - """Test permissions for IrodsConfigView""" + def test_get_irods_config(self): + """Test IrodsConfigView GET""" url = reverse('irodsinfo:config') good_users = [self.superuser, self.regular_user] bad_users = [self.anonymous] diff --git a/irodsinfo/tests/test_permissions_api.py b/irodsinfo/tests/test_permissions_api.py new file mode 100644 index 00000000..ba42883c --- /dev/null +++ b/irodsinfo/tests/test_permissions_api.py @@ -0,0 +1,18 @@ +"""Tests for API view permissions in the irodsinfo app""" + +from django.urls import reverse + +# Projectroles dependency +from projectroles.tests.test_permissions import TestSiteAppPermissionBase + + +class TestIrodsConfigRetrieveAPIView(TestSiteAppPermissionBase): + """Tests for irodsinfo API""" + + def test_get_irods_config(self): + """Test IrodsConfigRetrieveAPIView GET""" + url = reverse('irodsinfo:api_env') + good_users = [self.superuser, self.regular_user] + bad_users = [self.anonymous] + self.assert_response(url, good_users, 200) + self.assert_response(url, bad_users, 401) diff --git a/irodsinfo/tests/test_views.py b/irodsinfo/tests/test_views.py index 162fd385..327654d8 100644 --- a/irodsinfo/tests/test_views.py +++ b/irodsinfo/tests/test_views.py @@ -27,10 +27,8 @@ def setUp(self): # Create users self.superuser = self.make_user('superuser') self.superuser.is_superuser = True - self.superuser.is_staff = True self.superuser.save() self.regular_user = self.make_user('regular_user') - # No user self.anonymous = None diff --git a/irodsinfo/tests/test_views_api.py b/irodsinfo/tests/test_views_api.py new file mode 100644 index 00000000..11f291ae --- /dev/null +++ b/irodsinfo/tests/test_views_api.py @@ -0,0 +1,37 @@ +"""Tests for API views in the irodsinfo app""" + +from django.test import override_settings +from django.urls import reverse +from rest_framework import status + +from test_plus.test import TestCase + +from irodsinfo.tests.test_views import PLUGINS_DISABLE_IRODS + + +class TestIrodsConfigRetrieveAPIView(TestCase): + """Tests for IrodsConfigRetrieveAPIView""" + + def setUp(self): + # Create users + self.superuser = self.make_user('superuser') + self.superuser.is_superuser = True + self.superuser.save() + self.regular_user = self.make_user('regular_user') + + def test_get_irods_config(self): + """Test GET request to retrieve iRODS config""" + url = reverse('irodsinfo:api_env') + with self.login(self.regular_user): + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('irods_environment', response.data) + + @override_settings(ENABLED_BACKEND_PLUGINS=PLUGINS_DISABLE_IRODS) + def test_get_irods_config_with_disabled_backend(self): + """Test GET request to retrieve iRODS config with disabled backend""" + url = reverse('irodsinfo:api_env') + with self.login(self.regular_user): + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertIn('iRODS backend not enabled', response.data['detail']) diff --git a/irodsinfo/urls.py b/irodsinfo/urls.py index f2166ea5..06550a70 100644 --- a/irodsinfo/urls.py +++ b/irodsinfo/urls.py @@ -1,10 +1,13 @@ +"""URL patterns for the irodsinfo app""" + from django.urls import path -from . import views +from irodsinfo import views, views_api app_name = 'irodsinfo' -urlpatterns = [ +# UI views +urls_ui = [ path( route='info', view=views.IrodsInfoView.as_view(), @@ -16,3 +19,14 @@ name='config', ), ] + +# REST API views +urls_api = [ + path( + route='api/environment', + view=views_api.IrodsEnvRetrieveAPIView.as_view(), + name='api_env', + ), +] + +urlpatterns = urls_ui + urls_api diff --git a/irodsinfo/views.py b/irodsinfo/views.py index fd18c39b..edec148b 100644 --- a/irodsinfo/views.py +++ b/irodsinfo/views.py @@ -22,6 +22,45 @@ logger = logging.getLogger(__name__) +class IrodsConfigMixin: + """Mixin for iRODS configuration views""" + + @staticmethod + def get_irods_client_env(user, irods_backend): + """ + Create iRODS configuration file for the current user. + """ + user_name = user.username + # Just in case Django mangles the user name case, as it might + if user_name.find('@') != -1: + user_name = ( + user_name.split('@')[0] + '@' + user_name.split('@')[1].upper() + ) + home_path = '/{}/home/{}'.format(settings.IRODS_ZONE, user_name) + cert_file_name = settings.IRODS_HOST + '.crt' + + # Set up irods_environment.json + irods_env = dict(settings.IRODS_ENV_DEFAULT) + irods_env.update( + { + 'irods_authentication_scheme': 'PAM', + 'irods_cwd': home_path, + 'irods_home': home_path, + 'irods_host': settings.IRODS_HOST_FQDN, + 'irods_port': settings.IRODS_PORT, + 'irods_user_name': user_name, + 'irods_zone_name': settings.IRODS_ZONE, + } + ) + if settings.IRODS_CERT_PATH: + irods_env['irods_ssl_certificate_file'] = cert_file_name + # Get optional client environment overrides + irods_env.update(dict(settings.IRODS_ENV_CLIENT)) + irods_env = irods_backend.format_env(irods_env) + logger.debug('iRODS environment: {}'.format(irods_env)) + return irods_env + + class IrodsInfoView(LoggedInPermissionMixin, HTTPRefererMixin, TemplateView): """iRODS Information View""" @@ -66,7 +105,9 @@ def get_context_data(self, *args, **kwargs): return context -class IrodsConfigView(LoggedInPermissionMixin, HTTPRefererMixin, View): +class IrodsConfigView( + IrodsConfigMixin, LoggedInPermissionMixin, HTTPRefererMixin, View +): """iRODS Configuration file download view""" permission_required = 'irodsinfo.get_config' @@ -77,34 +118,8 @@ def get(self, request, *args, **kwargs): messages.error(request, 'iRODS Backend not enabled.') return redirect(reverse('irodsinfo:info')) - user_name = request.user.username - # Just in case Django mangles the user name case, as it might - if user_name.find('@') != -1: - user_name = ( - user_name.split('@')[0] + '@' + user_name.split('@')[1].upper() - ) - home_path = '/{}/home/{}'.format(settings.IRODS_ZONE, user_name) - cert_file_name = settings.IRODS_HOST + '.crt' - - # Set up irods_environment.json - irods_env = dict(settings.IRODS_ENV_DEFAULT) - irods_env.update( - { - 'irods_authentication_scheme': 'PAM', - 'irods_cwd': home_path, - 'irods_home': home_path, - 'irods_host': settings.IRODS_HOST_FQDN, - 'irods_port': settings.IRODS_PORT, - 'irods_user_name': user_name, - 'irods_zone_name': settings.IRODS_ZONE, - } - ) - if settings.IRODS_CERT_PATH: - irods_env['irods_ssl_certificate_file'] = cert_file_name - # Get optional client environment overrides - irods_env.update(dict(settings.IRODS_ENV_CLIENT)) - irods_env = irods_backend.format_env(irods_env) - logger.debug('iRODS environment: {}'.format(irods_env)) + # Create iRODS environment file + irods_env = self.get_irods_client_env(request.user, irods_backend) env_json = json.dumps(irods_env, indent=2) # Create zip archive @@ -117,6 +132,7 @@ def get(self, request, *args, **kwargs): if settings.IRODS_CERT_PATH: try: with open(settings.IRODS_CERT_PATH) as cert_file: + cert_file_name = irods_env['irods_ssl_certificate_file'] zip_file.writestr(cert_file_name, cert_file.read()) except FileNotFoundError: logger.warning( diff --git a/irodsinfo/views_api.py b/irodsinfo/views_api.py new file mode 100644 index 00000000..288283f1 --- /dev/null +++ b/irodsinfo/views_api.py @@ -0,0 +1,47 @@ +"""REST API views for the irodsinfo app""" + +import logging + +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +# Projectroles dependency +from projectroles.plugins import get_backend_api + +from irodsinfo.views import IrodsConfigMixin + + +logger = logging.getLogger(__name__) + + +class IrodsEnvRetrieveAPIView(IrodsConfigMixin, APIView): + """ + Retrieve iRODS environment file for the current user. + + **URL:** ``/irods/api/environment`` + + **Methods:** ``GET`` + + **Returns:** + + - ``irods_environment``: iRODS client environment (dict) + """ + + permission_classes = [IsAuthenticated] + + def get(self, request, *args, **kwargs): + """Get iRODS environment file""" + try: + irods_backend = get_backend_api('omics_irods') + if not irods_backend: + return Response( + {'detail': 'iRODS backend not enabled'}, status=404 + ) + env = self.get_irods_client_env(request.user, irods_backend) + return Response({'irods_environment': env}) + except Exception as ex: + logger.error('iRODS config retrieval failed: {}'.format(ex)) + return Response( + {'detail': 'iRODS config retrieval failed'}, status=400 + ) diff --git a/landingzones/constants.py b/landingzones/constants.py new file mode 100644 index 00000000..737db695 --- /dev/null +++ b/landingzones/constants.py @@ -0,0 +1,91 @@ +"""Constants for the landingzones app""" + +# Status types for landing zones +ZONE_STATUS_OK = 'OK' +ZONE_STATUS_CREATING = 'CREATING' +ZONE_STATUS_NOT_CREATED = 'NOT CREATED' +ZONE_STATUS_ACTIVE = 'ACTIVE' +ZONE_STATUS_PREPARING = 'PREPARING' +ZONE_STATUS_VALIDATING = 'VALIDATING' +ZONE_STATUS_MOVING = 'MOVING' +ZONE_STATUS_MOVED = 'MOVED' +ZONE_STATUS_FAILED = 'FAILED' +ZONE_STATUS_DELETING = 'DELETING' +ZONE_STATUS_DELETED = 'DELETED' + +ZONE_STATUS_TYPES = [ + ZONE_STATUS_CREATING, + ZONE_STATUS_NOT_CREATED, + ZONE_STATUS_ACTIVE, + ZONE_STATUS_PREPARING, + ZONE_STATUS_VALIDATING, + ZONE_STATUS_MOVING, + ZONE_STATUS_MOVED, + ZONE_STATUS_FAILED, + ZONE_STATUS_DELETING, + ZONE_STATUS_DELETED, +] + +DEFAULT_STATUS_INFO = { + ZONE_STATUS_CREATING: 'Creating landing zone in iRODS', + ZONE_STATUS_NOT_CREATED: 'Creating landing zone in iRODS failed (unknown problem)', + ZONE_STATUS_ACTIVE: 'Available with write access for user', + ZONE_STATUS_PREPARING: 'Preparing transaction for validation and moving', + ZONE_STATUS_VALIDATING: 'Validation in progress, write access disabled', + ZONE_STATUS_MOVING: 'Validation OK, moving files into sample data repository', + ZONE_STATUS_MOVED: 'Files moved successfully, landing zone removed', + ZONE_STATUS_FAILED: 'Validation/moving failed (unknown problem)', + ZONE_STATUS_DELETING: 'Deleting landing zone', + ZONE_STATUS_DELETED: 'Landing zone deleted', +} +STATUS_INFO_DELETE_NO_COLL = ( + 'No iRODS collection for zone found, marked as deleted' +) + +STATUS_STYLES = { + ZONE_STATUS_CREATING: 'bg-warning', + ZONE_STATUS_NOT_CREATED: 'bg-danger', + ZONE_STATUS_ACTIVE: 'bg-info', + ZONE_STATUS_PREPARING: 'bg-warning', + ZONE_STATUS_VALIDATING: 'bg-warning', + ZONE_STATUS_MOVING: 'bg-warning', + ZONE_STATUS_MOVED: 'bg-success', + ZONE_STATUS_FAILED: 'bg-danger', + ZONE_STATUS_DELETING: 'bg-warning', + ZONE_STATUS_DELETED: 'bg-secondary', +} + +# Status types for which zone validation, moving and deletion are allowed +STATUS_ALLOW_UPDATE = [ZONE_STATUS_ACTIVE, ZONE_STATUS_FAILED] + +# Status types for zones for which activities have finished +STATUS_FINISHED = [ + ZONE_STATUS_MOVED, + ZONE_STATUS_NOT_CREATED, + ZONE_STATUS_DELETED, +] + +# Status types which lock the project in Taskflow +STATUS_LOCKING = [ + ZONE_STATUS_PREPARING, + ZONE_STATUS_VALIDATING, + ZONE_STATUS_MOVING, +] + +# Status types for busy landing zones +STATUS_BUSY = [ + ZONE_STATUS_CREATING, + ZONE_STATUS_PREPARING, + ZONE_STATUS_VALIDATING, + ZONE_STATUS_MOVING, + ZONE_STATUS_DELETING, +] + +# Status types during which file lists and stats should be displayed +STATUS_DISPLAY_FILES = [ + ZONE_STATUS_ACTIVE, + ZONE_STATUS_PREPARING, + ZONE_STATUS_VALIDATING, + ZONE_STATUS_MOVING, + ZONE_STATUS_FAILED, +] diff --git a/landingzones/forms.py b/landingzones/forms.py index 4f357955..f9d90455 100644 --- a/landingzones/forms.py +++ b/landingzones/forms.py @@ -101,11 +101,15 @@ def __init__( ) # Updating else: + # Set initial assay value + self.initial['assay'] = self.instance.assay.sodar_uuid + # Don't allow modifying certain fields - self.fields['title_suffix'].disabled = True - self.fields['create_colls'].disabled = True - self.fields['restrict_colls'].disabled = True - # TODO: Don't allow modifying the assay + self.fields['title_suffix'].widget = forms.HiddenInput() + self.fields['create_colls'].widget = forms.HiddenInput() + self.fields['restrict_colls'].widget = forms.HiddenInput() + self.fields['assay'].widget = forms.HiddenInput() + self.fields['configuration'].widget = forms.HiddenInput() def clean(self): # Creation @@ -114,6 +118,9 @@ def clean(self): self.cleaned_data['title'] = get_zone_title( self.cleaned_data.get('title_suffix') ) + # Updating + else: + self.cleaned_data['title'] = self.instance.title return self.cleaned_data def save(self, *args, **kwargs): diff --git a/landingzones/management/commands/busyzones.py b/landingzones/management/commands/busyzones.py index 4ee3f867..ea1f0549 100644 --- a/landingzones/management/commands/busyzones.py +++ b/landingzones/management/commands/busyzones.py @@ -5,7 +5,8 @@ # Projectroles dependency from projectroles.management.logging import ManagementCommandLogger -from landingzones.models import LandingZone, STATUS_BUSY +from landingzones.constants import STATUS_BUSY +from landingzones.models import LandingZone logger = ManagementCommandLogger(__name__) diff --git a/landingzones/management/commands/inactivezones.py b/landingzones/management/commands/inactivezones.py index a4b32229..c6c35a80 100644 --- a/landingzones/management/commands/inactivezones.py +++ b/landingzones/management/commands/inactivezones.py @@ -10,6 +10,7 @@ from projectroles.management.logging import ManagementCommandLogger from projectroles.plugins import get_backend_api +from landingzones.constants import ZONE_STATUS_MOVED, ZONE_STATUS_DELETED from landingzones.models import LandingZone @@ -20,7 +21,7 @@ def get_inactive_zones(weeks=2): """Return landing zones last modified over n weeks ago""" return LandingZone.objects.filter( date_modified__lte=localtime() - timedelta(weeks=weeks) - ).exclude(status__in=('DELETED', 'MOVED')) + ).exclude(status__in=(ZONE_STATUS_MOVED, ZONE_STATUS_DELETED)) def get_output(zones, irods_backend, irods): diff --git a/landingzones/models.py b/landingzones/models.py index 33d61484..7132d478 100644 --- a/landingzones/models.py +++ b/landingzones/models.py @@ -9,68 +9,12 @@ # Samplesheets dependency from samplesheets.models import Assay +import landingzones.constants as lc + # Access Django user model AUTH_USER_MODEL = getattr(settings, 'AUTH_USER_MODEL', 'auth.User') -ZONE_STATUS_TYPES = [ - 'CREATING', - 'NOT CREATED', - 'ACTIVE', - 'PREPARING', - 'VALIDATING', - 'MOVING', - 'FAILED', - 'MOVED', - 'DELETING', - 'DELETED', -] - -DEFAULT_STATUS_INFO = { - 'CREATING': 'Creating landing zone in iRODS', - 'NOT CREATED': 'Creating landing zone in iRODS failed (unknown problem)', - 'ACTIVE': 'Available with write access for user', - 'PREPARING': 'Preparing transaction for validation and moving', - 'VALIDATING': 'Validation in progress, write access disabled', - 'MOVING': 'Validation OK, moving files into sample data repository', - 'MOVED': 'Files moved successfully, landing zone removed', - 'FAILED': 'Validation/moving failed (unknown problem)', - 'DELETING': 'Deleting landing zone', - 'DELETED': 'Landing zone deleted', -} -STATUS_INFO_DELETE_NO_COLL = ( - 'No iRODS collection for zone found, marked as deleted' -) - -STATUS_STYLES = { - 'CREATING': 'bg-warning', - 'NOT CREATED': 'bg-danger', - 'ACTIVE': 'bg-info', - 'PREPARING': 'bg-warning', - 'VALIDATING': 'bg-warning', - 'MOVING': 'bg-warning', - 'MOVED': 'bg-success', - 'FAILED': 'bg-danger', - 'DELETING': 'bg-warning', - 'DELETED': 'bg-secondary', -} - -# Status types for which zone validation, moving and deletion are allowed -STATUS_ALLOW_UPDATE = ['ACTIVE', 'FAILED'] - -# Status types for zones for which activities have finished -STATUS_FINISHED = ['MOVED', 'NOT CREATED', 'DELETED'] - -# Status types which lock the project in Taskflow -STATUS_LOCKING = ['PREPARING', 'VALIDATING', 'MOVING'] - -# Status types for busy landing zones -STATUS_BUSY = ['CREATING', 'PREPARING', 'VALIDATING', 'MOVING', 'DELETING'] - -# Status types during which file lists and stats should be displayed -STATUS_DISPLAY_FILES = ['ACTIVE', 'PREPARING', 'VALIDATING', 'MOVING', 'FAILED'] - - class LandingZone(models.Model): """Class representing an user's iRODS landing zone for an assay""" @@ -108,7 +52,7 @@ class LandingZone(models.Model): max_length=64, null=False, blank=False, - default='CREATING', + default=lc.ZONE_STATUS_CREATING, help_text='Status of landing zone', ) @@ -117,7 +61,7 @@ class LandingZone(models.Model): max_length=1024, null=True, blank=True, - default=DEFAULT_STATUS_INFO['CREATING'], + default=lc.DEFAULT_STATUS_INFO[lc.ZONE_STATUS_CREATING], help_text='Additional status information', ) @@ -185,13 +129,13 @@ def get_project(self): def set_status(self, status, status_info=None): """Set zone status""" - if status not in ZONE_STATUS_TYPES: + if status not in lc.ZONE_STATUS_TYPES: raise TypeError('Unknown status "{}"'.format(status)) self.status = status if status_info: self.status_info = status_info else: - self.status_info = DEFAULT_STATUS_INFO[status][:1024] + self.status_info = lc.DEFAULT_STATUS_INFO[status][:1024] self.save() def is_locked(self): @@ -199,10 +143,10 @@ def is_locked(self): Return True/False depending whether write access to zone is currently locked. """ - return self.status in STATUS_LOCKING + return self.status in lc.STATUS_LOCKING def can_display_files(self): """ Return True/False depending whether file info should be displayed. """ - return self.status in STATUS_DISPLAY_FILES + return self.status in lc.STATUS_DISPLAY_FILES diff --git a/landingzones/plugins.py b/landingzones/plugins.py index 299b269c..ce8a09e8 100644 --- a/landingzones/plugins.py +++ b/landingzones/plugins.py @@ -17,9 +17,14 @@ # Samplesheets dependency from samplesheets.models import Investigation, Assay -from landingzones.models import LandingZone, STATUS_ALLOW_UPDATE +from landingzones.constants import ( + STATUS_ALLOW_UPDATE, + ZONE_STATUS_MOVED, + ZONE_STATUS_DELETED, +) +from landingzones.models import LandingZone from landingzones.urls import urlpatterns -from landingzones.views import ZoneCreateMixin +from landingzones.views import ZoneModifyMixin logger = logging.getLogger(__name__) @@ -38,7 +43,7 @@ class ProjectAppPlugin( - ZoneCreateMixin, ProjectModifyPluginMixin, ProjectAppPluginPoint + ZoneModifyMixin, ProjectModifyPluginMixin, ProjectAppPluginPoint ): """Plugin for registering app with Projectroles""" @@ -75,7 +80,9 @@ class ProjectAppPlugin( entry_point_url_id = 'landingzones:list' #: Description string - description = 'Management of sample data landing zones in iRODS' + description = ( + 'Landing zone management for uploading sample data files to iRODS' + ) #: Required permission for accessing the app app_permission = 'landingzones.view_zone_own' @@ -125,7 +132,7 @@ def get_object_link(self, model_str, uuid): obj = self.get_object(eval(model_str), uuid) if not obj: return None - if obj.__class__ == LandingZone and obj.status != 'MOVED': + if obj.__class__ == LandingZone and obj.status != ZONE_STATUS_MOVED: return { 'url': reverse( 'landingzones:list', @@ -137,12 +144,7 @@ def get_object_link(self, model_str, uuid): } elif obj.__class__ == Assay: return { - 'url': reverse( - 'samplesheets:project_sheets', - kwargs={'project': obj.get_project().sodar_uuid}, - ) - + '#/assay/' - + str(obj.sodar_uuid), + 'url': obj.get_url(), 'label': obj.get_display_name(), } @@ -166,7 +168,9 @@ def get_project_list_value(self, column_id, project, user): zones = LandingZone.objects.filter(project=project) else: zones = LandingZone.objects.filter(project=project, user=user) - active_count = zones.exclude(status__in=['MOVED', 'DELETED']).count() + active_count = zones.exclude( + status__in=[ZONE_STATUS_MOVED, ZONE_STATUS_DELETED] + ).count() if investigation and investigation.irods_status and active_count > 0: return ( @@ -235,7 +239,8 @@ def perform_project_sync(self, project): zone_path = irods_backend.get_path(zone) if irods.collections.exists(zone_path): continue # Skip if already there - self.submit_create(zone, False) + logger.info('Syncing landing zone "{}"..'.format(zone.title)) + self.submit_create(zone, create_colls=True, sync=True) # Landingzones configuration sub-app plugin ------------------------------------ diff --git a/landingzones/serializers.py b/landingzones/serializers.py index 0eb4bb56..3e20e5bf 100644 --- a/landingzones/serializers.py +++ b/landingzones/serializers.py @@ -12,6 +12,11 @@ # Samplesheets dependency from samplesheets.models import Assay +from landingzones.constants import ( + ZONE_STATUS_OK, + ZONE_STATUS_DELETED, + ZONE_STATUS_NOT_CREATED, +) from landingzones.models import LandingZone from landingzones.utils import get_zone_title @@ -56,18 +61,24 @@ def get_status_locked(self, obj): def get_irods_path(self, obj): irods_backend = get_backend_api('omics_irods') if irods_backend and obj.status not in [ - 'MOVED', - 'DELETED', - 'NOT CREATED', + ZONE_STATUS_OK, + ZONE_STATUS_DELETED, + ZONE_STATUS_NOT_CREATED, ]: return irods_backend.get_path(obj) def validate(self, attrs): - assay = Assay.objects.filter( - sodar_uuid=attrs['assay']['sodar_uuid'] - ).first() - if not assay: - raise serializers.ValidationError('Assay not found') + try: + if 'assay' in attrs: + assay = Assay.objects.get( + sodar_uuid=attrs['assay']['sodar_uuid'] + ) + elif 'assay' in self.context: + assay = Assay.objects.get(sodar_uuid=self.context['assay']) + else: + raise serializers.ValidationError('Assay not found') + except Exception as ex: + raise serializers.ValidationError('Assay not found') from ex if assay.get_project() != self.context['project']: raise serializers.ValidationError( 'Assay does not belong to project' @@ -82,3 +93,12 @@ def create(self, validated_data): sodar_uuid=validated_data['assay']['sodar_uuid'] ) return super().create(validated_data) + + def update(self, instance, validated_data): + validated_data['title'] = get_zone_title(validated_data.get('title')) + validated_data['project'] = self.context['project'] + validated_data['user'] = self.context['request'].user + validated_data['assay'] = Assay.objects.get( + sodar_uuid=self.context['assay'] + ) + return super().update(instance, validated_data) diff --git a/landingzones/static/landingzones/js/landingzones.js b/landingzones/static/landingzones/js/landingzones.js index 5caf7d8c..90fd16f6 100644 --- a/landingzones/static/landingzones/js/landingzones.js +++ b/landingzones/static/landingzones/js/landingzones.js @@ -1,23 +1,39 @@ +// Init global variable +var isSuperuser = false; + /***************************** Zone status updating function *****************************/ var updateZoneStatus = function() { + window.zoneStatusUpdated = false; + var zoneUuids = []; + $('.sodar-lz-zone-tr-existing').each(function() { var trId = $(this).attr('id'); var zoneTr = $('#' + trId); var zoneUuid = $(this).attr('data-zone-uuid'); - var sampleUrl = $(this).attr('data-sample-url'); var statusTd = zoneTr.find('td#sodar-lz-zone-status-' + zoneUuid); if (statusTd.text() !== 'MOVED' && statusTd.text() !== 'DELETED') { - $.ajax({ - url: $(this).attr('data-status-url'), - method: 'GET', - dataType: 'json' - }).done(function (data) { - // console.log(trId + ': ' + data['status']); // DEBUG + zoneUuids.push(zoneUuid); + } + }); + + // Make the POST request to retrieve zone statuses + if (zoneUuids.length > 0) { + $.ajax({ + url: zoneStatusUrl, + method: 'POST', + dataType: 'JSON', + data: { + zone_uuids: zoneUuids + } + }).done(function(data) { + $('.sodar-lz-zone-tr-existing').each(function() { + var zoneUuid = $(this).attr('data-zone-uuid'); + var zoneTr = $('#' + $(this).attr('id')); + var statusTd = zoneTr.find('td#sodar-lz-zone-status-' + zoneUuid); var statusInfoSpan = zoneTr.find('span#sodar-lz-zone-status-info-' + zoneUuid); - // TODO: Should somehow get these from STATUS_STYLES instead var statusStyles = { 'CREATING': 'bg-warning', 'NOT CREATED': 'bg-danger', @@ -31,67 +47,94 @@ var updateZoneStatus = function() { 'DELETED': 'bg-secondary' }; - if (statusTd.text() !== data['status'] || - statusInfoSpan.text() !== data['status_info']) { - statusTd.text(data['status']); - statusTd.removeClass(); - statusTd.addClass(statusStyles[data['status']] + ' text-white'); - statusInfoSpan.text(data['status_info']); - if (['PREPARING', 'VALIDATING', 'MOVING'].includes(data['status'])) { - statusTd.append( - '') - } + if (data[zoneUuid]) { + var zoneStatus = data[zoneUuid].status; + var zoneStatusInfo = data[zoneUuid].status_info; - if (['CREATING', 'NOT CREATED', 'MOVED', 'DELETED'].includes(data['status'])) { - zoneTr.find('p#sodar-lz-zone-stats-container-' + zoneUuid).hide(); + if ( + statusTd.text() !== zoneStatus || + statusInfoSpan.text() !== zoneStatusInfo + ) { + statusTd.text(zoneStatus); + statusTd.removeClass(); + statusTd.addClass(statusStyles[zoneStatus] + ' text-white'); + statusInfoSpan.text(zoneStatusInfo); - if (data['status'] === 'MOVED') { - var statusMovedSpan = zoneTr.find( - 'span#sodar-lz-zone-status-moved-' + zoneUuid); - statusMovedSpan.html( - '

' + - ' ' + - 'Browse files in sample sheet

'); + if (['PREPARING', 'VALIDATING', 'MOVING', 'DELETING'].includes(zoneStatus)) { + statusTd.append( + '' + ); } - } - // Button modification - if (data['status'] !== 'ACTIVE' && data['status'] !== 'FAILED') { - zoneTr.find('td.sodar-lz-zone-title').addClass('text-muted'); - zoneTr.find('td.sodar-lz-zone-assay').addClass('text-muted'); - zoneTr.find('td.sodar-lz-zone-status-info').addClass('text-muted'); - zoneTr.find('.btn').each(function() { - if ($(this).is('button')) { - $(this).attr('disabled', 'disabled'); - } - else if ($(this).is('a')) { - $(this).addClass('disabled'); - } - $(this).tooltip('disable'); - }); - zoneTr.find('.sodar-list-dropdown').addClass('disabled'); - } - else { - zoneTr.find('td.sodar-lz-zone-title').removeClass('text-muted'); - zoneTr.find('td.sodar-lz-zone-assay').removeClass('text-muted'); - zoneTr.find('td.sodar-lz-zone-status-info').removeClass('text-muted'); - zoneTr.find('p#sodar-lz-zone-stats-container-' + zoneUuid).show(); - zoneTr.find('.btn').each(function() { - if ($(this).is('button')) { - $(this).removeAttr('disabled'); + if (['CREATING', 'NOT CREATED', 'MOVED', 'DELETED'].includes(zoneStatus)) { + zoneTr.find('p#sodar-lz-zone-stats-container-' + zoneUuid).hide(); + + if (zoneStatus === 'MOVED') { + var statusMovedSpan = zoneTr.find( + 'span#sodar-lz-zone-status-moved-' + zoneUuid + ); + statusMovedSpan.html( + '

' + + ' ' + + 'Browse files in sample sheet

' + ); } - $(this).removeClass('disabled'); - $(this).tooltip('enable'); - }); - zoneTr.find('.sodar-list-dropdown').removeClass('disabled'); + } + + // Button modification + if (zoneStatus !== 'ACTIVE' && zoneStatus !== 'FAILED' && isSuperuser) {} + else if (zoneStatus !== 'ACTIVE' && zoneStatus !== 'FAILED') { + zoneTr.find('td.sodar-lz-zone-title').addClass('text-muted'); + zoneTr.find('td.sodar-lz-zone-assay').addClass('text-muted'); + zoneTr.find('td.sodar-lz-zone-status-info').addClass('text-muted'); + zoneTr.find('.btn').each(function() { + if ($(this).is('button')) { + $(this).attr('disabled', 'disabled'); + } else if ($(this).is('a')) { + $(this).addClass('disabled'); + } + $(this).tooltip('disable'); + }); + zoneTr.find('.sodar-list-dropdown').addClass('disabled'); + } else { + zoneTr.find('td.sodar-lz-zone-title').removeClass('text-muted'); + zoneTr.find('td.sodar-lz-zone-assay').removeClass('text-muted'); + zoneTr.find('td.sodar-lz-zone-status-info').removeClass('text-muted'); + zoneTr.find('p#sodar-lz-zone-stats-container-' + zoneUuid).show(); + zoneTr.find('.btn').each(function() { + if ($(this).is('button')) { + $(this).removeAttr('disabled'); + } + $(this).removeClass('disabled'); + $(this).tooltip('enable'); + }); + zoneTr.find('.sodar-list-dropdown').removeClass('disabled'); + } } } }); - } - }); + window.zoneStatusUpdated = true; + }); + } }; $(document).ready(function() { + /********************* + Get superuser status + *********************/ + $.ajax({ + url: currentUserURL, + method: "GET", + success: function(response) { + isSuperuser = response.is_superuser; + }, + error: function(response) { + isSuperuser = false; + } + }); + /****************** Update zone status ******************/ @@ -99,7 +142,7 @@ $(document).ready(function() { var statusInterval = window.statusInterval; // Poll and update active zones - setInterval(function () { + setInterval(function() { updateZoneStatus(); }, statusInterval); diff --git a/landingzones/tasks_celery.py b/landingzones/tasks_celery.py index b6948e57..29b3709e 100644 --- a/landingzones/tasks_celery.py +++ b/landingzones/tasks_celery.py @@ -1,4 +1,5 @@ """Celery tasks for the landingzones app""" + import logging from django.conf import settings @@ -10,7 +11,7 @@ from projectroles.models import Project from projectroles.plugins import get_backend_api -from landingzones.models import STATUS_ALLOW_UPDATE, STATUS_LOCKING +from landingzones.constants import STATUS_ALLOW_UPDATE, STATUS_LOCKING from landingzones.views import ZoneMoveMixin logger = logging.getLogger(__name__) diff --git a/landingzones/tasks_taskflow.py b/landingzones/tasks_taskflow.py index 0123569e..d81ead0b 100644 --- a/landingzones/tasks_taskflow.py +++ b/landingzones/tasks_taskflow.py @@ -17,8 +17,14 @@ # Taskflowbackend dependency from taskflowbackend.tasks.sodar_tasks import SODARBaseTask -from landingzones.models import STATUS_BUSY - +from landingzones.constants import ( + STATUS_BUSY, + ZONE_STATUS_FAILED, + ZONE_STATUS_NOT_CREATED, + ZONE_STATUS_MOVED, + ZONE_STATUS_ACTIVE, + ZONE_STATUS_DELETED, +) User = auth.get_user_model() logger = logging.getLogger(__name__) @@ -88,14 +94,16 @@ def _add_owner_alert( ): """Add app alert for zone owner for finished actions""" alert_level = ( - 'DANGER' if zone.status in ['FAILED', 'NOT CREATED'] else 'SUCCESS' + 'DANGER' + if zone.status in [ZONE_STATUS_FAILED, ZONE_STATUS_NOT_CREATED] + else 'SUCCESS' ) alert_url = reverse( 'landingzones:list', kwargs={'project': zone.project.sodar_uuid}, ) - if zone.status == 'MOVED': + if zone.status == ZONE_STATUS_MOVED: alert_msg = 'Successfully moved {} file{} from landing zone'.format( file_count, 's' if file_count != 1 else '' ) @@ -103,15 +111,21 @@ def _add_owner_alert( 'samplesheets:project_sheets', kwargs={'project': zone.project.sodar_uuid}, ) - elif validate_only and zone.status == 'ACTIVE': + elif validate_only and zone.status == ZONE_STATUS_ACTIVE: alert_msg = 'Successfully validated files in landing zone' - elif validate_only and zone.status == 'FAILED': + elif validate_only and zone.status == ZONE_STATUS_FAILED: alert_msg = 'Validation failed for landing zone' - elif flow_name == 'landing_zone_move' and zone.status == 'FAILED': + elif ( + flow_name == 'landing_zone_move' + and zone.status == ZONE_STATUS_FAILED + ): alert_msg = 'Failed to move files from landing zone' - elif zone.status == 'DELETED': + elif zone.status == ZONE_STATUS_DELETED: alert_msg = 'Deleted landing zone' - elif flow_name == 'landing_zone_delete' and zone.status == 'FAILED': + elif ( + flow_name == 'landing_zone_delete' + and zone.status == ZONE_STATUS_FAILED + ): alert_msg = 'Failed to delete landing zone' else: logger.error( @@ -169,17 +183,9 @@ def _send_owner_move_email(cls, zone): zone.project.title, zone.title, ) - if zone.status == 'MOVED': + if zone.status == ZONE_STATUS_MOVED: message_body = EMAIL_MSG_MOVED - email_url = ( - server_host - + reverse( - 'samplesheets:project_sheets', - kwargs={'project': zone.project.sodar_uuid}, - ) - + '#/assay/' - + str(zone.assay.sodar_uuid) - ) + email_url = server_host + zone.assay.get_url() else: # FAILED message_body = EMAIL_MSG_FAILED email_url = ( @@ -211,15 +217,7 @@ def _send_member_move_email(cls, member, zone, file_count): zone.user.get_full_name(), ) message_body = EMAIL_MSG_MEMBER - email_url = ( - server_host - + reverse( - 'samplesheets:project_sheets', - kwargs={'project': zone.project.sodar_uuid}, - ) - + '#/assay/' - + str(zone.assay.sodar_uuid) - ) + email_url = server_host + zone.assay.get_url() message_body = message_body.format( project=zone.project.title, assay=zone.assay.get_display_name(), @@ -267,7 +265,7 @@ def set_status(cls, zone, flow_name, status, status_info, extra_data=None): if ( zone.status not in STATUS_BUSY and flow_name != 'landing_zone_create' - and (file_count > 0 or zone.status != 'MOVED') + and (file_count > 0 or zone.status != ZONE_STATUS_MOVED) ): if app_alerts: try: @@ -299,12 +297,16 @@ def set_status(cls, zone, flow_name, status, status_info, extra_data=None): member_notify = app_settings.get( APP_NAME, 'member_notify_move', project=zone.project ) - if member_notify and zone.status == 'MOVED' and file_count > 0: + if ( + member_notify + and zone.status == ZONE_STATUS_MOVED + and file_count > 0 + ): members = list( set( [ r.user - for r in zone.project.get_all_roles() + for r in zone.project.get_roles() if r.user != zone.user ] ) @@ -336,7 +338,7 @@ def set_status(cls, zone, flow_name, status, status_info, extra_data=None): # If zone is removed by moving or deletion, call plugin function # TODO: TBD: Move into separate task? - if status in ['MOVED', 'DELETED']: + if status in [ZONE_STATUS_MOVED, ZONE_STATUS_DELETED]: from .plugins import get_zone_config_plugin # See issue #269 config_plugin = get_zone_config_plugin(zone) @@ -351,7 +353,7 @@ def set_status(cls, zone, flow_name, status, status_info, extra_data=None): # Update cache # TODO: TBD: Move into separate task? - if status == 'MOVED' and settings.SHEETS_ENABLE_CACHE: + if status == ZONE_STATUS_MOVED and settings.SHEETS_ENABLE_CACHE: try: update_project_cache_task.delay( project_uuid=str(zone.project.sodar_uuid), @@ -405,7 +407,7 @@ def execute( landing_zone, flow_name, info_prefix, - status='FAILED', + status=ZONE_STATUS_FAILED, extra_data=None, *args, **kwargs @@ -417,7 +419,7 @@ def revert( landing_zone, flow_name, info_prefix, - status='FAILED', + status=ZONE_STATUS_FAILED, extra_data=None, *args, **kwargs diff --git a/landingzones/templates/landingzones/_zone_buttons.html b/landingzones/templates/landingzones/_zone_buttons.html index 6d53a6f9..9e5ea674 100644 --- a/landingzones/templates/landingzones/_zone_buttons.html +++ b/landingzones/templates/landingzones/_zone_buttons.html @@ -56,6 +56,14 @@ Copy Zone UUID + {% if zone.status != 'MOVED' and can_update_all or zone.user == request.user and can_update_own %} + + + Update Zone + + {% endif %} + {% if zone.status != 'MOVED' and can_delete_all or zone.user == request.user and can_delete_own %} diff --git a/landingzones/templates/landingzones/_zone_item.html b/landingzones/templates/landingzones/_zone_item.html index cf8c6db4..c0896511 100644 --- a/landingzones/templates/landingzones/_zone_item.html +++ b/landingzones/templates/landingzones/_zone_item.html @@ -14,14 +14,15 @@ + data-status-url="{% url 'landingzones:ajax_status' project=project.sodar_uuid %}">
File/CollectionSizeModifiedMD5MD5iRODS
' + iconHtml + objLink + '' + humanFileSize(obj['size'], true) + '' + obj['modify_time'] + ''; - if (showChecksumCol === true) { - if (obj['md5_file'] === true) { - body += '' + - ''; - } else { - body += '' + - ''; - } + if (obj['md5_file'] === true) { + body += '' + + ''; + } else { + body += '' + + ''; } body += '' + copyButton + '
- + - + {% if zone.user != request.user %} {% get_user_html zone.user as zone_user %} {{ zone_user | safe }} / @@ -34,9 +35,11 @@ {% endif %} {% if zone.description %} - {% get_zone_desc_html zone as zone_desc_html %} - {% get_info_link zone_desc_html html=True as info_link %} - {{ info_link | safe }} + + {% get_zone_desc_html zone as zone_desc_html %} + {% get_info_link zone_desc_html html=True as info_link %} + {{ info_link | safe }} + {% endif %} {% if zone.configuration %} {% get_config_legend zone %} @@ -60,7 +63,7 @@ {% if zone.status == 'MOVED' %}

- + Browse files in sample sheets diff --git a/landingzones/templates/landingzones/landingzone_confirm_clear.html b/landingzones/templates/landingzones/landingzone_confirm_clear.html index 34a46892..71a8ef03 100644 --- a/landingzones/templates/landingzones/landingzone_confirm_clear.html +++ b/landingzones/templates/landingzones/landingzone_confirm_clear.html @@ -29,7 +29,7 @@

Confirm Clearing of Inactive Landing Zones

Cancel -
diff --git a/landingzones/templates/landingzones/landingzone_confirm_delete.html b/landingzones/templates/landingzones/landingzone_confirm_delete.html index 9e16b6f2..65aab73e 100644 --- a/landingzones/templates/landingzones/landingzone_confirm_delete.html +++ b/landingzones/templates/landingzones/landingzone_confirm_delete.html @@ -26,7 +26,7 @@

Confirm Landing Zone Deletion

href="{{ request.session.real_referer }}"> Cancel - diff --git a/landingzones/templates/landingzones/landingzone_confirm_move.html b/landingzones/templates/landingzones/landingzone_confirm_move.html index 164d162c..050569d4 100644 --- a/landingzones/templates/landingzones/landingzone_confirm_move.html +++ b/landingzones/templates/landingzones/landingzone_confirm_move.html @@ -32,11 +32,11 @@

Confirm iRODS File Validation {% if not validate_only %}and Moving{% endif % Cancel {% if validate_only %} - {% else %} - diff --git a/landingzones/templates/landingzones/landingzone_form.html b/landingzones/templates/landingzones/landingzone_form.html index 9971e60d..49ffd7ad 100644 --- a/landingzones/templates/landingzones/landingzone_form.html +++ b/landingzones/templates/landingzones/landingzone_form.html @@ -3,13 +3,33 @@ {% load crispy_forms_filters %} {% block title %} - Create Landing Zone in {{ project.title }} + {% if not object.pk %} + Create Landing Zone in {{ project.title }} + {% else %} + Update Landing Zone + {% if request.user.username == zone.user.username %} + {{ object.title }} + {% else %} + {{ object.user.username }} / {{ object.title }} + {% endif %} + {% endif %} {% endblock title %} {% block projectroles_extend %}
-

Create Landing Zone

+

+ {% if not object.pk %} + Create Landing Zone in {{ project.title }} + {% else %} + Update Landing Zone + {% if request.user.username == object.user.username %} + {{ object.title }} + {% else %} + {{ object.user.username }} / {{ object.title }} + {% endif %} + {% endif %} +

@@ -22,8 +42,12 @@

Create Landing Zone

href="{{ request.session.real_referer }}"> Cancel -
diff --git a/landingzones/templates/landingzones/project_zones.html b/landingzones/templates/landingzones/project_zones.html index f6f0c459..833864e3 100644 --- a/landingzones/templates/landingzones/project_zones.html +++ b/landingzones/templates/landingzones/project_zones.html @@ -20,34 +20,32 @@ .sodar-lz-table { table-layout: fixed; } - .sodar-lz-table thead tr th:nth-child(1){ width: 350px; } - .sodar-lz-table tbody tr td:nth-child(2) { word-wrap: break-word; } - .sodar-lz-table thead tr th:nth-child(3), .sodar-lz-table tbody tr td:nth-child(3) { white-space: nowrap; width: 150px; } - .sodar-lz-table thead tr th:nth-child(4), .sodar-lz-table tbody tr td:nth-child(4) { width: 150px; max-width: 150px; white-space: nowrap; } - .sodar-lz-table thead tr th:nth-child(5), .sodar-lz-table tbody tr td:nth-child(5) { text-align: right; width: 70px; max-width: 140px; } + .sodar-lz-zone-assay-link:hover { + text-decoration: none; + } /* Responsive modifications */ @media screen and (max-width: 1300px) { @@ -95,8 +93,7 @@

Landing Zones

{% if not zone_access_disabled and investigation and can_create_zone %} @@ -157,8 +154,12 @@

Landing Zones

@@ -170,16 +171,14 @@

Landing Zones

{# Tour content #} {% endblock javascript %} diff --git a/landingzones/templatetags/landingzones_tags.py b/landingzones/templatetags/landingzones_tags.py index 332d9d08..7efbaf3f 100644 --- a/landingzones/templatetags/landingzones_tags.py +++ b/landingzones/templatetags/landingzones_tags.py @@ -6,7 +6,8 @@ # Projectroles dependency from projectroles.plugins import get_backend_api -from landingzones.models import LandingZone, STATUS_STYLES, STATUS_FINISHED +from landingzones.constants import STATUS_STYLES, STATUS_FINISHED +from landingzones.models import LandingZone from landingzones.plugins import get_zone_config_plugin @@ -49,20 +50,6 @@ def get_zone_desc_html(zone): ) -@register.simple_tag -def get_zone_samples_url(zone): - """Return URL for samples related to zone""" - # TODO: TBD: Inherit this from samplesheets instead? - return ( - reverse( - 'samplesheets:project_sheets', - kwargs={'project': zone.project.sodar_uuid}, - ) - + '#/assay/' - + str(zone.assay.sodar_uuid) - ) - - @register.simple_tag def is_zone_enabled(zone): """Return True/False if the zone can be enabled in the UI""" diff --git a/landingzones/tests/test_commands.py b/landingzones/tests/test_commands.py index a7115085..339e9346 100644 --- a/landingzones/tests/test_commands.py +++ b/landingzones/tests/test_commands.py @@ -5,7 +5,6 @@ from datetime import timedelta from unittest import mock -from django.conf import settings from django.core.management import call_command from django.utils.timezone import localtime @@ -13,14 +12,23 @@ # Projectroles dependency from projectroles.constants import SODAR_CONSTANTS -from projectroles.models import Role from projectroles.plugins import get_backend_api -from projectroles.tests.test_models import ProjectMixin, RoleAssignmentMixin +from projectroles.tests.test_models import ( + ProjectMixin, + RoleMixin, + RoleAssignmentMixin, +) from landingzones.management.commands.inactivezones import ( get_inactive_zones, get_output, ) +from landingzones.constants import ( + ZONE_STATUS_MOVED, + ZONE_STATUS_DELETED, + ZONE_STATUS_ACTIVE, + ZONE_STATUS_MOVING, +) from landingzones.tests.test_models import LandingZoneMixin from samplesheets.tests.test_io import SampleSheetIOMixin, SHEET_DIR @@ -33,33 +41,27 @@ ZONE2_TITLE = '20201123_143323_test_zone' ZONE3_TITLE = '20201218_172740_test_zone_moved' ZONE4_TITLE = '20201218_172743_test_zone_deleted' -IRODS_BACKEND_ENABLED = ( - True if 'omics_irods' in settings.ENABLED_BACKEND_PLUGINS else False -) -IRODS_BACKEND_SKIP_MSG = 'iRODS backend not enabled in settings' LOGGER_BUSY_ZONES = 'landingzones.management.commands.busyzones' -class TestCommandBase( +class LandingzonesCommandTestBase( ProjectMixin, - SampleSheetIOMixin, + RoleMixin, RoleAssignmentMixin, + SampleSheetIOMixin, LandingZoneMixin, TestCase, ): """Base class for command tests""" def setUp(self): - super().setUp() - + # Init roles + self.init_roles() # Init superuser self.user = self.make_user('user') self.user.is_superuser = True self.user.is_staff = True self.user.save() - - # Init roles - self.role_owner = Role.objects.get_or_create(name=PROJECT_ROLE_OWNER)[0] # Init project with owner self.project = self.make_project( 'TestProject', PROJECT_TYPE_PROJECT, None @@ -67,13 +69,12 @@ def setUp(self): self.owner_as = self.make_assignment( self.project, self.user, self.role_owner ) - self.investigation = self.import_isa_from_file(SHEET_PATH, self.project) self.study = self.investigation.studies.first() self.assay = self.study.assays.first() -class TestInactiveZones(TestCommandBase): +class TestInactiveZones(LandingzonesCommandTestBase): """Tests for the inactivezones command""" def setUp(self): @@ -102,7 +103,7 @@ def setUp(self): description=ZONE_DESC, configuration=None, config_data={}, - status='MOVED', + status=ZONE_STATUS_MOVED, ) # Create landing zone 3 from 3 weeks ago but status DELETED self.zone4 = self.make_landing_zone( @@ -113,7 +114,7 @@ def setUp(self): description=ZONE_DESC, configuration=None, config_data={}, - status='DELETED', + status=ZONE_STATUS_DELETED, ) mock_now.return_value = testtime2 # Create landing zone 2 from 1 week ago @@ -130,16 +131,12 @@ def setUp(self): self.irods_backend = get_backend_api('omics_irods') self.irods = self.irods_backend.get_session_obj() - # Create the irods collections + # Create iRODS collections self.irods.collections.create(self.irods_backend.get_path(self.zone)) self.irods.collections.create(self.irods_backend.get_path(self.zone2)) self.irods.collections.create(self.irods_backend.get_path(self.zone3)) self.irods.collections.create(self.irods_backend.get_path(self.zone4)) - def tearDown(self): - self.irods.collections.get('/sodarZone/projects').remove(force=True) - self.irods.cleanup() - def test_get_inactive_zones(self): """Test get_inactive_zones()""" zones = get_inactive_zones() @@ -175,13 +172,12 @@ def test_command_inactivezones(self): self.assertIn(expected, cm.output[0]) -class TestBusyZones(TestCommandBase): +class TestBusyZones(LandingzonesCommandTestBase): """Tests for the busyzones command""" def setUp(self): super().setUp() - - # Create LandingZone 1 from 3 weeks ago + # Create zones self.zone = self.make_landing_zone( title=ZONE1_TITLE, project=self.project, @@ -190,7 +186,7 @@ def setUp(self): description=ZONE_DESC, configuration=None, config_data={}, - status='ACTIVE', + status=ZONE_STATUS_ACTIVE, ) self.zone2 = self.make_landing_zone( title=ZONE2_TITLE, @@ -200,7 +196,7 @@ def setUp(self): description=ZONE_DESC, configuration=None, config_data={}, - status='ACTIVE', + status=ZONE_STATUS_ACTIVE, ) def test_active_zones(self): @@ -211,7 +207,7 @@ def test_active_zones(self): def test_command(self): """Test command with a busy zone""" - self.zone2.status = 'MOVING' + self.zone2.status = ZONE_STATUS_MOVING self.zone2.save() with self.assertLogs(LOGGER_BUSY_ZONES, level='INFO') as cm: call_command('busyzones') diff --git a/landingzones/tests/test_models.py b/landingzones/tests/test_models.py index 783265e5..eed8016a 100644 --- a/landingzones/tests/test_models.py +++ b/landingzones/tests/test_models.py @@ -5,16 +5,26 @@ from test_plus.test import TestCase # Projectroles dependency -from projectroles.models import Role, SODAR_CONSTANTS -from projectroles.tests.test_models import ProjectMixin, RoleAssignmentMixin +from projectroles.models import SODAR_CONSTANTS +from projectroles.tests.test_models import ( + ProjectMixin, + RoleMixin, + RoleAssignmentMixin, +) # Samplesheets dependency from samplesheets.tests.test_io import SampleSheetIOMixin, SHEET_DIR -from landingzones.models import LandingZone, DEFAULT_STATUS_INFO +from landingzones.constants import ( + DEFAULT_STATUS_INFO, + ZONE_STATUS_CREATING, + ZONE_STATUS_ACTIVE, + ZONE_STATUS_MOVING, +) +from landingzones.models import LandingZone -# Global constants +# SODAR constants PROJECT_ROLE_OWNER = SODAR_CONSTANTS['PROJECT_ROLE_OWNER'] PROJECT_ROLE_DELEGATE = SODAR_CONSTANTS['PROJECT_ROLE_DELEGATE'] PROJECT_ROLE_CONTRIBUTOR = SODAR_CONSTANTS['PROJECT_ROLE_CONTRIBUTOR'] @@ -27,8 +37,7 @@ ZONE_TITLE = '20180503_1724_test_zone' ZONE_DESC = 'description' ZONE_MSG = 'user message' -ZONE_STATUS_INIT = 'CREATING' -ZONE_STATUS_INFO_INIT = DEFAULT_STATUS_INFO['CREATING'] +ZONE_STATUS_INFO_INIT = DEFAULT_STATUS_INFO[ZONE_STATUS_CREATING] class LandingZoneMixin: @@ -46,7 +55,7 @@ def make_landing_zone( assay, description='', user_message='', - status='CREATING', + status=ZONE_STATUS_CREATING, configuration=None, config_data={}, ): @@ -66,33 +75,32 @@ def make_landing_zone( return result -class TestLandingZoneBase( +class TestLandingZone( LandingZoneMixin, SampleSheetIOMixin, ProjectMixin, + RoleMixin, RoleAssignmentMixin, TestCase, ): - """Base tests for LandingZone""" + """Tests for LandingZone""" def setUp(self): + # Init roles + self.init_roles() # Make owner user self.user_owner = self.make_user('owner') - - # Init project, role and assignment + # Init project and assignment self.project = self.make_project( 'TestProject', PROJECT_TYPE_PROJECT, None ) - self.role_owner = Role.objects.get_or_create(name=PROJECT_ROLE_OWNER)[0] - self.assignment_owner = self.make_assignment( + self.owner_as = self.make_assignment( self.project, self.user_owner, self.role_owner ) - # Import investigation self.investigation = self.import_isa_from_file(SHEET_PATH, self.project) self.study = self.investigation.studies.first() self.assay = self.study.assays.first() - # Create LandingZone self.landing_zone = self.make_landing_zone( title=ZONE_TITLE, @@ -105,13 +113,6 @@ def setUp(self): config_data={}, ) - -class TestLandingZone(TestLandingZoneBase): - """Tests for LandingZone""" - - def setUp(self): - super().setUp() - def test_initialization(self): """Test LandingZone initialization""" expected = { @@ -124,11 +125,10 @@ def test_initialization(self): 'user_message': ZONE_MSG, 'configuration': None, 'config_data': {}, - 'status': ZONE_STATUS_INIT, + 'status': ZONE_STATUS_CREATING, 'status_info': ZONE_STATUS_INFO_INIT, 'sodar_uuid': self.landing_zone.sodar_uuid, } - self.assertEqual(model_to_dict(self.landing_zone), expected) def test__str__(self): @@ -150,33 +150,23 @@ def test__repr__(self): def test_set_status(self): """Test set_status() with status and status_info""" - status = 'ACTIVE' + status = ZONE_STATUS_ACTIVE status_info = 'ok' - - # Assert preconditions self.assertNotEqual(self.landing_zone.status, status) self.assertNotEqual(self.landing_zone.status_info, status_info) - self.landing_zone.set_status(status, status_info) self.landing_zone.refresh_from_db() - - # Assert postconditions self.assertEqual(self.landing_zone.status, status) self.assertEqual(self.landing_zone.status_info, status_info) def test_set_status_no_info(self): """Test set_status() without status_info""" - status = 'ACTIVE' + status = ZONE_STATUS_ACTIVE status_info = DEFAULT_STATUS_INFO[status] - - # Assert preconditions self.assertNotEqual(self.landing_zone.status, status) self.assertNotEqual(self.landing_zone.status_info, status_info) - self.landing_zone.set_status(status) self.landing_zone.refresh_from_db() - - # Assert postconditions self.assertEqual(self.landing_zone.status, status) self.assertEqual(self.landing_zone.status_info, status_info) @@ -192,15 +182,15 @@ def test_is_locked_false(self): def test_is_locked_true(self): """Test is_locked() with MOVING status""" - self.landing_zone.status = 'MOVING' + self.landing_zone.status = ZONE_STATUS_MOVING self.assertEqual(self.landing_zone.is_locked(), True) def test_can_display_files_true(self): """Test can_display_files() with a valid zone status""" - self.landing_zone.status = 'ACTIVE' + self.landing_zone.status = ZONE_STATUS_ACTIVE self.assertEqual(self.landing_zone.can_display_files(), True) def test_can_display_files_false(self): """Test display_files() with an invalid zone status""" - self.landing_zone.status = 'CREATING' + self.landing_zone.status = ZONE_STATUS_CREATING self.assertEqual(self.landing_zone.can_display_files(), False) diff --git a/landingzones/tests/test_permissions.py b/landingzones/tests/test_permissions.py index d850ca22..09b40926 100644 --- a/landingzones/tests/test_permissions.py +++ b/landingzones/tests/test_permissions.py @@ -17,7 +17,7 @@ ) -# Global constants +# SODAR constants PROJECT_ROLE_OWNER = SODAR_CONSTANTS['PROJECT_ROLE_OWNER'] PROJECT_ROLE_DELEGATE = SODAR_CONSTANTS['PROJECT_ROLE_DELEGATE'] PROJECT_ROLE_CONTRIBUTOR = SODAR_CONSTANTS['PROJECT_ROLE_CONTRIBUTOR'] @@ -27,322 +27,401 @@ # Local constants SHEET_PATH = SHEET_DIR + 'i_small.zip' +TEST_OBJ_NAME = 'test1.txt' -class TestLandingZonePermissionsBase( - LandingZoneMixin, SampleSheetIOMixin, TestProjectPermissionBase +class LandingzonesPermissionTestBase( + LandingZoneMixin, + SampleSheetIOMixin, + TestProjectPermissionBase, ): - """Base view for landingzones permissions tests""" + """Base class for landingzones permissions tests""" - def setUp(self): - super().setUp() - # Import investigation - self.investigation = self.import_isa_from_file(SHEET_PATH, self.project) - self.study = self.investigation.studies.first() - self.assay = self.study.assays.first() - # Create LandingZone - self.landing_zone = self.make_landing_zone( - title=ZONE_TITLE, - project=self.project, - user=self.user_contributor, - assay=self.assay, - description=ZONE_DESC, - status='ACTIVE', - configuration=None, - config_data={}, - ) +class TestProjectZoneView(LandingzonesPermissionTestBase): + """Tests for ProjectZoneView permissions""" -class TestLandingZonePermissions(TestLandingZonePermissionsBase): - """Tests for landingzones UI view permissions""" - - def test_zone_list(self): - """Test ProjectZoneView permissions""" - url = reverse( + def setUp(self): + super().setUp() + self.url = reverse( 'landingzones:list', kwargs={'project': self.project.sodar_uuid} ) - good_users = [ - self.superuser, - self.user_owner_cat, # Inherited owner - self.user_owner, - self.user_delegate, - self.user_contributor, - ] - bad_users = [self.user_guest, self.anonymous, self.user_no_roles] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 302) - def test_zone_list_archive(self): - """Test ProjectZoneView with archived project""" - self.project.set_archive() - url = reverse( - 'landingzones:list', kwargs={'project': self.project.sodar_uuid} - ) + def test_get(self): + """Test ProjectZoneView GET""" good_users = [ self.superuser, - self.user_owner_cat, + self.user_owner_cat, # Inherited + self.user_delegate_cat, # Inherited + self.user_contributor_cat, # Inherited self.user_owner, self.user_delegate, self.user_contributor, ] - bad_users = [self.user_guest, self.anonymous, self.user_no_roles] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 302) - - @override_settings(LANDINGZONES_DISABLE_FOR_USERS=True) - def test_zone_list_disable(self): - """Test ProjectZoneView with disabled non-superuser access""" - url = reverse( - 'landingzones:list', kwargs={'project': self.project.sodar_uuid} - ) - good_users = [ - self.superuser, - self.user_owner_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, + bad_users = [ + self.user_guest_cat, # Inherited + self.user_finder_cat, # Inherited + self.user_guest, + self.user_no_roles, + self.anonymous, ] - bad_users = [self.user_guest, self.anonymous, self.user_no_roles] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 302) - - def test_zone_create(self): - """Test ZoneCreateView permissions""" - url = reverse( - 'landingzones:create', kwargs={'project': self.project.sodar_uuid} + self.assert_response(self.url, good_users, 200) + self.assert_response(self.url, bad_users, 302) + self.project.set_public() + self.assert_response( + self.url, [self.user_no_roles, self.anonymous], 302 ) + + @override_settings(PROJECTROLES_ALLOW_ANONYMOUS=True) + def test_get_anon(self): + """Test GET with anonymous access""" + self.project.set_public() + self.assert_response(self.url, self.anonymous, 302) + + def test_get_archive(self): + """Test GET with archived project""" + self.project.set_archive() good_users = [ self.superuser, self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, self.user_owner, self.user_delegate, self.user_contributor, ] - bad_users = [self.user_guest, self.anonymous, self.user_no_roles] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 302) - - def test_zone_create_archive(self): - """Test ZoneCreateView with archived project""" - self.project.set_archive() - url = reverse( - 'landingzones:create', kwargs={'project': self.project.sodar_uuid} - ) - good_users = [self.superuser] bad_users = [ - self.user_owner_cat, - self.user_owner, - self.user_delegate, - self.user_contributor, + self.user_guest_cat, + self.user_finder_cat, self.user_guest, self.anonymous, self.user_no_roles, ] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 302) + self.assert_response(self.url, good_users, 200) + self.assert_response(self.url, bad_users, 302) @override_settings(LANDINGZONES_DISABLE_FOR_USERS=True) - def test_zone_create_disable(self): - """Test ZoneCreateView with disabled non-superuser access""" - url = reverse( - 'landingzones:create', kwargs={'project': self.project.sodar_uuid} - ) - good_users = [self.superuser] - bad_users = [ + def test_get_disable(self): + """Test GET with disabled non-superuser access""" + good_users = [ + self.superuser, self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, self.user_owner, self.user_delegate, self.user_contributor, + ] + bad_users = [ + self.user_guest_cat, + self.user_finder_cat, self.user_guest, - self.anonymous, self.user_no_roles, + self.anonymous, ] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 302) - - def test_zone_delete(self): - """Test ZoneDeleteView permissions""" - url = reverse( - 'landingzones:delete', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, + self.assert_response(self.url, good_users, 200) + self.assert_response(self.url, bad_users, 302) + self.project.set_public() + self.assert_response( + self.url, [self.user_no_roles, self.anonymous], 302 + ) + + +class TestZoneCreateView(LandingzonesPermissionTestBase): + """Tests for ZoneCreateView permissions""" + + def setUp(self): + super().setUp() + self.url = reverse( + 'landingzones:create', kwargs={'project': self.project.sodar_uuid} ) + + def test_get(self): + """Test ZoneCreateView GET""" good_users = [ self.superuser, self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, self.user_owner, self.user_delegate, self.user_contributor, ] bad_users = [ + self.user_guest_cat, + self.user_finder_cat, self.user_guest, - self.anonymous, self.user_no_roles, + self.anonymous, ] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 302) + self.assert_response(self.url, good_users, 200) + self.assert_response(self.url, bad_users, 302) + self.project.set_public() + self.assert_response( + self.url, [self.user_no_roles, self.anonymous], 302 + ) - def test_zone_delete_archive(self): - """Test ZoneDeleteView with archived project""" + @override_settings(PROJECTROLES_ALLOW_ANONYMOUS=True) + def test_get_anon(self): + """Test GET with anonymous access""" + self.project.set_public() + self.assert_response(self.url, self.anonymous, 302) + + def test_get_archive(self): + """Test GET with archived project""" self.project.set_archive() - url = reverse( - 'landingzones:delete', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ) - good_users = [ - self.superuser, + good_users = [self.superuser] + bad_users = [ self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, self.user_owner, self.user_delegate, self.user_contributor, - ] - bad_users = [ self.user_guest, - self.anonymous, self.user_no_roles, + self.anonymous, ] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 302) + self.assert_response(self.url, good_users, 200) + self.assert_response(self.url, bad_users, 302) + self.project.set_public() + self.assert_response( + self.url, [self.user_no_roles, self.anonymous], 302 + ) @override_settings(LANDINGZONES_DISABLE_FOR_USERS=True) - def test_zone_delete_disable(self): - """Test ZoneDeleteView with disabled non-superuser access""" - url = reverse( - 'landingzones:delete', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ) - good_users = [ - self.superuser, - ] + def test_get_disable(self): + """Test ZoneCreateView with disabled non-superuser access""" + good_users = [self.superuser] bad_users = [ self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, self.user_owner, self.user_delegate, self.user_contributor, self.user_guest, - self.anonymous, self.user_no_roles, + self.anonymous, ] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 302) - - def test_zone_move(self): - """Test ZoneMoveView permissions""" - url = reverse( - 'landingzones:move', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, + self.assert_response(self.url, good_users, 200) + self.assert_response(self.url, bad_users, 302) + + +class TestZoneUpdateView(LandingzonesPermissionTestBase): + """Tests for ZoneUpdateView permissions""" + + def setUp(self): + super().setUp() + self.investigation = self.import_isa_from_file(SHEET_PATH, self.project) + self.study = self.investigation.studies.first() + self.assay = self.study.assays.first() + zone = self.make_landing_zone( + title=ZONE_TITLE, + project=self.project, + user=self.user_contributor, # NOTE: Zone owner = user_contributor + assay=self.assay, + description=ZONE_DESC, + status='ACTIVE', + configuration=None, + config_data={}, + ) + self.url = reverse( + 'landingzones:update', kwargs={'landingzone': zone.sodar_uuid} + ) + self.redirect_url = reverse( + 'landingzones:list', kwargs={'project': zone.project.sodar_uuid} ) + + def test_get(self): + """Test ZoneUpdateView GET""" good_users = [ self.superuser, self.user_owner_cat, + self.user_delegate_cat, self.user_owner, self.user_delegate, self.user_contributor, ] bad_users = [ + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, self.user_guest, - self.anonymous, self.user_no_roles, + self.anonymous, ] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 302) + self.assert_response(self.url, good_users, 200) + self.assert_response( + self.url, bad_users, 302, redirect_user=self.redirect_url + ) + self.project.set_public() + self.assert_response( + self.url, + [self.user_no_roles, self.anonymous], + 302, + redirect_user=self.redirect_url, + ) - def test_zone_move_archive(self): - """Test ZoneMoveView with archived project""" - self.project.set_archive() - url = reverse( - 'landingzones:move', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, + @override_settings(PROJECTROLES_ALLOW_ANONYMOUS=True) + def test_get_anon(self): + """Test GET with anonymous access""" + self.project.set_public() + self.assert_response( + self.url, self.anonymous, 302, redirect_user=self.redirect_url ) + + def test_get_archive(self): + """Test GET with archived project""" + self.project.set_archive() good_users = [self.superuser] bad_users = [ self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, self.user_owner, self.user_delegate, self.user_contributor, self.user_guest, - self.anonymous, self.user_no_roles, + self.anonymous, ] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 302) + self.assert_response(self.url, good_users, 200) + self.assert_response( + self.url, bad_users, 302, redirect_user=self.redirect_url + ) + self.project.set_public() + self.assert_response( + self.url, + [self.user_no_roles, self.anonymous], + 302, + redirect_user=self.redirect_url, + ) @override_settings(LANDINGZONES_DISABLE_FOR_USERS=True) - def test_zone_move_disable(self): - """Test ZoneMoveView with disabled non-superuser access""" - url = reverse( - 'landingzones:move', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ) + def test_get_disable(self): + """Test GET with disabled non-superuser access""" good_users = [self.superuser] bad_users = [ self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, self.user_owner, self.user_delegate, self.user_contributor, self.user_guest, - self.anonymous, self.user_no_roles, + self.anonymous, ] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 302) - - def test_zone_validate(self): - """Test ZoneMoveView for zone validation""" - url = reverse( - 'landingzones:validate', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, + self.assert_response(self.url, good_users, 200) + self.assert_response( + self.url, bad_users, 302, redirect_user=self.redirect_url + ) + + +class TestZoneDeleteView(LandingzonesPermissionTestBase): + """Tests for ZoneDeleteView permissions""" + + def setUp(self): + super().setUp() + self.investigation = self.import_isa_from_file(SHEET_PATH, self.project) + self.study = self.investigation.studies.first() + self.assay = self.study.assays.first() + zone = self.make_landing_zone( + title=ZONE_TITLE, + project=self.project, + user=self.user_contributor, # NOTE: Zone owner = user_contributor + assay=self.assay, + description=ZONE_DESC, + status='ACTIVE', + configuration=None, + config_data={}, + ) + self.url = reverse( + 'landingzones:delete', kwargs={'landingzone': zone.sodar_uuid} ) + + def test_get(self): + """Test ZoneDeleteView GET""" good_users = [ self.superuser, self.user_owner_cat, + self.user_delegate_cat, self.user_owner, self.user_delegate, self.user_contributor, ] bad_users = [ + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, self.user_guest, - self.anonymous, self.user_no_roles, + self.anonymous, ] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 302) + self.assert_response(self.url, good_users, 200) + self.assert_response(self.url, bad_users, 302) + self.project.set_public() + self.assert_response( + self.url, [self.user_no_roles, self.anonymous], 302 + ) + + @override_settings(PROJECTROLES_ALLOW_ANONYMOUS=True) + def test_get_anon(self): + """Test GET with anonymous access""" + self.project.set_public() + self.assert_response(self.url, self.anonymous, 302) - def test_zone_validate_archive(self): - """Test ZoneMoveView for zone validation with archived project""" + def test_get_archive(self): + """Test GET with archived project""" self.project.set_archive() - url = reverse( - 'landingzones:validate', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ) - good_users = [self.superuser] - bad_users = [ + good_users = [ + self.superuser, self.user_owner_cat, + self.user_delegate_cat, self.user_owner, self.user_delegate, self.user_contributor, + ] + bad_users = [ + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, self.user_guest, - self.anonymous, self.user_no_roles, + self.anonymous, ] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 302) + self.assert_response(self.url, good_users, 200) + self.assert_response(self.url, bad_users, 302) + self.project.set_public() + self.assert_response( + self.url, [self.user_no_roles, self.anonymous], 302 + ) @override_settings(LANDINGZONES_DISABLE_FOR_USERS=True) - def test_zone_validate_disable(self): - """Test ZoneMoveView for zone validation with disabled non-superuser access""" - url = reverse( - 'landingzones:validate', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ) + def test_get_disable(self): + """Test GET with disabled non-superuser access""" good_users = [self.superuser] bad_users = [ self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, self.user_owner, self.user_delegate, self.user_contributor, self.user_guest, - self.anonymous, self.user_no_roles, + self.anonymous, ] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 302) + self.assert_response(self.url, good_users, 200) + self.assert_response(self.url, bad_users, 302) diff --git a/landingzones/tests/test_permissions_ajax.py b/landingzones/tests/test_permissions_ajax.py index aab7d4b3..a9420f92 100644 --- a/landingzones/tests/test_permissions_ajax.py +++ b/landingzones/tests/test_permissions_ajax.py @@ -1,52 +1,114 @@ """Tests for Ajax API view permissions in the landingzones app""" +from django.test import override_settings from django.urls import reverse -from landingzones.tests.test_permissions import TestLandingZonePermissionsBase +# Samplesheets dependency +from samplesheets.tests.test_io import SHEET_DIR +from landingzones.tests.test_models import ZONE_TITLE, ZONE_DESC +from landingzones.tests.test_permissions import LandingzonesPermissionTestBase -class TestZoneStatusRetrieveAjaxViewPermissions(TestLandingZonePermissionsBase): + +# Local constants +SHEET_PATH = SHEET_DIR + 'i_small.zip' + + +class TestZoneStatusRetrieveAjaxViewPermissions(LandingzonesPermissionTestBase): """Tests for ZoneStatusRetrieveAjaxView permissions""" - def test_zone_status(self): - """Test ZoneStatusRetrieveAjaxView permissions""" - url = reverse( + def setUp(self): + super().setUp() + self.investigation = self.import_isa_from_file(SHEET_PATH, self.project) + self.study = self.investigation.studies.first() + self.assay = self.study.assays.first() + zone = self.make_landing_zone( + title=ZONE_TITLE, + project=self.project, + user=self.user_contributor, # NOTE: Zone owner = user_contributor + assay=self.assay, + description=ZONE_DESC, + status='ACTIVE', + configuration=None, + config_data={}, + ) + self.url = reverse( 'landingzones:ajax_status', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, + kwargs={'project': self.project.sodar_uuid}, ) + self.post_data = {'zone_uuids': [str(zone.sodar_uuid)]} + + def test_post(self): + """Test ZoneStatusRetrieveAjaxView POST""" good_users = [ self.superuser, - self.user_owner_cat, # Inherited owner + self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, self.user_owner, self.user_delegate, self.user_contributor, # Zone owner ] bad_users = [ + self.user_guest_cat, + self.user_finder_cat, self.user_guest, self.user_no_roles, self.anonymous, ] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 403) + self.assert_response( + self.url, good_users, 200, method='post', data=self.post_data + ) + self.assert_response( + self.url, bad_users, 403, method='post', data=self.post_data + ) + self.project.set_public() + self.assert_response( + self.url, + [self.user_no_roles, self.anonymous], + 403, + method='post', + data=self.post_data, + ) - def test_zone_status_archive(self): - """Test ZoneStatusRetrieveAjaxView with archived project""" - self.project.set_archive() - url = reverse( - 'landingzones:ajax_status', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, + @override_settings(PROJECTROLES_ALLOW_ANONYMOUS=True) + def test_post_anon(self): + """Test POST with anonymous access""" + self.project.set_public() + self.assert_response( + self.url, self.anonymous, 403, method='post', data=self.post_data ) + + def test_post_archive(self): + """Test POST with archived project""" + self.project.set_archive() good_users = [ self.superuser, self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, self.user_owner, self.user_delegate, self.user_contributor, ] bad_users = [ + self.user_guest_cat, + self.user_finder_cat, self.user_guest, self.user_no_roles, self.anonymous, ] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 403) + self.assert_response( + self.url, good_users, 200, method='post', data=self.post_data + ) + self.assert_response( + self.url, bad_users, 403, method='post', data=self.post_data + ) + self.project.set_public() + self.assert_response( + self.url, + [self.user_no_roles, self.anonymous], + 403, + method='post', + data=self.post_data, + ) diff --git a/landingzones/tests/test_permissions_api.py b/landingzones/tests/test_permissions_api.py index a5b7d7ab..5b8a612a 100644 --- a/landingzones/tests/test_permissions_api.py +++ b/landingzones/tests/test_permissions_api.py @@ -1,10 +1,11 @@ """Tests for REST API view permissions in the landingzones app""" +from django.test import override_settings from django.urls import reverse # Projectroles dependency from projectroles.models import SODAR_CONSTANTS -from projectroles.tests.test_permissions import TestProjectPermissionBase +from projectroles.tests.test_permissions_api import TestProjectAPIPermissionBase # Samplesheets dependency from samplesheets.tests.test_io import SampleSheetIOMixin, SHEET_DIR @@ -16,7 +17,7 @@ ) -# Global constants +# SODAR constants PROJECT_ROLE_OWNER = SODAR_CONSTANTS['PROJECT_ROLE_OWNER'] PROJECT_ROLE_DELEGATE = SODAR_CONSTANTS['PROJECT_ROLE_DELEGATE'] PROJECT_ROLE_CONTRIBUTOR = SODAR_CONSTANTS['PROJECT_ROLE_CONTRIBUTOR'] @@ -28,10 +29,81 @@ SHEET_PATH = SHEET_DIR + 'i_small.zip' -class TestLandingZonePermissions( - LandingZoneMixin, SampleSheetIOMixin, TestProjectPermissionBase +class ZoneAPIPermissionTestBase( + LandingZoneMixin, + SampleSheetIOMixin, + TestProjectAPIPermissionBase, ): - """Tests for landingzones REST API view permissions""" + """Base class for landingzones REST API view permission tests""" + + +class TestZoneListAPIView(ZoneAPIPermissionTestBase): + """Tests for ZoneListAPIView permissions""" + + def setUp(self): + super().setUp() + self.url = reverse( + 'landingzones:api_list', kwargs={'project': self.project.sodar_uuid} + ) + + def test_get(self): + """Test ZoneListAPIView GET""" + good_users = [ + self.superuser, + self.user_owner_cat, # Inherited + self.user_delegate_cat, # Inherited + self.user_contributor_cat, # Inherited + self.user_owner, + self.user_delegate, + self.user_contributor, + ] + bad_users = [ + self.user_guest_cat, # Inherited + self.user_finder_cat, # Inherited + self.user_guest, + self.user_no_roles, + ] + self.assert_response(self.url, good_users, 200) + self.assert_response(self.url, bad_users, 403) + self.assert_response(self.url, self.anonymous, 401) + self.project.set_public() + self.assert_response(self.url, self.user_no_roles, 403) + self.assert_response(self.url, self.anonymous, 401) + + @override_settings(PROJECTROLES_ALLOW_ANONYMOUS=True) + def test_get_anon(self): + """Test GET with anonymous access""" + self.project.set_public() + self.assert_response(self.url, self.anonymous, 401) + + def test_get_archive(self): + """Test GET with archived project""" + self.project.set_archive() + good_users = [ + self.superuser, + self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, + self.user_owner, + self.user_delegate, + self.user_contributor, + ] + bad_users = [ + self.user_guest_cat, + self.user_finder_cat, + self.user_guest, + self.user_no_roles, + ] + self.assert_response(self.url, good_users, 200) + self.assert_response(self.url, bad_users, 403) + self.assert_response(self.url, self.anonymous, 401) + self.project.set_public() + self.assert_response(self.url, self.user_no_roles, 403) + self.assert_response(self.url, self.anonymous, 401) + + +class TestZoneRetrieveAPIView(ZoneAPIPermissionTestBase): + """Tests for ZoneRetrieveAPIView permissions""" def setUp(self): super().setUp() @@ -39,8 +111,8 @@ def setUp(self): self.investigation = self.import_isa_from_file(SHEET_PATH, self.project) self.study = self.investigation.studies.first() self.assay = self.study.assays.first() - # Create LandingZone for project owner - self.landing_zone = self.make_landing_zone( + # Create zone for project owner + zone = self.make_landing_zone( title=ZONE_TITLE, project=self.project, user=self.user_owner, @@ -49,81 +121,214 @@ def setUp(self): configuration=None, config_data={}, ) - - def test_list(self): - """Test LandingZoneListAPIView permissions""" - url = reverse( - 'landingzones:api_list', kwargs={'project': self.project.sodar_uuid} + self.url = reverse( + 'landingzones:api_retrieve', kwargs={'landingzone': zone.sodar_uuid} ) + + def test_get(self): + """Test ZoneRetrieveAPIView GET""" good_users = [ self.superuser, - self.user_owner_cat, # Inherited owner + self.user_owner_cat, + self.user_delegate_cat, self.user_owner, self.user_delegate, + ] + bad_users = [ + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, self.user_contributor, + self.user_guest, + self.user_no_roles, ] - bad_users = [self.user_guest, self.user_no_roles] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 403) - self.assert_response(url, [self.anonymous], 401) + self.assert_response(self.url, good_users, 200) + self.assert_response(self.url, bad_users, 403) + self.assert_response(self.url, self.anonymous, 401) + self.project.set_public() + self.assert_response(self.url, self.user_no_roles, 403) + self.assert_response(self.url, self.anonymous, 401) - def test_list_archive(self): - """Test LandingZoneListAPIView with archived project""" + @override_settings(PROJECTROLES_ALLOW_ANONYMOUS=True) + def test_get_anon(self): + """Test GET with anonymous access""" + self.project.set_public() + self.assert_response(self.url, self.anonymous, 401) + + def test_get_archive(self): + """Test GET with archived project""" self.project.set_archive() - url = reverse( - 'landingzones:api_list', kwargs={'project': self.project.sodar_uuid} - ) good_users = [ self.superuser, self.user_owner_cat, + self.user_delegate_cat, self.user_owner, self.user_delegate, + ] + bad_users = [ + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, self.user_contributor, + self.user_guest, + self.user_no_roles, ] - bad_users = [self.user_guest, self.user_no_roles] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 403) - self.assert_response(url, [self.anonymous], 401) + self.assert_response(self.url, good_users, 200) + self.assert_response(self.url, bad_users, 403) + self.assert_response(self.url, self.anonymous, 401) + self.project.set_public() + self.assert_response(self.url, self.user_no_roles, 403) + self.assert_response(self.url, self.anonymous, 401) + - def test_retrieve(self): - """Test LandingZoneRetrieveAPIView permissions""" - url = reverse( - 'landingzones:api_retrieve', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, +# NOTE: For ZoneCreateAPIView tests, see test_permissions_api_taskflow + + +class TestZoneUpdateAPIView(ZoneAPIPermissionTestBase): + """Tests for ZoneUpdateAPIView permissions""" + + def setUp(self): + super().setUp() + self.investigation = self.import_isa_from_file(SHEET_PATH, self.project) + self.study = self.investigation.studies.first() + self.assay = self.study.assays.first() + zone = self.make_landing_zone( + title=ZONE_TITLE, + project=self.project, + user=self.user_owner, + assay=self.assay, + description=ZONE_DESC, + configuration=None, + config_data={}, ) + self.url = reverse( + 'landingzones:api_update', kwargs={'landingzone': zone.sodar_uuid} + ) + self.post_data = {'description': 'Test description updated'} + + def test_patch(self): + """Test ZoneUpdateAPIView PATCH""" good_users = [ self.superuser, self.user_owner_cat, + self.user_delegate_cat, self.user_owner, self.user_delegate, ] bad_users = [ + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, self.user_contributor, self.user_guest, self.user_no_roles, ] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 403) - self.assert_response(url, [self.anonymous], 401) + self.assert_response_api( + self.url, + good_users, + 200, + method='PATCH', + data=self.post_data, + knox=True, + ) + self.assert_response_api( + self.url, + bad_users, + 403, + method='PATCH', + data=self.post_data, + knox=True, + ) + self.assert_response_api( + self.url, + self.anonymous, + 401, + method='PATCH', + data=self.post_data, + ) + self.project.set_public() + self.assert_response_api( + self.url, + self.user_no_roles, + 403, + method='PATCH', + data=self.post_data, + knox=True, + ) + self.assert_response_api( + self.url, + self.anonymous, + 401, + method='PATCH', + data=self.post_data, + ) - def test_retrieve_archive(self): - """Test LandingZoneRetrieveAPIView with archived project""" - self.project.set_archive() - url = reverse( - 'landingzones:api_retrieve', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, + def test_patch_anon(self): + """Test PATCH with anonymous access""" + self.project.set_public() + self.assert_response_api( + self.url, + self.anonymous, + 401, + method='PATCH', + data=self.post_data, ) - good_users = [ - self.superuser, + + def test_patch_archive(self): + """Test PATCH with archived project""" + self.project.set_archive() + good_users = [self.superuser] + bad_users = [ self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, self.user_owner, self.user_delegate, - ] - bad_users = [ self.user_contributor, self.user_guest, self.user_no_roles, ] - self.assert_response(url, good_users, 200) - self.assert_response(url, bad_users, 403) - self.assert_response(url, [self.anonymous], 401) + self.assert_response_api( + self.url, + good_users, + 200, + method='PATCH', + data=self.post_data, + knox=True, + ) + self.assert_response_api( + self.url, + bad_users, + 403, + method='PATCH', + data=self.post_data, + knox=True, + ) + self.assert_response_api( + self.url, + self.anonymous, + 401, + method='PATCH', + data=self.post_data, + ) + self.project.set_public() + self.assert_response_api( + self.url, + self.user_no_roles, + 403, + method='PATCH', + data=self.post_data, + knox=True, + ) + self.assert_response_api( + self.url, + self.anonymous, + 401, + method='PATCH', + data=self.post_data, + ) + + +# NOTE: For other API view tests, see test_permissions_api_taskflow diff --git a/landingzones/tests/test_permissions_api_taskflow.py b/landingzones/tests/test_permissions_api_taskflow.py index 897c79c3..d73ae03c 100644 --- a/landingzones/tests/test_permissions_api_taskflow.py +++ b/landingzones/tests/test_permissions_api_taskflow.py @@ -15,6 +15,11 @@ # Taskflowbackend dependency from taskflowbackend.tests.base import TaskflowAPIPermissionTestBase +from landingzones.constants import ( + ZONE_STATUS_ACTIVE, + ZONE_STATUS_VALIDATING, + ZONE_STATUS_PREPARING, +) from landingzones.tests.test_models import LandingZoneMixin from landingzones.tests.test_views_taskflow import ( LandingZoneTaskflowMixin, @@ -27,7 +32,7 @@ SHEET_PATH = SHEET_DIR + 'i_small.zip' -class TestZoneAPIPermissionTaskflowBase( +class ZoneAPIPermissionTaskflowTestBase( SampleSheetIOMixin, SampleSheetTaskflowMixin, LandingZoneMixin, @@ -45,7 +50,7 @@ def setUp(self): self.make_irods_colls(self.investigation) -class TestZoneCreateAPIViewPermissions(TestZoneAPIPermissionTaskflowBase): +class TestZoneCreateAPIViewPermissions(ZoneAPIPermissionTaskflowTestBase): """Tests for ZoneCreateAPIView permissions with Taskflow""" def _get_post_data(self): @@ -65,15 +70,23 @@ def setUp(self): kwargs={'project': self.project.sodar_uuid}, ) - def test_create(self): - """Test ZoneCreateAPIView permissions""" + def test_post(self): + """Test ZoneCreateAPIView POST""" good_users = [ self.superuser, + self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, self.user_owner, self.user_delegate, self.user_contributor, ] - bad_users = [self.user_guest, self.user_no_roles] + bad_users = [ + self.user_guest_cat, + self.user_finder_cat, + self.user_guest, + self.user_no_roles, + ] self.assert_response_api( self.url, good_users, 201, method='POST', data=self._get_post_data() ) @@ -95,7 +108,6 @@ def test_create(self): data=self._get_post_data(), knox=True, ) - # Test public project self.project.set_public() self.assert_response_api( self.url, @@ -106,8 +118,8 @@ def test_create(self): ) @override_settings(PROJECTROLES_ALLOW_ANONYMOUS=True) - def test_create_anon(self): - """Test ZoneCreateAPIView with anonymous guest access""" + def test_post_anon(self): + """Test POST with anonymous guest access""" self.project.set_public() self.assert_response_api( self.url, @@ -117,11 +129,16 @@ def test_create_anon(self): data=self._get_post_data(), ) - def test_create_archive(self): - """Test ZoneCreateAPIView with archived project""" + def test_post_archive(self): + """Test POST with archived project""" self.project.set_archive() good_users = [self.superuser] bad_users = [ + self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, self.user_owner, self.user_delegate, self.user_contributor, @@ -149,7 +166,6 @@ def test_create_archive(self): data=self._get_post_data(), knox=True, ) - # Test public project self.project.set_public() self.assert_response_api( self.url, @@ -160,10 +176,15 @@ def test_create_archive(self): ) @override_settings(LANDINGZONES_DISABLE_FOR_USERS=True) - def test_create_disable(self): - """Test ZoneCreateAPIView with disabled non-superuser access""" + def test_post_disable(self): + """Test POST with disabled non-superuser access""" good_users = [self.superuser] bad_users = [ + self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, self.user_owner, self.user_delegate, self.user_contributor, @@ -191,7 +212,6 @@ def test_create_disable(self): data=self._get_post_data(), knox=True, ) - # Test public project self.project.set_public() self.assert_response_api( self.url, @@ -202,11 +222,11 @@ def test_create_disable(self): ) -class TestZoneSubmitDeleteAPIViewPermissions(TestZoneAPIPermissionTaskflowBase): +class TestZoneSubmitDeleteAPIViewPermissions(ZoneAPIPermissionTaskflowTestBase): """Tests for ZoneSubmitDeleteAPIView permissions with Taskflow""" def _cleanup(self): - self.landing_zone.status = 'ACTIVE' + self.landing_zone.status = ZONE_STATUS_ACTIVE self.landing_zone.save() def setUp(self): @@ -226,15 +246,23 @@ def setUp(self): kwargs={'landingzone': self.landing_zone.sodar_uuid}, ) - def test_submit_delete(self): - """Test ZoneSubmitDeleteAPIView permissions""" + def test_post(self): + """Test ZoneSubmitDeleteAPIView POST""" good_users = [ self.superuser, + self.user_owner_cat, + self.user_delegate_cat, self.user_owner, self.user_delegate, self.user_contributor, ] - bad_users = [self.user_guest, self.user_no_roles] + bad_users = [ + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, + self.user_guest, + self.user_no_roles, + ] self.assert_response_api( self.url, good_users, @@ -262,7 +290,6 @@ def test_submit_delete(self): cleanup_method=self._cleanup, knox=True, ) - # Test public project self.project.set_public() self.assert_response_api( self.url, @@ -272,22 +299,30 @@ def test_submit_delete(self): ) @override_settings(PROJECTROLES_ALLOW_ANONYMOUS=True) - def test_submit_delete_anon(self): - """Test ZoneSubmitDeleteAPIView with anonymous guest access""" + def test_post_anon(self): + """Test POST with anonymous guest access""" self.project.set_public() self.assert_response_api(self.url, self.anonymous, 401, method='POST') - def test_submit_delete_archive(self): - """Test ZoneSubmitDeleteAPIView with archived project""" + def test_post_archive(self): + """Test POST with archived project""" # NOTE: Should still be allowed self.project.set_archive() good_users = [ self.superuser, + self.user_owner_cat, + self.user_delegate_cat, self.user_owner, self.user_delegate, self.user_contributor, ] - bad_users = [self.user_guest, self.user_no_roles] + bad_users = [ + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, + self.user_guest, + self.user_no_roles, + ] self.assert_response_api( self.url, good_users, @@ -315,7 +350,6 @@ def test_submit_delete_archive(self): cleanup_method=self._cleanup, knox=True, ) - # Test public project self.project.set_public() self.assert_response_api( self.url, @@ -325,12 +359,15 @@ def test_submit_delete_archive(self): ) @override_settings(LANDINGZONES_DISABLE_FOR_USERS=True) - def test_submit_delete_disable(self): - """Test ZoneSubmitDeleteAPIView with disabled non-superuser access""" - good_users = [ - self.superuser, - ] + def test_post_disable(self): + """Test POST with disabled non-superuser access""" + good_users = [self.superuser] bad_users = [ + self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, self.user_owner, self.user_delegate, self.user_contributor, @@ -364,7 +401,6 @@ def test_submit_delete_disable(self): cleanup_method=self._cleanup, knox=True, ) - # Test public project self.project.set_public() self.assert_response_api( self.url, @@ -374,7 +410,7 @@ def test_submit_delete_disable(self): ) -class TestZoneSubmitMoveAPIViewPermissions(TestZoneAPIPermissionTaskflowBase): +class TestZoneSubmitMoveAPIViewPermissions(ZoneAPIPermissionTaskflowTestBase): """Tests for ZoneSubmitMoveAPIView permissions with Taskflow""" # NOTE: Using validate_only in tests, perms are identical to move @@ -384,14 +420,15 @@ def _cleanup(self): retry_count = 0 # Wait for async activity to finish while ( - self.landing_zone.status in ['PREPARING', 'VALIDATING'] + self.landing_zone.status + in [ZONE_STATUS_PREPARING, ZONE_STATUS_VALIDATING] and retry_count < 5 ): time.sleep(1) self.landing_zone.refrsh_from_db() retry_count += 1 - if self.landing_zone.status != 'ACTIVE': - self.landing_zone.status = 'ACTIVE' + if self.landing_zone.status != ZONE_STATUS_ACTIVE: + self.landing_zone.status = ZONE_STATUS_ACTIVE self.landing_zone.save() def setUp(self): @@ -411,15 +448,23 @@ def setUp(self): kwargs={'landingzone': self.landing_zone.sodar_uuid}, ) - def test_submit_validate(self): - """Test ZoneSubmitMoveAPIView permissions""" + def test_post(self): + """Test ZoneSubmitMoveAPIView POST""" good_users = [ self.superuser, + self.user_owner_cat, + self.user_delegate_cat, self.user_owner, self.user_delegate, self.user_contributor, ] - bad_users = [self.user_guest, self.user_no_roles] + bad_users = [ + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, + self.user_guest, + self.user_no_roles, + ] self.assert_response_api( self.url, good_users, @@ -447,7 +492,6 @@ def test_submit_validate(self): cleanup_method=self._cleanup, knox=True, ) - # Test public project self.project.set_public() self.assert_response_api( self.url, @@ -457,17 +501,22 @@ def test_submit_validate(self): ) @override_settings(PROJECTROLES_ALLOW_ANONYMOUS=True) - def test_submit_validate_anon(self): - """Test ZoneSubmitMoveAPIView with anonymous guest access""" + def test_post_anon(self): + """Test POST with anonymous guest access""" self.project.set_public() self.assert_response_api(self.url, self.anonymous, 401, method='POST') - def test_submit_validate_archive(self): - """Test ZoneSubmitMoveAPIView with archived project""" + def test_post_archive(self): + """Test POST with archived project""" # NOTE: We don't allow move OR validate for archived projects self.project.set_archive() good_users = [self.superuser] bad_users = [ + self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, self.user_owner, self.user_delegate, self.user_contributor, @@ -501,7 +550,6 @@ def test_submit_validate_archive(self): cleanup_method=self._cleanup, knox=True, ) - # Test public project self.project.set_public() self.assert_response_api( self.url, @@ -511,10 +559,15 @@ def test_submit_validate_archive(self): ) @override_settings(LANDINGZONES_DISABLE_FOR_USERS=True) - def test_submit_validate_disable(self): - """Test ZoneSubmitMoveAPIView with disabled non-superuser access""" + def test_post_disable(self): + """Test POST with disabled non-superuser access""" good_users = [self.superuser] bad_users = [ + self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, self.user_owner, self.user_delegate, self.user_contributor, @@ -548,7 +601,6 @@ def test_submit_validate_disable(self): cleanup_method=self._cleanup, knox=True, ) - # Test public project self.project.set_public() self.assert_response_api( self.url, diff --git a/landingzones/tests/test_permissions_taskflow.py b/landingzones/tests/test_permissions_taskflow.py new file mode 100644 index 00000000..dda5d26b --- /dev/null +++ b/landingzones/tests/test_permissions_taskflow.py @@ -0,0 +1,150 @@ +"""Tests for UI view permissions with taskflow""" + +from django.test import override_settings +from django.urls import reverse + +# Projectroles dependency +from projectroles.models import SODAR_CONSTANTS + +# Samplesheets dependency +from samplesheets.tests.test_io import SampleSheetIOMixin, SHEET_DIR + +from taskflowbackend.tests.base import TaskflowPermissionTestBase +from landingzones.tests.test_models import ( + LandingZoneMixin, + ZONE_TITLE, + ZONE_DESC, +) +from landingzones.tests.test_views_taskflow import LandingZoneTaskflowMixin + + +# SODAR constants +PROJECT_ROLE_OWNER = SODAR_CONSTANTS['PROJECT_ROLE_OWNER'] +PROJECT_ROLE_DELEGATE = SODAR_CONSTANTS['PROJECT_ROLE_DELEGATE'] +PROJECT_ROLE_CONTRIBUTOR = SODAR_CONSTANTS['PROJECT_ROLE_CONTRIBUTOR'] +PROJECT_ROLE_GUEST = SODAR_CONSTANTS['PROJECT_ROLE_GUEST'] +PROJECT_TYPE_CATEGORY = SODAR_CONSTANTS['PROJECT_TYPE_CATEGORY'] +PROJECT_TYPE_PROJECT = SODAR_CONSTANTS['PROJECT_TYPE_PROJECT'] + +# Local constants +SHEET_PATH = SHEET_DIR + 'i_small.zip' +TEST_OBJ_NAME = 'test1.txt' + + +class ZonePermissionTaskflowTestBase( + LandingZoneMixin, + LandingZoneTaskflowMixin, + SampleSheetIOMixin, + TaskflowPermissionTestBase, +): + """Base view for landingzones permissions tests with taskflow""" + + def setUp(self): + super().setUp() + # Import investigation + self.investigation = self.import_isa_from_file(SHEET_PATH, self.project) + self.study = self.investigation.studies.first() + self.assay = self.study.assays.first() + # Create LandingZone + self.landing_zone = self.make_landing_zone( + title=ZONE_TITLE, + project=self.project, + user=self.user_contributor, # NOTE: owner = user_contributor + assay=self.assay, + description=ZONE_DESC, + configuration=None, + config_data={}, + ) + # Create zone and file in taskflow + self.make_zone_taskflow(self.landing_zone) + self.zone_coll = self.irods.collections.get( + self.irods_backend.get_path(self.landing_zone) + ) + self.irods_obj = self.make_irods_object(self.zone_coll, TEST_OBJ_NAME) + self.make_irods_md5_object(self.irods_obj) + + +class TestZoneMoveView(ZonePermissionTaskflowTestBase): + """Tests for ZoneMoveView permissions with taskflow""" + + def setUp(self): + super().setUp() + self.url = reverse( + 'landingzones:move', + kwargs={'landingzone': self.landing_zone.sodar_uuid}, + ) + + def test_get(self): + """Test ZoneMoveView GET""" + good_users = [ + self.superuser, + self.user_owner_cat, + self.user_delegate_cat, + self.user_owner, + self.user_delegate, + self.user_contributor, + ] + bad_users = [ + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, + self.user_guest, + self.user_no_roles, + self.anonymous, + ] + self.assert_response(self.url, good_users, 200) + self.assert_response(self.url, bad_users, 302) + self.project.set_public() + self.assert_response( + self.url, [self.user_no_roles, self.anonymous], 302 + ) + + @override_settings(PROJECTROLES_ALLOW_ANONYMOUS=True) + def test_get_anon(self): + """Test GET with anonymous access""" + self.project.set_public() + self.assert_response(self.url, self.anonymous, 302) + + def test_get_archive(self): + """Test GET with archived project""" + self.project.set_archive() + good_users = [self.superuser] + bad_users = [ + self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, + self.user_owner, + self.user_delegate, + self.user_contributor, + self.user_guest, + self.user_no_roles, + self.anonymous, + ] + self.assert_response(self.url, good_users, 200) + self.assert_response(self.url, bad_users, 302) + self.project.set_public() + self.assert_response( + self.url, [self.user_no_roles, self.anonymous], 302 + ) + + @override_settings(LANDINGZONES_DISABLE_FOR_USERS=True) + def test_get_disable(self): + """Test GET with disabled non-superuser access""" + good_users = [self.superuser] + bad_users = [ + self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, + self.user_guest_cat, + self.user_finder_cat, + self.user_owner, + self.user_delegate, + self.user_contributor, + self.user_guest, + self.user_no_roles, + self.anonymous, + ] + self.assert_response(self.url, good_users, 200) + self.assert_response(self.url, bad_users, 302) diff --git a/landingzones/tests/test_plugins_taskflow.py b/landingzones/tests/test_plugins_taskflow.py index ab17d98e..a0d9c0f4 100644 --- a/landingzones/tests/test_plugins_taskflow.py +++ b/landingzones/tests/test_plugins_taskflow.py @@ -6,18 +6,16 @@ from projectroles.plugins import ProjectAppPluginPoint # Samplesheets dependency -from samplesheets.tests.test_io import ( - SampleSheetIOMixin, - SHEET_DIR, -) +from samplesheets.tests.test_io import SampleSheetIOMixin, SHEET_DIR from samplesheets.tests.test_views_taskflow import ( SampleSheetPublicAccessMixin, SampleSheetTaskflowMixin, ) # Taskflowbackend dependency -from taskflowbackend.tests.base import TaskflowbackendTestBase +from taskflowbackend.tests.base import TaskflowViewTestBase +from landingzones.constants import ZONE_STATUS_ACTIVE, ZONE_STATUS_MOVED from landingzones.tests.test_models import LandingZoneMixin from landingzones.tests.test_views_taskflow import LandingZoneTaskflowMixin @@ -44,7 +42,7 @@ class TestPerformProjectSync( SampleSheetIOMixin, SampleSheetPublicAccessMixin, SampleSheetTaskflowMixin, - TaskflowbackendTestBase, + TaskflowViewTestBase, ): """Tests for perform_project_modify()""" @@ -76,7 +74,7 @@ def test_create_zone(self): project=self.project, user=self.user, assay=self.assay, - status='ACTIVE', + status=ZONE_STATUS_ACTIVE, ) zone_path = self.irods_backend.get_path(zone) self.assertEqual(self.irods.collections.exists(zone_path), False) @@ -91,7 +89,7 @@ def test_create_zone_moved(self): project=self.project, user=self.user, assay=self.assay, - status='MOVED', + status=ZONE_STATUS_MOVED, ) zone_path = self.irods_backend.get_path(zone) self.assertEqual(self.irods.collections.exists(zone_path), False) diff --git a/landingzones/tests/test_tasks_celery.py b/landingzones/tests/test_tasks_celery_taskflow.py similarity index 82% rename from landingzones/tests/test_tasks_celery.py rename to landingzones/tests/test_tasks_celery_taskflow.py index 3769d340..ac809944 100644 --- a/landingzones/tests/test_tasks_celery.py +++ b/landingzones/tests/test_tasks_celery_taskflow.py @@ -1,4 +1,4 @@ -"""Celery task tests for the landingzones app""" +"""Celery task tests for the landingzones app with taskflow enabled""" from django.conf import settings from django.contrib import auth @@ -12,8 +12,9 @@ from samplesheets.tests.test_views_taskflow import SampleSheetTaskflowMixin # Taskflowbackend dependency -from taskflowbackend.tests.base import TaskflowbackendTestBase +from taskflowbackend.tests.base import TaskflowViewTestBase +from landingzones.constants import ZONE_STATUS_ACTIVE, ZONE_STATUS_MOVED from landingzones.tasks_celery import TriggerZoneMoveTask from landingzones.tests.test_models import LandingZoneMixin from landingzones.tests.test_views_taskflow import LandingZoneTaskflowMixin @@ -45,13 +46,12 @@ class TestTriggerZoneMoveTask( LandingZoneMixin, LandingZoneTaskflowMixin, SampleSheetTaskflowMixin, - TaskflowbackendTestBase, + TaskflowViewTestBase, ): """Tests for the automated zone move triggering task""" def setUp(self): super().setUp() - # Init project # Make project with owner in Taskflow and Django self.project, self.owner_as = self.make_project_taskflow( title='TestProject', @@ -60,14 +60,12 @@ def setUp(self): owner=self.user, description='description', ) - # Import investigation self.investigation = self.import_isa_from_file(SHEET_PATH, self.project) self.study = self.investigation.studies.first() self.assay = self.study.assays.first() # Create iRODS collections self.make_irods_colls(self.investigation) - # Create zone self.landing_zone = self.make_landing_zone( title=ZONE_TITLE, @@ -87,28 +85,27 @@ def setUp(self): self.assay_coll = self.irods.collections.get( self.irods_backend.get_path(self.assay) ) - self.req_factory = RequestFactory() self.task = TriggerZoneMoveTask() def test_trigger(self): """Test triggering automated zone validation and moving""" - self.assertEqual(self.landing_zone.status, 'ACTIVE') - + self.assertEqual(self.landing_zone.status, ZONE_STATUS_ACTIVE) # Create file and fake request - self.make_object(self.zone_coll, settings.LANDINGZONES_TRIGGER_FILE) + self.make_irods_object( + self.zone_coll, settings.LANDINGZONES_TRIGGER_FILE + ) request = self.req_factory.post('/') request.user = self.user - # Run task and assert results self.task.run(request) - self.assert_zone_status(self.landing_zone, 'MOVED') + self.assert_zone_status(self.landing_zone, ZONE_STATUS_MOVED) self.landing_zone.refresh_from_db() - self.assertEqual(self.landing_zone.status, 'MOVED') + self.assertEqual(self.landing_zone.status, ZONE_STATUS_MOVED) def test_trigger_no_file(self): """Test triggering without an uploaded file""" - self.assertEqual(self.landing_zone.status, 'ACTIVE') + self.assertEqual(self.landing_zone.status, ZONE_STATUS_ACTIVE) # Run task and assert results self.task.run() - self.assert_zone_status(self.landing_zone, 'ACTIVE') + self.assert_zone_status(self.landing_zone, ZONE_STATUS_ACTIVE) diff --git a/landingzones/tests/test_ui.py b/landingzones/tests/test_ui.py index 06ead7d1..63b02fe3 100644 --- a/landingzones/tests/test_ui.py +++ b/landingzones/tests/test_ui.py @@ -1,5 +1,7 @@ """UI tests for the landingzones app""" +import time + from django.test import override_settings from django.urls import reverse @@ -8,18 +10,19 @@ # Projectroles dependency from projectroles.app_settings import AppSettingAPI -from projectroles.models import RoleAssignment from projectroles.tests.test_ui import TestUIBase # Samplesheets dependency from samplesheets.tests.test_io import SampleSheetIOMixin, SHEET_DIR from samplesheets.tests.test_sheet_config import SheetConfigMixin +from landingzones.constants import ZONE_STATUS_CREATING from landingzones.tests.test_models import LandingZoneMixin app_settings = AppSettingAPI() + # Local constants SHEET_PATH = SHEET_DIR + 'i_small.zip' @@ -54,18 +57,20 @@ def _assert_btn_enabled(self, element, expected=True): else: self.assertIn('disabled', element.get_attribute('class')) + def _wait_for_status_update(self): + """Wait for JQuery landing zone status updates to finish""" + for i in range(0, 20): + if self.selenium.execute_script('return window.zoneStatusUpdated'): + return + time.sleep(0.5) + def setUp(self): super().setUp() - # NOTE: Temp inherited owner override, see bihealth/sodar-core#1103 - self.user_owner_cat = self.make_user('user_owner_cat') - self.owner_as_cat = RoleAssignment.objects.get( - project=self.category, role=self.role_owner - ) - self.owner_as_cat.user = self.user_owner_cat - self.owner_as_cat.save() # Users with access to landing zones self.zone_users = [ self.user_owner_cat, + self.user_delegate_cat, + self.user_contributor_cat, self.user_owner, self.user_delegate, self.user_contributor, @@ -289,6 +294,7 @@ def test_zone_buttons(self): 'contrib_zone', self.project, self.user_contributor, self.assay ) self.login_and_redirect(self.user_contributor, self.url) + self._wait_for_status_update() zone = self.selenium.find_elements( By.CLASS_NAME, 'sodar-lz-zone-tr-existing' )[0] @@ -319,6 +325,7 @@ def test_zone_buttons_archive(self): ) self.project.set_archive() self.login_and_redirect(self.user_contributor, self.url) + self._wait_for_status_update() zone = self.selenium.find_elements( By.CLASS_NAME, 'sodar-lz-zone-tr-existing' )[0] @@ -338,3 +345,78 @@ def test_zone_buttons_archive(self): zone.find_element(By.CLASS_NAME, 'sodar-lz-zone-btn-delete'), True, ) + + def test_zone_locked_superuser(self): + """Test ProjectZoneView zone rendering for locked zone as superuser""" + self._setup_investigation() + self.investigation.irods_status = True + self.investigation.save() + zone = self.make_landing_zone( + 'contrib_zone', self.project, self.user_contributor, self.assay + ) + self.assertEqual(zone.status, ZONE_STATUS_CREATING) + self.login_and_redirect(self.superuser, self.url) + self._wait_for_status_update() + zone_elem = self.selenium.find_elements( + By.CLASS_NAME, 'sodar-lz-zone-tr-existing' + )[0] + self.assertNotIn( + 'disabled', + zone_elem.find_element( + By.CLASS_NAME, 'sodar-list-dropdown' + ).get_attribute('class'), + ) + self.assertNotIn( + 'text-muted', + zone_elem.find_element( + By.CLASS_NAME, 'sodar-lz-zone-title' + ).get_attribute('class'), + ) + self.assertNotIn( + 'text-muted', + zone_elem.find_element( + By.CLASS_NAME, 'sodar-lz-zone-status-info' + ).get_attribute('class'), + ) + class_names = [ + 'sodar-lz-zone-btn-validate', + 'sodar-lz-zone-btn-move', + 'sodar-lz-zone-btn-copy', + 'sodar-lz-zone-btn-delete', + ] + for c in class_names: + self._assert_btn_enabled( + zone_elem.find_element(By.CLASS_NAME, c), True + ) + + def test_zone_locked_contributor(self): + """Test ProjectZoneView zone rendering for locked zone as contributor""" + self._setup_investigation() + self.investigation.irods_status = True + self.investigation.save() + self.make_landing_zone( + 'contrib_zone', self.project, self.user_contributor, self.assay + ) + self.login_and_redirect(self.user_contributor, self.url) + self._wait_for_status_update() + zone_elem = self.selenium.find_elements( + By.CLASS_NAME, 'sodar-lz-zone-tr-existing' + )[0] + self.assertIn( + 'disabled', + zone_elem.find_element( + By.CLASS_NAME, 'sodar-list-dropdown' + ).get_attribute('class'), + ) + self.assertIn( + 'text-muted', + zone_elem.find_element( + By.CLASS_NAME, 'sodar-lz-zone-title' + ).get_attribute('class'), + ) + self.assertIn( + 'text-muted', + zone_elem.find_element( + By.CLASS_NAME, 'sodar-lz-zone-status-info' + ).get_attribute('class'), + ) diff --git a/landingzones/tests/test_views.py b/landingzones/tests/test_views.py index 95d9d156..c0e59224 100644 --- a/landingzones/tests/test_views.py +++ b/landingzones/tests/test_views.py @@ -1,25 +1,32 @@ """Tests for UI views in the landingzones app""" -from django.conf import settings +from django.forms import HiddenInput from django.test import override_settings from django.urls import reverse from test_plus.test import TestCase # Projectroles dependency -from projectroles.models import Role, SODAR_CONSTANTS -from projectroles.tests.test_models import ProjectMixin, RoleAssignmentMixin +from projectroles.models import SODAR_CONSTANTS +from projectroles.tests.test_models import ( + ProjectMixin, + RoleMixin, + RoleAssignmentMixin, +) # Samplesheets dependency from samplesheets.tests.test_io import SampleSheetIOMixin, SHEET_DIR +from landingzones.constants import ZONE_STATUS_ACTIVE, ZONE_STATUS_DELETED +from landingzones.models import LandingZone from landingzones.tests.test_models import ( LandingZoneMixin, ZONE_TITLE, ZONE_DESC, ) -# Global constants + +# SODAR constants PROJECT_ROLE_OWNER = SODAR_CONSTANTS['PROJECT_ROLE_OWNER'] PROJECT_ROLE_DELEGATE = SODAR_CONSTANTS['PROJECT_ROLE_DELEGATE'] PROJECT_ROLE_CONTRIBUTOR = SODAR_CONSTANTS['PROJECT_ROLE_CONTRIBUTOR'] @@ -29,36 +36,22 @@ # Local constants SHEET_PATH = SHEET_DIR + 'i_small.zip' -ZONE_STATUS = 'VALIDATING' ZONE_STATUS_INFO = 'Testing' -IRODS_BACKEND_ENABLED = ( - True if 'omics_irods' in settings.ENABLED_BACKEND_PLUGINS else False -) -IRODS_BACKEND_SKIP_MSG = 'iRODS backend not enabled in settings' - - class TestViewsBase( - LandingZoneMixin, - SampleSheetIOMixin, ProjectMixin, + RoleMixin, RoleAssignmentMixin, + SampleSheetIOMixin, + LandingZoneMixin, TestCase, ): """Base class for view testing""" def setUp(self): # Init roles - self.role_owner = Role.objects.get_or_create(name=PROJECT_ROLE_OWNER)[0] - self.role_delegate = Role.objects.get_or_create( - name=PROJECT_ROLE_DELEGATE - )[0] - self.role_contributor = Role.objects.get_or_create( - name=PROJECT_ROLE_CONTRIBUTOR - )[0] - self.role_guest = Role.objects.get_or_create(name=PROJECT_ROLE_GUEST)[0] - + self.init_roles() # Init superuser self.user = self.make_user('superuser') self.user.is_superuser = True @@ -71,11 +64,10 @@ def setUp(self): self.project, self.user, self.role_owner ) # Init contributor user and assignment - self.user_contrib = self.make_user('user_contrib') - self.contrib_as = self.make_assignment( - self.project, self.user_contrib, self.role_contributor + self.user_contributor = self.make_user('user_contributor') + self.contributor_as = self.make_assignment( + self.project, self.user_contributor, self.role_contributor ) - # Import investigation self.investigation = self.import_isa_from_file(SHEET_PATH, self.project) self.study = self.investigation.studies.first() @@ -87,7 +79,7 @@ def setUp(self): user=self.user, assay=self.assay, description=ZONE_DESC, - status='ACTIVE', + status=ZONE_STATUS_ACTIVE, ) @@ -112,7 +104,7 @@ def test_render_owner(self): def test_render_contrib(self): """Test rendering of project zones view as project contributor""" - with self.login(self.user_contrib): + with self.login(self.user_contributor): response = self.client.get( reverse( 'landingzones:list', @@ -129,7 +121,7 @@ def test_render_contrib(self): @override_settings(LANDINGZONES_DISABLE_FOR_USERS=True) def test_render_disable(self): """Test rendering with user access disabled""" - with self.login(self.user_contrib): + with self.login(self.user_contributor): response = self.client.get( reverse( 'landingzones:list', @@ -173,23 +165,102 @@ def test_render(self): self.assertIsNotNone(form.fields['configuration']) -class TestLandingZoneMoveView(TestViewsBase): - """Tests for the landing zone validation and moving view""" +class TestLandingZoneUpdateView(TestViewsBase): + """Tests for the landing zone update view""" def test_render(self): - """Test rendering of the landing zone validation and moving view""" + """Test rendering of the landing zone update view""" with self.login(self.user): response = self.client.get( reverse( - 'landingzones:move', + 'landingzones:update', kwargs={'landingzone': self.landing_zone.sodar_uuid}, ) ) self.assertEqual(response.status_code, 200) + # Assert form + form = response.context['form'] + self.assertIsNotNone(form) + self.assertIsNotNone(form.fields['assay']) + self.assertIsNotNone(form.fields['description']) + # Make sure to also assert the expected fields + # are hidden with the HiddenInput widget. + self.assertIsInstance(form.fields['title_suffix'].widget, HiddenInput) + self.assertIsInstance(form.fields['configuration'].widget, HiddenInput) + self.assertIsInstance(form.fields['create_colls'].widget, HiddenInput) + self.assertIsInstance(form.fields['restrict_colls'].widget, HiddenInput) + self.assertIsInstance(form.fields['assay'].widget, HiddenInput) + + def test_render_invalid_status(self): + """Test rendering with an invalid zone status""" + self.landing_zone.status = ZONE_STATUS_DELETED + self.landing_zone.save() + + with self.login(self.user): + response = self.client.get( + reverse( + 'landingzones:update', + kwargs={'landingzone': self.landing_zone.sodar_uuid}, + ) + ) + self.assertEqual(response.status_code, 302) + + def test_post(self): + """Test POST request to the landing zone update view""" + with self.login(self.user): + response = self.client.post( + reverse( + 'landingzones:update', + kwargs={'landingzone': self.landing_zone.sodar_uuid}, + ), + data={ + 'assay': self.assay.sodar_uuid, + 'description': 'test description updated', + 'user_message': 'test user message', + }, + ) + self.assertRedirects( + response, + reverse( + 'landingzones:list', + kwargs={'project': self.project.sodar_uuid}, + ), + ) + landing_zone = LandingZone.objects.get( + sodar_uuid=self.landing_zone.sodar_uuid + ) + self.assertEqual(landing_zone.assay, self.assay) + self.assertEqual(landing_zone.description, 'test description updated') + self.assertEqual(landing_zone.user_message, 'test user message') + + def test_post_invalid_data(self): + """Test POST request with invalid data""" + with self.login(self.user): + response = self.client.post( + reverse( + 'landingzones:update', + kwargs={'landingzone': self.landing_zone.sodar_uuid}, + ), + data={ + 'assay': self.assay.sodar_uuid, + 'description': 'test description updated', + 'title_suffix': 'test suffix', + }, + ) + self.assertEqual(response.status_code, 302) + landing_zone = LandingZone.objects.get( + sodar_uuid=self.landing_zone.sodar_uuid + ) + self.assertEqual(landing_zone.assay, self.assay) + self.assertEqual(landing_zone.description, 'description') + + +class TestLandingZoneMoveView(TestViewsBase): + """Tests for the landing zone validation and moving view""" def test_render_invalid_status(self): """Test rendering with an invalid zone status""" - self.landing_zone.status = 'DELETED' + self.landing_zone.status = ZONE_STATUS_DELETED self.landing_zone.save() with self.login(self.user): @@ -218,7 +289,7 @@ def test_render(self): def test_render_invalid_status(self): """Test rendering with an invalid zone status""" - self.landing_zone.status = 'DELETED' + self.landing_zone.status = ZONE_STATUS_DELETED self.landing_zone.save() with self.login(self.user): diff --git a/landingzones/tests/test_views_ajax.py b/landingzones/tests/test_views_ajax.py index 2f63d755..396cacbc 100644 --- a/landingzones/tests/test_views_ajax.py +++ b/landingzones/tests/test_views_ajax.py @@ -8,19 +8,34 @@ class TestLandingZoneStatusGetAjaxView(TestViewsBase): """Tests for the landing zone status getting Ajax view""" - def test_get(self): - """Test GET request for getting a landing zone status""" + def test_post(self): + """Test POST request for getting a landing zone status""" with self.login(self.user): - response = self.client.get( + response = self.client.post( reverse( 'landingzones:ajax_status', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ) + kwargs={'project': self.project.sodar_uuid}, + ), + data={'zone_uuids[]': [str(self.landing_zone.sodar_uuid)]}, ) self.assertEqual(response.status_code, 200) - expected = { - 'status': self.landing_zone.status, - 'status_info': self.landing_zone.status_info, + str(self.landing_zone.sodar_uuid): { + 'status': self.landing_zone.status, + 'status_info': self.landing_zone.status_info, + } } self.assertEquals(response.data, expected) + + def test_post_no_zone(self): + """Test POST request for getting a landing zone status with no zones""" + with self.login(self.user): + response = self.client.post( + reverse( + 'landingzones:ajax_status', + kwargs={'project': self.project.sodar_uuid}, + ), + data={'zone_uuids[]': []}, + ) + self.assertEqual(response.status_code, 200) + self.assertEquals(response.data, {}) diff --git a/landingzones/tests/test_views_api.py b/landingzones/tests/test_views_api.py index 36491842..cf068201 100644 --- a/landingzones/tests/test_views_api.py +++ b/landingzones/tests/test_views_api.py @@ -12,14 +12,17 @@ # Samplesheets dependency from samplesheets.tests.test_io import SampleSheetIOMixin, SHEET_DIR -from landingzones.tests.test_models import LandingZoneMixin -from landingzones.tests.test_views_taskflow import ( - ZONE_TITLE, - ZONE_DESC, +from landingzones.constants import ( + ZONE_STATUS_ACTIVE, + ZONE_STATUS_MOVED, + ZONE_STATUS_MOVING, + ZONE_STATUS_VALIDATING, ) +from landingzones.tests.test_models import LandingZoneMixin +from landingzones.tests.test_views_taskflow import ZONE_TITLE, ZONE_DESC -# Global constants +# SODAR constants PROJECT_ROLE_OWNER = SODAR_CONSTANTS['PROJECT_ROLE_OWNER'] PROJECT_ROLE_DELEGATE = SODAR_CONSTANTS['PROJECT_ROLE_DELEGATE'] PROJECT_ROLE_CONTRIBUTOR = SODAR_CONSTANTS['PROJECT_ROLE_CONTRIBUTOR'] @@ -29,7 +32,7 @@ # Local constants SHEET_PATH = SHEET_DIR + 'i_small.zip' -ZONE_STATUS = 'VALIDATING' +ZONE_STATUS = ZONE_STATUS_VALIDATING ZONE_STATUS_INFO = 'Testing' INVALID_UUID = '11111111-1111-1111-1111-111111111111' @@ -41,18 +44,15 @@ class TestLandingZoneAPIViewsBase( def setUp(self): super().setUp() - # Init contributor user and assignment - self.user_contrib = self.make_user('user_contrib') - self.contrib_as = self.make_assignment( - self.project, self.user_contrib, self.role_contributor + self.user_contributor = self.make_user('user_contributor') + self.contributor_as = self.make_assignment( + self.project, self.user_contributor, self.role_contributor ) - # Import investigation self.investigation = self.import_isa_from_file(SHEET_PATH, self.project) self.study = self.investigation.studies.first() self.assay = self.study.assays.first() - # Create LandingZone self.landing_zone = self.make_landing_zone( title=ZONE_TITLE, @@ -60,7 +60,7 @@ def setUp(self): user=self.user, assay=self.assay, description=ZONE_DESC, - status='ACTIVE', + status=ZONE_STATUS_ACTIVE, ) @@ -104,20 +104,20 @@ def test_get_no_own_zones(self): 'landingzones:api_list', kwargs={'project': self.project.sodar_uuid} ) response = self.request_knox( - url, token=self.get_token(self.user_contrib) + url, token=self.get_token(self.user_contributor) ) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.data), 0) def test_get_finished_default(self): - """Test get() with a finished zone and no finished parameter""" + """Test get() with finished zone and no finished parameter""" self.make_landing_zone( title=ZONE_TITLE + '_moved', project=self.project, user=self.user, assay=self.assay, description=ZONE_DESC, - status='MOVED', + status=ZONE_STATUS_MOVED, ) url = reverse( 'landingzones:api_list', kwargs={'project': self.project.sodar_uuid} @@ -131,14 +131,14 @@ def test_get_finished_default(self): ) def test_get_finished_false(self): - """Test get() with a finished zone and finished=0""" + """Test get() with finished zone and finished=0""" self.make_landing_zone( title=ZONE_TITLE + '_moved', project=self.project, user=self.user, assay=self.assay, description=ZONE_DESC, - status='MOVED', + status=ZONE_STATUS_MOVED, ) url = ( reverse( @@ -156,14 +156,14 @@ def test_get_finished_false(self): ) def test_get_finished_true(self): - """Test get() with a finished zone and finished=1""" + """Test get() with finished zone and finished=1""" self.make_landing_zone( title=ZONE_TITLE + '_moved', project=self.project, user=self.user, assay=self.assay, description=ZONE_DESC, - status='MOVED', + status=ZONE_STATUS_MOVED, ) url = ( reverse( @@ -212,7 +212,7 @@ def test_get(self): def test_get_locked(self): """Test get() with locked landing zone status""" - self.landing_zone.status = 'MOVING' + self.landing_zone.status = ZONE_STATUS_MOVING self.landing_zone.save() url = reverse( 'landingzones:api_retrieve', @@ -221,3 +221,65 @@ def test_get_locked(self): response = self.request_knox(url) self.assertEqual(response.status_code, 200) self.assertEqual(json.loads(response.content)['status_locked'], True) + + +class TestLandingZoneUpdateAPIView(TestLandingZoneAPIViewsBase): + """Tests for LandingZoneUpdateAPIView""" + + def test_patch(self): + """Test LandingZoneUpdateAPIView patch() as zone owner""" + url = reverse( + 'landingzones:api_update', + kwargs={'landingzone': self.landing_zone.sodar_uuid}, + ) + data = { + 'description': 'New description', + 'user_message': 'New user message', + } + response = self.request_knox(url, method='PATCH', data=data) + self.assertEqual(response.status_code, 200) + self.assertEqual( + json.loads(response.content)['description'], 'New description' + ) + self.assertEqual( + json.loads(response.content)['user_message'], 'New user message' + ) + + def test_patch_title(self): + """Test updating title with patch() (should fail)""" + url = reverse( + 'landingzones:api_update', + kwargs={'landingzone': self.landing_zone.sodar_uuid}, + ) + data = {'title': 'New title'} + response = self.request_knox(url, method='PATCH', data=data) + self.assertEqual(response.status_code, 400) + + def test_put(self): + """Test LandingZoneUpdateAPIView put() as zone owner""" + url = reverse( + 'landingzones:api_update', + kwargs={'landingzone': self.landing_zone.sodar_uuid}, + ) + data = { + 'description': 'New description', + 'user_message': 'New user message', + } + response = self.request_knox(url, method='PUT', data=data) + self.assertEqual(response.status_code, 200) + self.assertEqual( + json.loads(response.content)['description'], 'New description' + ) + self.assertEqual( + json.loads(response.content)['user_message'], 'New user message' + ) + + def test_put_title(self): + """Test updating title with put() (should fail)""" + url = reverse( + 'landingzones:api_update', + kwargs={'landingzone': self.landing_zone.sodar_uuid}, + ) + data = {'title': 'New title'} + response = self.request_knox(url, method='PUT', data=data) + self.assertEqual(response.status_code, 400) diff --git a/landingzones/tests/test_views_api_taskflow.py b/landingzones/tests/test_views_api_taskflow.py index bee0b0e0..e4a6f43a 100644 --- a/landingzones/tests/test_views_api_taskflow.py +++ b/landingzones/tests/test_views_api_taskflow.py @@ -25,7 +25,15 @@ IRODS_ACCESS_OWN, ) -from landingzones.models import LandingZone, DEFAULT_STATUS_INFO +from landingzones.constants import ( + DEFAULT_STATUS_INFO, + ZONE_STATUS_ACTIVE, + ZONE_STATUS_CREATING, + ZONE_STATUS_DELETED, + ZONE_STATUS_MOVED, + ZONE_STATUS_FAILED, +) +from landingzones.models import LandingZone from landingzones.tests.test_models import LandingZoneMixin from landingzones.tests.test_views_taskflow import ( LandingZoneTaskflowMixin, @@ -39,7 +47,7 @@ ) -# Global constants +# SODAR constants PROJECT_ROLE_OWNER = SODAR_CONSTANTS['PROJECT_ROLE_OWNER'] PROJECT_ROLE_DELEGATE = SODAR_CONSTANTS['PROJECT_ROLE_DELEGATE'] PROJECT_ROLE_CONTRIBUTOR = SODAR_CONSTANTS['PROJECT_ROLE_CONTRIBUTOR'] @@ -49,11 +57,10 @@ # Local constants SHEET_PATH = SHEET_DIR + 'i_small.zip' -ZONE_STATUS = 'VALIDATING' ZONE_STATUS_INFO = 'Testing' -class TestLandingZoneAPITaskflowBase( +class ZoneAPIViewTaskflowTestBase( SampleSheetIOMixin, SampleSheetTaskflowMixin, LandingZoneMixin, @@ -68,8 +75,6 @@ def setUp(self): self.irods_backend = get_backend_api('omics_irods') self.assertIsNotNone(self.irods_backend) self.irods = self.irods_backend.get_session_obj() - - # Init project # Make project with owner in Taskflow and Django self.project, self.owner_as = self.make_project_taskflow( title='TestProject', @@ -78,7 +83,6 @@ def setUp(self): owner=self.user, description='description', ) - # Import investigation self.investigation = self.import_isa_from_file(SHEET_PATH, self.project) self.study = self.investigation.studies.first() @@ -88,21 +92,20 @@ def setUp(self): # Set up helpers self.group_name = self.irods_backend.get_user_group_name(self.project) - def tearDown(self): - self.irods.cleanup() - super().tearDown() - -class TestLandingZoneCreateAPIView(TestLandingZoneAPITaskflowBase): - """Tests for LandingZoneCreateAPIView""" +class TestZoneCreateAPIView(ZoneAPIViewTaskflowTestBase): + """Tests for ZoneCreateAPIView""" - def test_post(self): - """Test LandingZoneCreateAPIView post()""" - self.assertEqual(LandingZone.objects.count(), 0) - url = reverse( + def setUp(self): + super().setUp() + self.url = reverse( 'landingzones:api_create', kwargs={'project': self.project.sodar_uuid}, ) + + def test_post(self): + """Test ZoneCreateAPIView POST""" + self.assertEqual(LandingZone.objects.count(), 0) # NOTE: No optional create_colls and restrict_colls args, default=False request_data = { 'title': 'new zone', @@ -112,16 +115,15 @@ def test_post(self): 'configuration': None, 'config_data': {}, } - response = self.request_knox(url, method='POST', data=request_data) + response = self.request_knox(self.url, method='POST', data=request_data) # Assert status after creation self.assertEqual(response.status_code, 201) self.assertEqual(LandingZone.objects.count(), 1) # Assert status after taskflow has finished zone = LandingZone.objects.first() - self.assert_zone_status(zone, 'ACTIVE') + self.assert_zone_status(zone, ZONE_STATUS_ACTIVE) - # Check result # NOTE: date_modified will be changend async, can't test response_data = json.loads(response.content) expected = { @@ -129,8 +131,8 @@ def test_post(self): 'project': str(self.project.sodar_uuid), 'user': self.get_serialized_user(self.user), 'assay': str(self.assay.sodar_uuid), - 'status': 'CREATING', - 'status_info': DEFAULT_STATUS_INFO['CREATING'], + 'status': ZONE_STATUS_CREATING, + 'status_info': DEFAULT_STATUS_INFO[ZONE_STATUS_CREATING], 'status_locked': False, 'date_modified': response_data['date_modified'], 'description': zone.description, @@ -155,13 +157,8 @@ def test_post(self): self.assert_irods_access(self.group_name, zone_coll, None) def test_post_colls(self): - """Test LandingZoneCreateAPIView post() with default collections""" + """Test POST with default collections""" self.assertEqual(LandingZone.objects.count(), 0) - - url = reverse( - 'landingzones:api_create', - kwargs={'project': self.project.sodar_uuid}, - ) request_data = { 'title': 'new zone', 'assay': str(self.assay.sodar_uuid), @@ -170,12 +167,12 @@ def test_post_colls(self): 'create_colls': True, 'config_data': {}, } - response = self.request_knox(url, method='POST', data=request_data) + response = self.request_knox(self.url, method='POST', data=request_data) self.assertEqual(response.status_code, 201) self.assertEqual(LandingZone.objects.count(), 1) zone = LandingZone.objects.first() - self.assert_zone_status(zone, 'ACTIVE') + self.assert_zone_status(zone, ZONE_STATUS_ACTIVE) self.assert_irods_coll(zone) for c in ZONE_BASE_COLLS: self.assert_irods_coll(zone, c, True) @@ -191,7 +188,7 @@ def test_post_colls(self): ) def test_post_colls_plugin(self): - """Test LandingZoneCreateAPIView post() with plugin collections""" + """Test POST with plugin collections""" self.assertEqual(LandingZone.objects.count(), 0) # Mock assay configuration self.assay.measurement_type = {'name': 'genome sequencing'} @@ -206,10 +203,6 @@ def test_post_colls_plugin(self): self.user, ) - url = reverse( - 'landingzones:api_create', - kwargs={'project': self.project.sodar_uuid}, - ) request_data = { 'title': 'new zone', 'assay': str(self.assay.sodar_uuid), @@ -218,12 +211,12 @@ def test_post_colls_plugin(self): 'create_colls': True, 'config_data': {}, } - response = self.request_knox(url, method='POST', data=request_data) + response = self.request_knox(self.url, method='POST', data=request_data) self.assertEqual(response.status_code, 201) self.assertEqual(LandingZone.objects.count(), 1) zone = LandingZone.objects.first() - self.assert_zone_status(zone, 'ACTIVE') + self.assert_zone_status(zone, ZONE_STATUS_ACTIVE) self.assert_irods_coll(zone) for c in ZONE_ALL_COLLS: self.assert_irods_coll(zone, c, True) @@ -237,7 +230,7 @@ def test_post_colls_plugin(self): ) def test_post_colls_plugin_restrict(self): - """Test LandingZoneCreateAPIView post() with restricted collections""" + """Test POST with restricted collections""" self.assertEqual(LandingZone.objects.count(), 0) # Mock assay configuration self.assay.measurement_type = {'name': 'genome sequencing'} @@ -252,10 +245,6 @@ def test_post_colls_plugin_restrict(self): self.user, ) - url = reverse( - 'landingzones:api_create', - kwargs={'project': self.project.sodar_uuid}, - ) request_data = { 'title': 'new zone', 'assay': str(self.assay.sodar_uuid), @@ -265,12 +254,12 @@ def test_post_colls_plugin_restrict(self): 'restrict_colls': True, 'config_data': {}, } - response = self.request_knox(url, method='POST', data=request_data) + response = self.request_knox(self.url, method='POST', data=request_data) self.assertEqual(response.status_code, 201) self.assertEqual(LandingZone.objects.count(), 1) zone = LandingZone.objects.first() - self.assert_zone_status(zone, 'ACTIVE') + self.assert_zone_status(zone, ZONE_STATUS_ACTIVE) self.assert_irods_coll(zone) for c in ZONE_ALL_COLLS: self.assert_irods_coll(zone, c, True) @@ -287,13 +276,9 @@ def test_post_colls_plugin_restrict(self): # TODO: Test without sodarcache (see issue #1157) def test_post_no_investigation(self): - """Test LandingZoneCreateAPIView post() with no investigation""" + """Test POST with no investigation""" self.investigation.delete() self.assertEqual(LandingZone.objects.count(), 0) - url = reverse( - 'landingzones:api_create', - kwargs={'project': self.project.sodar_uuid}, - ) request_data = { 'title': 'new zone', 'assay': str(self.assay.sodar_uuid), @@ -301,19 +286,15 @@ def test_post_no_investigation(self): 'configuration': None, 'config_data': {}, } - response = self.request_knox(url, method='POST', data=request_data) + response = self.request_knox(self.url, method='POST', data=request_data) self.assertEqual(response.status_code, 400) self.assertEqual(LandingZone.objects.count(), 0) def test_post_no_irods_collections(self): - """Test LandingZoneCreateAPIView post() with no iRODS collections""" + """Test POST with no iRODS collections""" self.investigation.irods_status = False self.investigation.save() self.assertEqual(LandingZone.objects.count(), 0) - url = reverse( - 'landingzones:api_create', - kwargs={'project': self.project.sodar_uuid}, - ) request_data = { 'title': 'new zone', 'assay': str(self.assay.sodar_uuid), @@ -321,13 +302,13 @@ def test_post_no_irods_collections(self): 'configuration': None, 'config_data': {}, } - response = self.request_knox(url, method='POST', data=request_data) + response = self.request_knox(self.url, method='POST', data=request_data) self.assertEqual(response.status_code, 400) self.assertEqual(LandingZone.objects.count(), 0) -class TestLandingZoneSubmitDeleteAPIView(TestLandingZoneAPITaskflowBase): - """Tests for LandingZoneSubmitDeleteAPIView""" +class TestZoneSubmitDeleteAPIView(ZoneAPIViewTaskflowTestBase): + """Tests for ZoneSubmitDeleteAPIView""" def setUp(self): super().setUp() @@ -341,40 +322,35 @@ def setUp(self): configuration=None, config_data={}, ) - - def test_post(self): - """Test LandingZoneSubmitDeleteAPIView post()""" - self.make_zone_taskflow(self.landing_zone) - url = reverse( + self.url = reverse( 'landingzones:api_submit_delete', kwargs={'landingzone': self.landing_zone.sodar_uuid}, ) - response = self.request_knox(url, method='POST') + + def test_post(self): + """Test ZoneSubmitDeleteAPIView POST""" + self.make_zone_taskflow(self.landing_zone) + response = self.request_knox(self.url, method='POST') self.assertEqual(response.status_code, 200) self.assertEqual( response.data['sodar_uuid'], str(self.landing_zone.sodar_uuid) ) self.assertEqual(LandingZone.objects.count(), 1) zone = LandingZone.objects.first() - self.assert_zone_status(zone, 'DELETED') + self.assert_zone_status(zone, ZONE_STATUS_DELETED) def test_post_invalid_status(self): - """Test post() with invalid zone status (should fail)""" + """Test POST with invalid zone status (should fail)""" self.make_zone_taskflow(self.landing_zone) - self.landing_zone.status = 'MOVED' + self.landing_zone.status = ZONE_STATUS_MOVED self.landing_zone.save() - - url = reverse( - 'landingzones:api_submit_delete', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ) - response = self.request_knox(url, method='POST') + response = self.request_knox(self.url, method='POST') self.assertEqual(response.status_code, 400) self.assertEqual(LandingZone.objects.count(), 1) - self.assertEqual(LandingZone.objects.first().status, 'MOVED') + self.assertEqual(LandingZone.objects.first().status, ZONE_STATUS_MOVED) def test_post_invalid_uuid(self): - """Test post() with invalid zone UUID (should fail)""" + """Test POST with invalid zone UUID (should fail)""" self.make_zone_taskflow(self.landing_zone) url = reverse( 'landingzones:api_submit_delete', @@ -385,49 +361,41 @@ def test_post_invalid_uuid(self): self.assertEqual(LandingZone.objects.count(), 1) def test_post_restrict(self): - """Test post() on restricted collections""" + """Test POST on restricted collections""" self.make_zone_taskflow( zone=self.landing_zone, colls=[MISC_FILES_COLL, RESULTS_COLL], restrict_colls=True, ) - url = reverse( - 'landingzones:api_submit_delete', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ) - response = self.request_knox(url, method='POST') + response = self.request_knox(self.url, method='POST') self.assertEqual(response.status_code, 200) self.assertEqual( response.data['sodar_uuid'], str(self.landing_zone.sodar_uuid) ) self.assertEqual(LandingZone.objects.count(), 1) zone = LandingZone.objects.first() - self.assert_zone_status(zone, 'DELETED') + self.assert_zone_status(zone, ZONE_STATUS_DELETED) def test_post_no_coll(self): - """Test post() with no zone root collection in iRODS""" + """Test POST with no zone root collection in iRODS""" self.make_zone_taskflow(self.landing_zone) zone_path = self.irods_backend.get_path(self.landing_zone) self.assertTrue(self.irods.collections.exists(zone_path)) # Remove collection self.irods.collections.remove(zone_path) self.assertFalse(self.irods.collections.exists(zone_path)) - url = reverse( - 'landingzones:api_submit_delete', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ) - response = self.request_knox(url, method='POST') + response = self.request_knox(self.url, method='POST') self.assertEqual(response.status_code, 200) self.assertEqual( response.data['sodar_uuid'], str(self.landing_zone.sodar_uuid) ) self.assertEqual(LandingZone.objects.count(), 1) zone = LandingZone.objects.first() - self.assert_zone_status(zone, 'DELETED') + self.assert_zone_status(zone, ZONE_STATUS_DELETED) -class TestLandingZoneSubmitMoveAPIView(TestLandingZoneAPITaskflowBase): - """Tests for TestLandingZoneSubmitMoveAPIView""" +class TestZoneSubmitMoveAPIView(ZoneAPIViewTaskflowTestBase): + """Tests for ZoneSubmitMoveAPIView""" def setUp(self): super().setUp() @@ -449,17 +417,21 @@ def setUp(self): self.assay_coll = self.irods.collections.get( self.irods_backend.get_path(self.assay) ) - - def test_post_validate(self): - """Test post() for validation""" - self.landing_zone.status = 'FAILED' # Update to check status change - self.landing_zone.save() - - url = reverse( + self.url = reverse( 'landingzones:api_submit_validate', kwargs={'landingzone': self.landing_zone.sodar_uuid}, ) - response = self.request_knox(url, method='POST') + self.url_move = reverse( + 'landingzones:api_submit_move', + kwargs={'landingzone': self.landing_zone.sodar_uuid}, + ) + + def test_post_validate(self): + """Test POST for validation""" + # Update to check status change + self.landing_zone.status = ZONE_STATUS_FAILED + self.landing_zone.save() + response = self.request_knox(self.url, method='POST') self.assertEqual(response.status_code, 200) self.assertEqual( @@ -467,89 +439,68 @@ def test_post_validate(self): ) self.assertEqual(LandingZone.objects.count(), 1) zone = LandingZone.objects.first() - self.assert_zone_status(zone, 'ACTIVE') + self.assert_zone_status(zone, ZONE_STATUS_ACTIVE) self.assertEqual( LandingZone.objects.first().status_info, 'Successfully validated 0 files', ) def test_post_validate_invalid_status(self): - """Test post() for validation with invalid zone status (should fail)""" - self.landing_zone.status = 'MOVED' + """Test POST for validation with invalid zone status (should fail)""" + self.landing_zone.status = ZONE_STATUS_MOVED self.landing_zone.save() - - url = reverse( - 'landingzones:api_submit_validate', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ) - response = self.request_knox(url, method='POST') - + response = self.request_knox(self.url, method='POST') self.assertEqual(response.status_code, 400) self.assertEqual(LandingZone.objects.count(), 1) - self.assertEqual(LandingZone.objects.first().status, 'MOVED') + self.assertEqual(LandingZone.objects.first().status, ZONE_STATUS_MOVED) def test_post_move(self): - """Test post() for moving""" - irods_obj = self.make_object(self.zone_coll, TEST_OBJ_NAME) - self.make_md5_object(irods_obj) - self.assertEqual(self.landing_zone.status, 'ACTIVE') + """Test POST for moving""" + irods_obj = self.make_irods_object(self.zone_coll, TEST_OBJ_NAME) + self.make_irods_md5_object(irods_obj) + self.assertEqual(self.landing_zone.status, ZONE_STATUS_ACTIVE) self.assertEqual(len(self.zone_coll.data_objects), 2) self.assertEqual(len(self.assay_coll.data_objects), 0) - - url = reverse( - 'landingzones:api_submit_move', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ) - response = self.request_knox(url, method='POST') + response = self.request_knox(self.url_move, method='POST') self.assertEqual(response.status_code, 200) self.assertEqual( response.data['sodar_uuid'], str(self.landing_zone.sodar_uuid) ) self.assertEqual(LandingZone.objects.count(), 1) - zone = LandingZone.objects.first() - self.assert_zone_status(zone, 'MOVED') + self.assert_zone_status(self.landing_zone, ZONE_STATUS_MOVED) self.assertEqual(len(self.zone_coll.data_objects), 0) self.assertEqual(len(self.assay_coll.data_objects), 2) def test_post_move_invalid_status(self): - """Test post() for moving with invalid zone status (should fail)""" - self.landing_zone.status = 'DELETED' + """Test POST for moving with invalid zone status (should fail)""" + self.landing_zone.status = ZONE_STATUS_DELETED self.landing_zone.save() - - url = reverse( - 'landingzones:api_submit_move', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ) - response = self.request_knox(url, method='POST') - + response = self.request_knox(self.url_move, method='POST') self.assertEqual(response.status_code, 400) self.assertEqual(LandingZone.objects.count(), 1) - self.assertEqual(LandingZone.objects.first().status, 'DELETED') + self.assertEqual( + LandingZone.objects.first().status, ZONE_STATUS_DELETED + ) @override_settings(REDIS_URL=INVALID_REDIS_URL) def test_post_move_lock_failure(self): - """Test post() for moving with project lock failure""" - url = reverse( - 'landingzones:api_submit_move', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ) - response = self.request_knox(url, method='POST') - + """Test POST for moving with project lock failure""" + response = self.request_knox(self.url_move, method='POST') self.assertEqual(response.status_code, 500) self.assertEqual(LandingZone.objects.count(), 1) zone = LandingZone.objects.first() - self.assert_zone_status(zone, 'FAILED') + self.assert_zone_status(zone, ZONE_STATUS_FAILED) def test_post_move_restricted(self): - """Test post() for moving with restricted collections""" + """Test POST for moving with restricted collections""" zone = self.make_landing_zone( title=ZONE_TITLE + '_new', project=self.project, user=self.user, assay=self.assay, description=ZONE_DESC, - status='CREATING', + status=ZONE_STATUS_CREATING, ) self.make_zone_taskflow( zone=zone, @@ -560,9 +511,9 @@ def test_post_move_restricted(self): zone_results_coll = self.irods.collections.get( os.path.join(new_zone_path, RESULTS_COLL) ) - irods_obj = self.make_object(zone_results_coll, TEST_OBJ_NAME) - self.make_md5_object(irods_obj) - self.assertEqual(zone.status, 'ACTIVE') + irods_obj = self.make_irods_object(zone_results_coll, TEST_OBJ_NAME) + self.make_irods_md5_object(irods_obj) + self.assertEqual(zone.status, ZONE_STATUS_ACTIVE) self.assertEqual(len(zone_results_coll.data_objects), 2) self.assertEqual(len(self.assay_coll.data_objects), 0) @@ -574,7 +525,7 @@ def test_post_move_restricted(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.data['sodar_uuid'], str(zone.sodar_uuid)) - self.assert_zone_status(zone, 'MOVED') + self.assert_zone_status(zone, ZONE_STATUS_MOVED) self.assertEqual(len(zone_results_coll.data_objects), 0) assay_results_path = os.path.join(self.sample_path, RESULTS_COLL) assay_results_coll = self.irods.collections.get(assay_results_path) diff --git a/landingzones/tests/test_views_taskflow.py b/landingzones/tests/test_views_taskflow.py index f2a9a7e2..db7d9939 100644 --- a/landingzones/tests/test_views_taskflow.py +++ b/landingzones/tests/test_views_taskflow.py @@ -1,11 +1,9 @@ """View tests in the landingzones app with taskflow""" -import hashlib import os import time from irods.test.helpers import make_object -from irods.keywords import REG_CHKSUM_KW from django.contrib import auth from django.contrib.messages import get_messages @@ -27,7 +25,7 @@ # Taskflowbackend dependency from taskflowbackend.tests.base import ( - TaskflowbackendTestBase, + TaskflowViewTestBase, IRODS_ACCESS_READ, IRODS_ACCESS_OWN, ) @@ -35,6 +33,14 @@ # Timeline dependency from timeline.models import ProjectEvent +from landingzones.constants import ( + ZONE_STATUS_CREATING, + ZONE_STATUS_ACTIVE, + ZONE_STATUS_MOVED, + ZONE_STATUS_FAILED, + ZONE_STATUS_VALIDATING, + ZONE_STATUS_DELETED, +) from landingzones.models import LandingZone from landingzones.tests.test_models import LandingZoneMixin from landingzones.views import ZONE_MOVE_INVALID_STATUS @@ -84,7 +90,7 @@ def make_zone_taskflow( """ timeline = get_backend_api('timeline_backend') user = request.user if request else zone.user - self.assertEqual(zone.status, 'CREATING') + self.assertEqual(zone.status, ZONE_STATUS_CREATING) # Create timeline event to prevent taskflow failure tl_event = timeline.add_event( @@ -110,37 +116,10 @@ def make_zone_taskflow( } self.taskflow.submit(**values) - self.assert_zone_status(zone, 'ACTIVE') + self.assert_zone_status(zone, ZONE_STATUS_ACTIVE) return zone - def make_object(self, coll, obj_name, content=None, content_length=1024): - """ - Create and put a data object into iRODS. - - :param coll: iRODSCollection object - :param obj_name: String - :param content: Content data (optional) - :param content_length: Random content length (if content not specified) - :return: iRODSDataObject object - """ - if not content: - content = ''.join('x' for _ in range(content_length)) - obj_path = os.path.join(coll.path, obj_name) - return make_object(self.irods, obj_path, content, **{REG_CHKSUM_KW: ''}) - - def make_md5_object(self, obj): - """ - Create and put an MD5 checksum object for an existing object in iRODS. - - :param obj: iRODSDataObject - :return: iRODSDataObject - """ - md5_path = obj.path + '.md5' - with obj.open() as obj_fp: - md5_content = hashlib.md5(obj_fp.read()).hexdigest() - return make_object(self.irods, md5_path, md5_content) - - def assert_zone_status(self, zone, status='ACTIVE'): + def assert_zone_status(self, zone, status=ZONE_STATUS_ACTIVE): """ Assert status of landing zone(s) after waiting for async taskflow operation to finish. @@ -173,14 +152,14 @@ def assert_zone_count(self, count): ) -class TestLandingZoneCreateView( +class TestZoneCreateView( SampleSheetIOMixin, LandingZoneMixin, SampleSheetTaskflowMixin, LandingZoneTaskflowMixin, - TaskflowbackendTestBase, + TaskflowViewTestBase, ): - """Tests for the landingzones create view with Taskflow and iRODS""" + """Tests for ZoneCreateView with Taskflow and iRODS""" def setUp(self): super().setUp() @@ -198,6 +177,13 @@ def setUp(self): self.assay = self.study.assays.first() # Create iRODS collections self.make_irods_colls(self.investigation) + # Set up URLs + self.url = reverse( + 'landingzones:create', kwargs={'project': self.project.sodar_uuid} + ) + self.redirect_url = reverse( + 'landingzones:list', kwargs={'project': self.project.sodar_uuid} + ) def test_create_zone(self): """Test landingzones creation with taskflow""" @@ -216,24 +202,12 @@ def test_create_zone(self): 'restrict_colls': False, } with self.login(self.user): - response = self.client.post( - reverse( - 'landingzones:create', - kwargs={'project': self.project.sodar_uuid}, - ), - values, - ) - self.assertRedirects( - response, - reverse( - 'landingzones:list', - kwargs={'project': self.project.sodar_uuid}, - ), - ) + response = self.client.post(self.url, values) + self.assertRedirects(response, self.redirect_url) self.assert_zone_count(1) zone = LandingZone.objects.first() - self.assert_zone_status(zone, 'ACTIVE') + self.assert_zone_status(zone, ZONE_STATUS_ACTIVE) self.assert_irods_coll(zone) for c in ZONE_BASE_COLLS: self.assert_irods_coll(zone, c, False) @@ -259,7 +233,6 @@ def test_create_zone(self): def test_create_zone_colls(self): """Test landingzones creation with default collections""" self.assertEqual(LandingZone.objects.count(), 0) - values = { 'assay': str(self.assay.sodar_uuid), 'title_suffix': ZONE_SUFFIX, @@ -269,24 +242,12 @@ def test_create_zone_colls(self): 'configuration': '', } with self.login(self.user): - response = self.client.post( - reverse( - 'landingzones:create', - kwargs={'project': self.project.sodar_uuid}, - ), - values, - ) - self.assertRedirects( - response, - reverse( - 'landingzones:list', - kwargs={'project': self.project.sodar_uuid}, - ), - ) + response = self.client.post(self.url, values) + self.assertRedirects(response, self.redirect_url) self.assert_zone_count(1) zone = LandingZone.objects.first() - self.assert_zone_status(zone, 'ACTIVE') + self.assert_zone_status(zone, ZONE_STATUS_ACTIVE) tl_event = ProjectEvent.objects.filter(event_name='zone_create').first() self.assertEqual(tl_event.extra_data['create_colls'], True) self.assertEqual(tl_event.extra_data['restrict_colls'], False) @@ -329,24 +290,12 @@ def test_create_zone_colls_plugin(self): 'configuration': '', } with self.login(self.user): - response = self.client.post( - reverse( - 'landingzones:create', - kwargs={'project': self.project.sodar_uuid}, - ), - values, - ) - self.assertRedirects( - response, - reverse( - 'landingzones:list', - kwargs={'project': self.project.sodar_uuid}, - ), - ) + response = self.client.post(self.url, values) + self.assertRedirects(response, self.redirect_url) self.assert_zone_count(1) zone = LandingZone.objects.first() - self.assert_zone_status(zone, 'ACTIVE') + self.assert_zone_status(zone, ZONE_STATUS_ACTIVE) self.assert_irods_coll(zone) for c in ZONE_ALL_COLLS: self.assert_irods_coll(zone, c, True) @@ -386,24 +335,12 @@ def test_create_zone_colls_plugin_restrict(self): 'configuration': '', } with self.login(self.user): - response = self.client.post( - reverse( - 'landingzones:create', - kwargs={'project': self.project.sodar_uuid}, - ), - values, - ) - self.assertRedirects( - response, - reverse( - 'landingzones:list', - kwargs={'project': self.project.sodar_uuid}, - ), - ) + response = self.client.post(self.url, values) + self.assertRedirects(response, self.redirect_url) self.assert_zone_count(1) zone = LandingZone.objects.first() - self.assert_zone_status(zone, 'ACTIVE') + self.assert_zone_status(zone, ZONE_STATUS_ACTIVE) self.assert_irods_coll(zone) zone_path = self.irods_backend.get_path(zone) # Read access to root path @@ -416,14 +353,14 @@ def test_create_zone_colls_plugin_restrict(self): ) -class TestLandingZoneMoveView( +class TestZoneMoveView( SampleSheetIOMixin, LandingZoneMixin, LandingZoneTaskflowMixin, SampleSheetTaskflowMixin, - TaskflowbackendTestBase, + TaskflowViewTestBase, ): - """Tests for the landingzones move/validate view with Taskflow and iRODS""" + """Tests for ZoneMoveView view with Taskflow and iRODS""" def setUp(self): super().setUp() @@ -462,13 +399,35 @@ def setUp(self): ) self.sample_path = self.irods_backend.get_path(self.assay) self.group_name = self.irods_backend.get_user_group_name(self.project) + # Set up URLs + self.url_move = reverse( + 'landingzones:move', + kwargs={'landingzone': self.landing_zone.sodar_uuid}, + ) + self.url_validate = reverse( + 'landingzones:validate', + kwargs={'landingzone': self.landing_zone.sodar_uuid}, + ) + self.url_redirect = reverse( + 'landingzones:list', kwargs={'project': self.project.sodar_uuid} + ) + + def test_render(self): + """Test rendering of the landing zone validation and moving view""" + irods_obj = self.make_irods_object(self.zone_coll, TEST_OBJ_NAME) + self.make_irods_md5_object(irods_obj) + zone = LandingZone.objects.first() + self.assertEqual(zone.status, ZONE_STATUS_ACTIVE) + with self.login(self.user): + response = self.client.get(self.url_move) + self.assertEqual(response.status_code, 200) def test_move(self): - """Test validating and moving a landing zone with objects""" - irods_obj = self.make_object(self.zone_coll, TEST_OBJ_NAME) - self.make_md5_object(irods_obj) + """Test validating and moving landing zone with objects""" + irods_obj = self.make_irods_object(self.zone_coll, TEST_OBJ_NAME) + self.make_irods_md5_object(irods_obj) zone = LandingZone.objects.first() - self.assertEqual(zone.status, 'ACTIVE') + self.assertEqual(zone.status, ZONE_STATUS_ACTIVE) self.assertEqual(len(self.zone_coll.data_objects), 2) self.assertEqual(len(self.assay_coll.data_objects), 0) self.assertEqual(len(mail.outbox), 1) @@ -477,21 +436,10 @@ def test_move(self): ) with self.login(self.user): - response = self.client.post( - reverse( - 'landingzones:move', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ), - ) - self.assertRedirects( - response, - reverse( - 'landingzones:list', - kwargs={'project': self.project.sodar_uuid}, - ), - ) + response = self.client.post(self.url_move) + self.assertRedirects(response, self.url_redirect) - self.assert_zone_status(zone, 'MOVED') + self.assert_zone_status(zone, ZONE_STATUS_MOVED) self.assertEqual(len(self.zone_coll.data_objects), 0) self.assertEqual(len(self.assay_coll.data_objects), 2) self.assertEqual(len(mail.outbox), 3) # Mails to owner & category owner @@ -499,12 +447,32 @@ def test_move(self): AppAlert.objects.filter(alert_name='zone_move').count(), 1 ) + def test_move_no_files(self): + """Test validating and moving a landing zone without objects""" + zone = LandingZone.objects.first() + self.assertEqual(zone.status, ZONE_STATUS_ACTIVE) + self.assertEqual(len(self.zone_coll.data_objects), 0) + self.assertEqual(len(self.assay_coll.data_objects), 0) + self.assertEqual( + AppAlert.objects.filter(alert_name='zone_move').count(), 0 + ) + + with self.login(self.user): + response = self.client.post(self.url_move) + self.assertRedirects(response, self.url_redirect) + + self.assertEqual(len(self.zone_coll.data_objects), 0) + self.assertEqual(len(self.assay_coll.data_objects), 0) + self.assertEqual( + AppAlert.objects.filter(alert_name='zone_move').count(), 0 + ) + def test_move_invalid_md5(self): """Test validating and moving with invalid checksum (should fail)""" - irods_obj = self.make_object(self.zone_coll, TEST_OBJ_NAME) + irods_obj = self.make_irods_object(self.zone_coll, TEST_OBJ_NAME) make_object(self.irods, irods_obj.path + '.md5', INVALID_MD5) zone = LandingZone.objects.first() - self.assertEqual(zone.status, 'ACTIVE') + self.assertEqual(zone.status, ZONE_STATUS_ACTIVE) self.assertEqual(len(self.zone_coll.data_objects), 2) self.assertEqual(len(self.assay_coll.data_objects), 0) self.assertEqual(len(mail.outbox), 1) @@ -513,21 +481,10 @@ def test_move_invalid_md5(self): ) with self.login(self.user): - response = self.client.post( - reverse( - 'landingzones:move', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ), - ) - self.assertRedirects( - response, - reverse( - 'landingzones:list', - kwargs={'project': self.project.sodar_uuid}, - ), - ) + response = self.client.post(self.url_move) + self.assertRedirects(response, self.url_redirect) - self.assert_zone_status(zone, 'FAILED') + self.assert_zone_status(zone, ZONE_STATUS_FAILED) self.assertEqual(len(self.zone_coll.data_objects), 2) self.assertEqual(len(self.assay_coll.data_objects), 0) self.assertEqual(len(mail.outbox), 2) @@ -537,10 +494,10 @@ def test_move_invalid_md5(self): def test_move_no_md5(self): """Test validating and moving without checksum (should fail)""" - self.make_object(self.zone_coll, TEST_OBJ_NAME) + self.make_irods_object(self.zone_coll, TEST_OBJ_NAME) # No md5 zone = LandingZone.objects.first() - self.assertEqual(zone.status, 'ACTIVE') + self.assertEqual(zone.status, ZONE_STATUS_ACTIVE) self.assertEqual(len(self.zone_coll.data_objects), 1) self.assertEqual(len(self.assay_coll.data_objects), 0) self.assertEqual( @@ -548,21 +505,10 @@ def test_move_no_md5(self): ) with self.login(self.user): - response = self.client.post( - reverse( - 'landingzones:move', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ), - ) - self.assertRedirects( - response, - reverse( - 'landingzones:list', - kwargs={'project': self.project.sodar_uuid}, - ), - ) + response = self.client.post(self.url_move) + self.assertRedirects(response, self.url_redirect) - self.assert_zone_status(zone, 'FAILED') + self.assert_zone_status(zone, ZONE_STATUS_FAILED) self.assertEqual(len(self.zone_coll.data_objects), 1) self.assertEqual(len(self.assay_coll.data_objects), 0) self.assertEqual( @@ -570,11 +516,11 @@ def test_move_no_md5(self): ) def test_validate(self): - """Test validating a landing zone with objects without moving""" - irods_obj = self.make_object(self.zone_coll, TEST_OBJ_NAME) - self.make_md5_object(irods_obj) + """Test validating landing zone with objects without moving""" + irods_obj = self.make_irods_object(self.zone_coll, TEST_OBJ_NAME) + self.make_irods_md5_object(irods_obj) zone = LandingZone.objects.first() - self.assertEqual(zone.status, 'ACTIVE') + self.assertEqual(zone.status, ZONE_STATUS_ACTIVE) self.assertEqual(len(self.zone_coll.data_objects), 2) self.assertEqual(len(self.assay_coll.data_objects), 0) self.assertEqual(len(mail.outbox), 1) @@ -583,21 +529,10 @@ def test_validate(self): ) with self.login(self.user): - response = self.client.post( - reverse( - 'landingzones:validate', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ), - ) - self.assertRedirects( - response, - reverse( - 'landingzones:list', - kwargs={'project': self.project.sodar_uuid}, - ), - ) + response = self.client.post(self.url_validate) + self.assertRedirects(response, self.url_redirect) - self.assert_zone_status(zone, 'ACTIVE') + self.assert_zone_status(zone, ZONE_STATUS_ACTIVE) self.assertEqual(len(self.zone_coll.data_objects), 2) self.assertEqual(len(self.assay_coll.data_objects), 0) self.assertEqual(len(mail.outbox), 1) @@ -605,12 +540,33 @@ def test_validate(self): AppAlert.objects.filter(alert_name='zone_validate').count(), 1 ) + def test_validate_no_files(self): + """Test validation a landing zone without files""" + zone = LandingZone.objects.first() + self.assertEqual(zone.status, ZONE_STATUS_ACTIVE) + self.assertEqual(len(self.zone_coll.data_objects), 0) + self.assertEqual(len(self.assay_coll.data_objects), 0) + self.assertEqual( + AppAlert.objects.filter(alert_name='zone_validate').count(), 0 + ) + + with self.login(self.user): + response = self.client.post(self.url_validate) + self.assertRedirects(response, self.url_redirect) + + self.assert_zone_status(zone, ZONE_STATUS_ACTIVE) + self.assertEqual(len(self.zone_coll.data_objects), 0) + self.assertEqual(len(self.assay_coll.data_objects), 0) + self.assertEqual( + AppAlert.objects.filter(alert_name='zone_validate').count(), 1 + ) + def test_validate_invalid_md5(self): - """Test validating a landing zone without checksum (should fail)""" - irods_obj = self.make_object(self.zone_coll, TEST_OBJ_NAME) + """Test validating with invalid checksum file (should fail)""" + irods_obj = self.make_irods_object(self.zone_coll, TEST_OBJ_NAME) make_object(self.irods, irods_obj.path + '.md5', INVALID_MD5) zone = LandingZone.objects.first() - self.assertEqual(zone.status, 'ACTIVE') + self.assertEqual(zone.status, ZONE_STATUS_ACTIVE) self.assertEqual(len(self.zone_coll.data_objects), 2) self.assertEqual(len(self.assay_coll.data_objects), 0) self.assertEqual(len(mail.outbox), 1) @@ -619,14 +575,9 @@ def test_validate_invalid_md5(self): ) with self.login(self.user): - self.client.post( - reverse( - 'landingzones:validate', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ), - ) + self.client.post(self.url_validate) - self.assert_zone_status(zone, 'FAILED') + self.assert_zone_status(zone, ZONE_STATUS_FAILED) self.assertTrue('BatchValidateChecksumsTask' in zone.status_info) self.assertEqual(len(self.zone_coll.data_objects), 2) self.assertEqual(len(self.assay_coll.data_objects), 0) @@ -636,56 +587,46 @@ def test_validate_invalid_md5(self): ) def test_validate_no_md5(self): - """Test validating a landing zone without checksum (should fail)""" - self.make_object(self.zone_coll, TEST_OBJ_NAME) + """Test validating without checksum file (should fail)""" + self.make_irods_object(self.zone_coll, TEST_OBJ_NAME) # No md5 zone = LandingZone.objects.first() - self.assertEqual(zone.status, 'ACTIVE') + self.assertEqual(zone.status, ZONE_STATUS_ACTIVE) self.assertEqual(len(self.zone_coll.data_objects), 1) self.assertEqual(len(self.assay_coll.data_objects), 0) with self.login(self.user): - self.client.post( - reverse( - 'landingzones:validate', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ), - ) + self.client.post(self.url_validate) - self.assert_zone_status(zone, 'FAILED') + self.assert_zone_status(zone, ZONE_STATUS_FAILED) self.assertTrue('BatchCheckFilesTask' in zone.status_info) self.assertEqual(len(self.zone_coll.data_objects), 1) self.assertEqual(len(self.assay_coll.data_objects), 0) def test_validate_md5_only(self): """Test validating zone with no file for MD5 file (should fail)""" - irods_obj = self.make_object(self.zone_coll, TEST_OBJ_NAME) - self.md5_obj = self.make_md5_object(irods_obj) + irods_obj = self.make_irods_object(self.zone_coll, TEST_OBJ_NAME) + self.md5_obj = self.make_irods_md5_object(irods_obj) irods_obj.unlink(force=True) zone = LandingZone.objects.first() - self.assertEqual(zone.status, 'ACTIVE') + self.assertEqual(zone.status, ZONE_STATUS_ACTIVE) self.assertEqual(len(self.zone_coll.data_objects), 1) self.assertEqual(len(self.assay_coll.data_objects), 0) with self.login(self.user): - self.client.post( - reverse( - 'landingzones:validate', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ), - ) + self.client.post(self.url_validate) - self.assert_zone_status(zone, 'FAILED') + self.assert_zone_status(zone, ZONE_STATUS_FAILED) self.assertTrue('BatchCheckFilesTask' in zone.status_info) self.assertEqual(len(self.zone_coll.data_objects), 1) self.assertEqual(len(self.assay_coll.data_objects), 0) def test_move_invalid_status(self): """Test validating and moving with invalid zone status (should fail)""" - irods_obj = self.make_object(self.zone_coll, TEST_OBJ_NAME) - self.make_md5_object(irods_obj) + irods_obj = self.make_irods_object(self.zone_coll, TEST_OBJ_NAME) + self.make_irods_md5_object(irods_obj) zone = LandingZone.objects.first() - zone.status = 'VALIDATING' + zone.status = ZONE_STATUS_VALIDATING zone.save() self.assertEqual(len(self.zone_coll.data_objects), 2) self.assertEqual(len(self.assay_coll.data_objects), 0) @@ -698,25 +639,14 @@ def test_move_invalid_status(self): ) with self.login(self.user): - response = self.client.post( - reverse( - 'landingzones:move', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ), - ) - self.assertRedirects( - response, - reverse( - 'landingzones:list', - kwargs={'project': self.project.sodar_uuid}, - ), - ) + response = self.client.post(self.url_validate) + self.assertRedirects(response, self.url_redirect) self.assertEqual( str(list(get_messages(response.wsgi_request))[0]), ZONE_MOVE_INVALID_STATUS, ) - self.assert_zone_status(zone, 'VALIDATING') + self.assert_zone_status(zone, ZONE_STATUS_VALIDATING) self.assertEqual(len(self.zone_coll.data_objects), 2) self.assertEqual(len(self.assay_coll.data_objects), 0) self.assertEqual(len(mail.outbox), 1) @@ -729,10 +659,10 @@ def test_move_invalid_status(self): @override_settings(REDIS_URL=INVALID_REDIS_URL) def test_move_lock_failure(self): """Test validating and moving with project lock failure""" - irods_obj = self.make_object(self.zone_coll, TEST_OBJ_NAME) - self.make_md5_object(irods_obj) + irods_obj = self.make_irods_object(self.zone_coll, TEST_OBJ_NAME) + self.make_irods_md5_object(irods_obj) zone = LandingZone.objects.first() - self.assertEqual(zone.status, 'ACTIVE') + self.assertEqual(zone.status, ZONE_STATUS_ACTIVE) self.assertEqual(len(self.zone_coll.data_objects), 2) self.assertEqual(len(self.assay_coll.data_objects), 0) self.assertEqual(len(mail.outbox), 1) @@ -744,27 +674,16 @@ def test_move_lock_failure(self): ) with self.login(self.user): - response = self.client.post( - reverse( - 'landingzones:move', - kwargs={'landingzone': self.landing_zone.sodar_uuid}, - ), - ) - self.assertRedirects( - response, - reverse( - 'landingzones:list', - kwargs={'project': self.project.sodar_uuid}, - ), - ) + response = self.client.post(self.url_move) + self.assertRedirects(response, self.url_redirect) - self.assert_zone_status(zone, 'FAILED') + self.assert_zone_status(zone, ZONE_STATUS_FAILED) self.assertEqual(len(self.zone_coll.data_objects), 2) self.assertEqual(len(self.assay_coll.data_objects), 0) self.assertEqual(len(mail.outbox), 1) # TODO: Should this send email? tl_event = ProjectEvent.objects.filter(event_name='zone_move').first() self.assertIsInstance(tl_event, ProjectEvent) - self.assertEqual(tl_event.get_status().status_type, 'FAILED') + self.assertEqual(tl_event.get_status().status_type, ZONE_STATUS_FAILED) # TODO: Create app alerts for async failures (see #1499) self.assertEqual( AppAlert.objects.filter(alert_name='zone_move').count(), 0 @@ -779,7 +698,7 @@ def test_move_restrict(self): user=self.user, assay=self.assay, description=ZONE_DESC, - status='CREATING', + status=ZONE_STATUS_CREATING, ) self.make_zone_taskflow( zone=zone, @@ -790,9 +709,9 @@ def test_move_restrict(self): zone_results_coll = self.irods.collections.get( os.path.join(new_zone_path, RESULTS_COLL) ) - irods_obj = self.make_object(zone_results_coll, TEST_OBJ_NAME) - self.make_md5_object(irods_obj) - self.assertEqual(zone.status, 'ACTIVE') + irods_obj = self.make_irods_object(zone_results_coll, TEST_OBJ_NAME) + self.make_irods_md5_object(irods_obj) + self.assertEqual(zone.status, ZONE_STATUS_ACTIVE) self.assertEqual(len(zone_results_coll.data_objects), 2) self.assertEqual(len(self.assay_coll.data_objects), 0) self.assertEqual(len(mail.outbox), 1) @@ -800,22 +719,14 @@ def test_move_restrict(self): AppAlert.objects.filter(alert_name='zone_move').count(), 0 ) + url = reverse( + 'landingzones:move', kwargs={'landingzone': zone.sodar_uuid} + ) with self.login(self.user): - response = self.client.post( - reverse( - 'landingzones:move', - kwargs={'landingzone': zone.sodar_uuid}, - ), - ) - self.assertRedirects( - response, - reverse( - 'landingzones:list', - kwargs={'project': self.project.sodar_uuid}, - ), - ) + response = self.client.post(url) + self.assertRedirects(response, self.url_redirect) - self.assert_zone_status(zone, 'MOVED') + self.assert_zone_status(zone, ZONE_STATUS_MOVED) self.assertEqual(len(zone_results_coll.data_objects), 0) assay_results_path = os.path.join(self.sample_path, RESULTS_COLL) assay_results_coll = self.irods.collections.get(assay_results_path) @@ -837,18 +748,17 @@ def test_move_restrict(self): ) -class TestLandingZoneDeleteView( +class TestZoneDeleteView( SampleSheetIOMixin, LandingZoneMixin, LandingZoneTaskflowMixin, SampleSheetTaskflowMixin, - TaskflowbackendTestBase, + TaskflowViewTestBase, ): - """Tests for the landingzones delete view with Taskflow and iRODS""" + """Tests for ZoneDeleteView with Taskflow and iRODS""" def setUp(self): super().setUp() - # Make project with owner in Taskflow and Django self.project, self.owner_as = self.make_project_taskflow( title='TestProject', type=PROJECT_TYPE_PROJECT, @@ -856,16 +766,17 @@ def setUp(self): owner=self.user, description='description', ) - # Import investigation self.investigation = self.import_isa_from_file(SHEET_PATH, self.project) self.study = self.investigation.studies.first() self.assay = self.study.assays.first() - # Create iRODS collections self.make_irods_colls(self.investigation) + self.url_redirect = reverse( + 'landingzones:list', + kwargs={'project': self.project.sodar_uuid}, + ) def test_delete_zone(self): """Test landingzones deletion with taskflow""" - # Create zone zone = self.make_landing_zone( title=ZONE_TITLE, project=self.project, @@ -881,24 +792,16 @@ def test_delete_zone(self): AppAlert.objects.filter(alert_name='zone_delete').count(), 0 ) + url = reverse( + 'landingzones:delete', kwargs={'landingzone': zone.sodar_uuid} + ) with self.login(self.user): - response = self.client.post( - reverse( - 'landingzones:delete', - kwargs={'landingzone': zone.sodar_uuid}, - ), - ) - self.assertRedirects( - response, - reverse( - 'landingzones:list', - kwargs={'project': self.project.sodar_uuid}, - ), - ) + response = self.client.post(url) + self.assertRedirects(response, self.url_redirect) self.assert_zone_count(1) zone.refresh_from_db() - self.assert_zone_status(zone, 'DELETED') + self.assert_zone_status(zone, ZONE_STATUS_DELETED) self.assertEqual(len(mail.outbox), 1) self.assertEqual( AppAlert.objects.filter(alert_name='zone_delete').count(), 1 @@ -925,24 +828,16 @@ def test_delete_zone_restrict(self): AppAlert.objects.filter(alert_name='zone_delete').count(), 0 ) + url = reverse( + 'landingzones:delete', kwargs={'landingzone': zone.sodar_uuid} + ) with self.login(self.user): - response = self.client.post( - reverse( - 'landingzones:delete', - kwargs={'landingzone': zone.sodar_uuid}, - ), - ) - self.assertRedirects( - response, - reverse( - 'landingzones:list', - kwargs={'project': self.project.sodar_uuid}, - ), - ) + response = self.client.post(url) + self.assertRedirects(response, self.url_redirect) self.assert_zone_count(1) zone.refresh_from_db() - self.assert_zone_status(zone, 'DELETED') + self.assert_zone_status(zone, ZONE_STATUS_DELETED) self.assertEqual(len(mail.outbox), 1) self.assertEqual( AppAlert.objects.filter(alert_name='zone_delete').count(), 1 @@ -967,13 +862,11 @@ def test_delete_zone_no_coll(self): self.irods.collections.remove(zone_path) self.assertFalse(self.irods.collections.exists(zone_path)) + url = reverse( + 'landingzones:delete', kwargs={'landingzone': zone.sodar_uuid} + ) with self.login(self.user): - self.client.post( - reverse( - 'landingzones:delete', - kwargs={'landingzone': zone.sodar_uuid}, - ), - ) + self.client.post(url) self.assert_zone_count(1) zone.refresh_from_db() - self.assert_zone_status(zone, 'DELETED') + self.assert_zone_status(zone, ZONE_STATUS_DELETED) diff --git a/landingzones/urls.py b/landingzones/urls.py index af2d1d44..5f1638b3 100644 --- a/landingzones/urls.py +++ b/landingzones/urls.py @@ -18,6 +18,11 @@ view=views.ZoneCreateView.as_view(), name='create', ), + path( + route='update/', + view=views.ZoneUpdateView.as_view(), + name='update', + ), path( route='move/', view=views.ZoneMoveView.as_view(), @@ -52,6 +57,11 @@ view=views_api.ZoneCreateAPIView.as_view(), name='api_create', ), + path( + route='api/update/', + view=views_api.ZoneUpdateAPIView.as_view(), + name='api_update', + ), path( route='api/submit/delete/', view=views_api.ZoneSubmitDeleteAPIView.as_view(), @@ -72,7 +82,7 @@ # Ajax API views urls_ajax = [ path( - route='ajax/status/retrieve/', + route='ajax/status/retrieve/', view=views_ajax.ZoneStatusRetrieveAjaxView.as_view(), name='ajax_status', ) diff --git a/landingzones/views.py b/landingzones/views.py index d4cf9711..0421f19c 100644 --- a/landingzones/views.py +++ b/landingzones/views.py @@ -9,7 +9,7 @@ from django.core.exceptions import ImproperlyConfigured from django.shortcuts import redirect from django.urls import reverse -from django.views.generic import TemplateView, CreateView +from django.views.generic import TemplateView, CreateView, UpdateView # Projectroles dependency from projectroles.plugins import get_backend_api @@ -28,13 +28,15 @@ TRACK_HUBS_COLL, ) -from landingzones.forms import LandingZoneForm -from landingzones.models import ( - LandingZone, +from landingzones.constants import ( STATUS_ALLOW_UPDATE, STATUS_FINISHED, STATUS_INFO_DELETE_NO_COLL, + ZONE_STATUS_OK, + ZONE_STATUS_DELETED, ) +from landingzones.forms import LandingZoneForm +from landingzones.models import LandingZone logger = logging.getLogger(__name__) @@ -45,7 +47,9 @@ APP_NAME = 'landingzones' SAMPLESHEETS_APP_NAME = 'samplesheets' ZONE_MOVE_INVALID_STATUS = 'Zone not in active state, unable to trigger action.' +ZONE_MOVE_NO_FILES = 'No files in landing zone, nothing to do.' ZONE_UPDATE_ACTIONS = ['update', 'move', 'delete'] +ZONE_UPDATE_FIELDS = ['description', 'user_message'] # Mixins ----------------------------------------------------------------------- @@ -112,11 +116,16 @@ def get_flow_data(cls, zone, flow_name, data): return data -class ZoneCreateMixin(ZoneConfigPluginMixin): +class ZoneModifyMixin(ZoneConfigPluginMixin): """Mixin to be used in zone creation in UI and REST API views""" def submit_create( - self, zone, create_colls=False, restrict_colls=False, request=None + self, + zone, + create_colls=False, + restrict_colls=False, + request=None, + sync=False, ): """ Handle timeline updating and taskflow initialization after a LandingZone @@ -126,6 +135,7 @@ def submit_create( :param create_colls: Auto-create expected collections (boolean) :param restrict_colls: Restrict access to created collections (boolean) :param request: HTTPRequest object or None + :param sync: Whether method is called from syncmodifyapi (boolean) :raise: taskflow.FlowSubmitException if taskflow submit fails """ taskflow = get_backend_api('taskflow') @@ -141,6 +151,7 @@ def submit_create( # Add event in Timeline if timeline: + tl_action = 'sync' if sync else 'create' tl_extra = { 'title': zone.title, 'assay': str(zone.assay.sodar_uuid), @@ -155,9 +166,9 @@ def submit_create( project=project, app_name=APP_NAME, user=request.user if request else None, - event_name='zone_create', - description='create landing zone {{{}}}{} for {{{}}} in ' - '{{{}}}'.format('zone', config_str, 'user', 'assay'), + event_name='zone_{}'.format(tl_action), + description='{} landing zone {{{}}}{} for {{{}}} in ' + '{{{}}}'.format(tl_action, 'zone', config_str, 'user', 'assay'), status_type='SUBMIT', extra_data=tl_extra, ) @@ -220,6 +231,45 @@ def submit_create( zone.delete() raise ex + def update_zone(self, zone, request=None): + """ + Handle timeline updating after a LandingZone object has been updated. + + :param zone: LandingZone object + :param form: LandingZoneForm object + :raise: taskflow.FlowSubmitException if taskflow submit fails + """ + timeline = get_backend_api('timeline_backend') + user = request.user if request else None + + # Add event in Timeline + if timeline: + description = 'update landing zone {zone} for {user} in {assay}' + tl_extra = { + 'title': zone.title, + 'assay': str(zone.assay.sodar_uuid), + 'description': zone.description, + 'user_message': zone.user_message, + } + tl_event = timeline.add_event( + project=zone.project, + app_name=APP_NAME, + user=user, + event_name='zone_update', + description=description, + status_type=ZONE_STATUS_OK, + extra_data=tl_extra, + ) + tl_event.add_object(obj=zone, label='zone', name=zone.title) + tl_event.add_object( + obj=user, + label='user', + name=user.username, + ) + tl_event.add_object( + obj=zone.assay, label='assay', name=zone.assay.get_name() + ) + class ZoneDeleteMixin(ZoneConfigPluginMixin): """Mixin to be used in zone creation""" @@ -282,7 +332,7 @@ def submit_delete(self, zone): tl_event=tl_event if tl_event else None, ) else: # Delete locally - zone.set_status('DELETED', STATUS_INFO_DELETE_NO_COLL) + zone.set_status(ZONE_STATUS_DELETED, STATUS_INFO_DELETE_NO_COLL) self.object = None @@ -404,7 +454,7 @@ class ZoneCreateView( ProjectPermissionMixin, InvestigationContextMixin, CurrentUserFormMixin, - ZoneCreateMixin, + ZoneModifyMixin, CreateView, ): """LandingZone creation view""" @@ -480,6 +530,99 @@ def form_valid(self, form): ) +class ZoneUpdateView( + LoginRequiredMixin, + InvestigationContextMixin, + ZoneModifyMixin, + UpdateView, +): + """LandingZone update view""" + + model = LandingZone + form_class = LandingZoneForm + slug_url_kwarg = 'landingzone' + slug_field = 'sodar_uuid' + + def get_permission_required(self, user): + """Get custom permission for user""" + if self.request.user == user: + return 'landingzones.update_zone_own' + else: + return 'landingzones.update_zone_all' + + def get(self, request, *args, **kwargs): + """Override get() to ensure the zone status""" + zone = LandingZone.objects.get(sodar_uuid=self.kwargs['landingzone']) + redirect_url = reverse( + 'landingzones:list', kwargs={'project': zone.project.sodar_uuid} + ) + # Check permissions + if not self.request.user.has_perm( + self.get_permission_required(zone.user), zone.project + ): + msg = 'You do not have permission to update this landing zone.' + messages.error(request, msg) + return redirect(redirect_url) + + # Check status + if zone.status not in STATUS_ALLOW_UPDATE: + messages.error( + request, + 'Unable to update a landing zone with the ' + 'status of "{}".'.format(zone.status), + ) + return redirect(redirect_url) + return super().get(request, *args, **kwargs) + + def form_valid(self, form): + self.zone = LandingZone.objects.get( + sodar_uuid=self.kwargs['landingzone'] + ) + redirect_url = reverse( + 'landingzones:list', + kwargs={'project': self.zone.project.sodar_uuid}, + ) + + # Check permissions + if not self.request.user.has_perm( + self.get_permission_required(self.zone.user), self.zone.project + ): + messages.error( + self.request, + 'You do not have permission to update this landing zone.', + ) + return redirect_url + + # Double check that only allowed fields are updated + # Remove create_colls and restrict_colls from changed_data + # as they are passed to the form automatically + if ( + set(form.changed_data) + - {'create_colls', 'restrict_colls'} + - set(ZONE_UPDATE_FIELDS) + ): + messages.error( + self.request, + "You can only update the following fields: {}".format( + ', '.join(ZONE_UPDATE_FIELDS) + ), + ) + return redirect(redirect_url) + + # Update zone + self.zone = form.save() + self.update_zone(zone=self.zone, request=self.request) + msg = 'Landing zone "{}" was updated.'.format(self.zone.title) + messages.success(self.request, msg) + return super().form_valid(form) + + def get_success_url(self): + return reverse( + 'landingzones:list', + kwargs={'project': self.object.project.sodar_uuid}, + ) + + class ZoneDeleteView( LoginRequiredMixin, LoggedInPermissionMixin, @@ -582,6 +725,28 @@ def get_context_data(self, *args, **kwargs): def get(self, request, *args, **kwargs): """Override get() to ensure the zone status""" zone = LandingZone.objects.get(sodar_uuid=self.kwargs['landingzone']) + try: + irods_backend = get_backend_api('omics_irods') + path = irods_backend.get_path(zone) + with irods_backend.get_session() as irods: + stats = irods_backend.get_object_stats(irods, path) + if stats['file_count'] == 0: + messages.info(request, ZONE_MOVE_NO_FILES) + return redirect( + reverse( + 'landingzones:list', + kwargs={'project': zone.project.sodar_uuid}, + ) + ) + except Exception as ex: + messages.error(request, str(ex)) + return redirect( + reverse( + 'landingzones:list', + kwargs={'project': zone.project.sodar_uuid}, + ) + ) + if zone.status not in STATUS_ALLOW_UPDATE: messages.error( request, diff --git a/landingzones/views_ajax.py b/landingzones/views_ajax.py index 8b0c5848..6e02698f 100644 --- a/landingzones/views_ajax.py +++ b/landingzones/views_ajax.py @@ -11,19 +11,34 @@ class ZoneStatusRetrieveAjaxView(SODARBaseProjectAjaxView): """Ajax API view for returning the landing zone status""" - def get_permission_required(self): - zone = LandingZone.objects.filter( - sodar_uuid=self.kwargs['landingzone'] - ).first() - return ( + permission_required = 'landingzones.view_zone_own' + + def check_zone_permission(self, zone, user): + permission = ( 'landingzones.view_zone_own' if zone.user == self.request.user else 'landingzones.view_zone_all' ) + return user.has_perm(permission, obj=zone.project) + + def post(self, request, *args, **kwargs): + zone_uuids = request.data.getlist('zone_uuids[]') + project = self.get_project() + + # Filter landing zones based on UUIDs and project + zones = LandingZone.objects.filter( + sodar_uuid__in=zone_uuids, project=project + ) + + status_dict = {} + for zone in zones: + # Check permissions + if not self.check_zone_permission(zone, self.request.user): + continue + + status_dict[str(zone.sodar_uuid)] = { + 'status': zone.status, + 'status_info': zone.status_info, + } - def get(self, *args, **kwargs): - zone = LandingZone.objects.filter( - sodar_uuid=self.kwargs['landingzone'] - ).first() - ret_data = {'status': zone.status, 'status_info': zone.status_info} - return Response(ret_data, status=200) + return Response(status_dict, status=200) diff --git a/landingzones/views_api.py b/landingzones/views_api.py index f018543d..57836422 100644 --- a/landingzones/views_api.py +++ b/landingzones/views_api.py @@ -5,7 +5,12 @@ from django.urls import reverse from rest_framework.exceptions import APIException, NotFound -from rest_framework.generics import ListAPIView, RetrieveAPIView, CreateAPIView +from rest_framework.generics import ( + ListAPIView, + RetrieveAPIView, + CreateAPIView, + UpdateAPIView, +) from rest_framework.response import Response from rest_framework import status from rest_framework.serializers import ValidationError @@ -21,17 +26,15 @@ # Samplesheets dependency from samplesheets.models import Investigation -from landingzones.models import ( - LandingZone, - STATUS_ALLOW_UPDATE, - STATUS_FINISHED, -) +from landingzones.constants import STATUS_ALLOW_UPDATE, STATUS_FINISHED +from landingzones.models import LandingZone from landingzones.serializers import LandingZoneSerializer from landingzones.views import ( ZoneModifyPermissionMixin, - ZoneCreateMixin, + ZoneModifyMixin, ZoneDeleteMixin, ZoneMoveMixin, + ZONE_UPDATE_FIELDS, ) @@ -128,7 +131,7 @@ class ZoneRetrieveAPIView(SODARAPIGenericProjectMixin, RetrieveAPIView): **Returns:** - ``assay``: Assay UUID (string) - - ``config_data``: Data for special configuration (JSON) + - ``config_data``: Data for special configuration (dict) - ``configuration``: Special configuration name (string) - ``date_modified``: Last modification date of the zone (string) - ``description``: Landing zone description (string) @@ -140,7 +143,7 @@ class ZoneRetrieveAPIView(SODARAPIGenericProjectMixin, RetrieveAPIView): - ``status_info``: Detailed description of the landing zone status (string) - ``status_locked``: Whether write access to the zone is currently locked (boolean) - ``title``: Full title of the created landing zone (string) - - ``user``: User who owns the zone (JSON) + - ``user``: User who owns the zone (dict) """ lookup_field = 'sodar_uuid' @@ -160,7 +163,7 @@ def get_permission_required(self): class ZoneCreateAPIView( - ZoneCreateMixin, SODARAPIGenericProjectMixin, CreateAPIView + ZoneModifyMixin, SODARAPIGenericProjectMixin, CreateAPIView ): """ Create a landing zone. @@ -172,7 +175,7 @@ class ZoneCreateAPIView( **Parameters:** - ``assay``: Assay UUID (string) - - ``config_data``: Data for special configuration (JSON, optional) + - ``config_data``: Data for special configuration (dict, optional) - ``configuration``: Special configuration (string, optional) - ``description``: Landing zone description (string, optional) - ``user_message``: Message displayed to users on successful moving of zone (string, optional) @@ -227,6 +230,65 @@ def perform_create(self, serializer): raise APIException('{}{}'.format(ex_msg, ex)) +class ZoneUpdateAPIView( + ZoneModifyMixin, SODARAPIGenericProjectMixin, UpdateAPIView +): + """ + Update a landing zone description and user message. + + **URL:** ``/landingzones/api/update/{LandingZone.sodar_uuid}`` + + **Methods:** ``PATCH``, ``PUT`` + + **Parameters:** + + - ``description``: Landing zone description (string, optional) + - ``user_message``: Message displayed to users on successful moving of zone (string, optional) + + **Returns:** Landing zone details (see ``ZoneRetrieveAPIView``) + """ + + lookup_field = 'sodar_uuid' + lookup_url_kwarg = 'landingzone' + permission_required = 'landingzones.update_zone_all' + serializer_class = LandingZoneSerializer + + def get_serializer_context(self, *args, **kwargs): + context = super().get_serializer_context(*args, **kwargs) + landing_zone = self.get_object() + context['assay'] = landing_zone.assay.sodar_uuid + return context + + def put(self, request, *args, **kwargs): + return self.partial_update(request, *args, **kwargs) + + def _validate_update_fields(self, serializer): + """ + Validate that only allowed fields are updated. + """ + for field in serializer.validated_data.keys(): + if field not in ZONE_UPDATE_FIELDS: + return False + return True + + def perform_update(self, serializer): + """ + Override perform_update() to add timeline event and initiate taskflow. + """ + ex_msg = 'Updating landing zone failed: ' + # Check that only allowed fields are updated + if not self._validate_update_fields(serializer): + # Should raise 400 Bad Request + raise ValidationError('{}Invalid update fields'.format(ex_msg)) + + # If all is OK, go forward with object update and taskflow submission + super().perform_update(serializer) + try: + self.update_zone(zone=serializer.instance, request=self.request) + except Exception as ex: + raise APIException('{}{}'.format(ex_msg, ex)) + + class ZoneSubmitDeleteAPIView(ZoneDeleteMixin, ZoneSubmitBaseAPIView): """ Initiate landing zone deletion. diff --git a/ontologyaccess/templates/ontologyaccess/obo_confirm_delete.html b/ontologyaccess/templates/ontologyaccess/obo_confirm_delete.html index 7b9fa3bf..4cd61f3a 100644 --- a/ontologyaccess/templates/ontologyaccess/obo_confirm_delete.html +++ b/ontologyaccess/templates/ontologyaccess/obo_confirm_delete.html @@ -21,7 +21,7 @@

Confirm OBO Ontology Deletion

href="{{ request.session.real_referer }}"> Cancel - diff --git a/ontologyaccess/templates/ontologyaccess/obo_import_form.html b/ontologyaccess/templates/ontologyaccess/obo_import_form.html index 6a09a5ff..022e56a1 100644 --- a/ontologyaccess/templates/ontologyaccess/obo_import_form.html +++ b/ontologyaccess/templates/ontologyaccess/obo_import_form.html @@ -34,7 +34,7 @@

{% if object.pk %}Update{% else %}Import{% endif %} OBO/OWL Format Ontology< href="{{ request.session.real_referer }}"> Cancel -

{% autoescape off %}{% get_icon study %}{% endautoescape %} - {{ study.get_display_name }} + {{ study.get_display_name }} {% get_irods_path study as irods_study_path %} @@ -98,7 +98,7 @@
{% autoescape off %}{% get_icon assay %}{% endautoescape %} - {{ assay.get_display_name }} + {{ assay.get_display_name }} {% get_irods_path assay as irods_assay_path %} diff --git a/samplesheets/templates/samplesheets/_search_item.html b/samplesheets/templates/samplesheets/_search_item.html index cbfe2c80..ffd872a4 100644 --- a/samplesheets/templates/samplesheets/_search_item.html +++ b/samplesheets/templates/samplesheets/_search_item.html @@ -38,7 +38,7 @@
{% if item.study %} - + {{ item.study.get_display_name }} {% else %} @@ -51,7 +51,7 @@ {% if item.assays %} {% for assay in item.assays %}
- + {{ assay.get_display_name }}
diff --git a/samplesheets/templates/samplesheets/irods_access_tickets.html b/samplesheets/templates/samplesheets/irods_access_tickets.html index eb08823e..d7b1e70d 100644 --- a/samplesheets/templates/samplesheets/irods_access_tickets.html +++ b/samplesheets/templates/samplesheets/irods_access_tickets.html @@ -15,36 +15,46 @@ {% block css %} {{ block.super }}