From ad72f1db5681085185f5255681ae62b3ab92057c Mon Sep 17 00:00:00 2001 From: Mikko Nieminen Date: Thu, 25 Aug 2022 12:18:13 +0200 Subject: [PATCH 01/17] decouple taskflow, add project modify api (#387, #979, #981) --- CHANGELOG.rst | 39 + MANIFEST.in | 1 - Makefile | 21 - README.rst | 2 - config/settings/base.py | 14 +- config/settings/local_taskflow.py | 16 - config/settings/test.py | 1 + config/settings/test_taskflow.py | 27 - docs/source/app_projectroles_basics.rst | 4 +- docs/source/app_projectroles_settings.rst | 7 - docs/source/app_taskflow.rst | 123 -- docs/source/conf.py | 4 +- docs/source/dev_core_resource.rst | 7 - docs/source/dev_project_app.rst | 2 - docs/source/dev_resource.rst | 54 + docs/source/getting_started.rst | 2 - docs/source/index.rst | 1 - docs/source/major_changes.rst | 35 + docs/source/repository.rst | 2 - example_project_app/plugins.py | 35 +- filesfolders/plugins.py | 8 - filesfolders/tests/test_plugins.py | 5 - projectroles/constants.py | 3 + projectroles/forms.py | 5 +- .../management/commands/batchupdateroles.py | 8 - projectroles/management/commands/geticons.py | 6 - .../management/commands/syncmodifyapi.py | 65 + projectroles/plugins.py | 180 ++- projectroles/remote_projects.py | 7 +- projectroles/serializers.py | 4 +- projectroles/tests/test_commands.py | 37 - projectroles/tests/test_commands_taskflow.py | 85 -- projectroles/tests/test_views_api_taskflow.py | 709 ---------- projectroles/tests/test_views_taskflow.py | 1213 ----------------- projectroles/urls.py | 43 +- projectroles/views.py | 527 +++---- projectroles/views_api.py | 3 +- projectroles/views_taskflow.py | 225 --- setup.py | 1 - taskflowbackend/__init__.py | 0 taskflowbackend/api.py | 223 --- taskflowbackend/apps.py | 5 - .../management/commands/__init__.py | 0 .../management/commands/synctaskflow.py | 229 ---- taskflowbackend/plugins.py | 24 - timeline/models.py | 2 +- timeline/templatetags/timeline_tags.py | 12 +- timeline/tests/test_views_taskflow.py | 75 - timeline/urls.py | 13 +- timeline/views_taskflow.py | 33 - 50 files changed, 605 insertions(+), 3542 deletions(-) delete mode 100644 config/settings/local_taskflow.py delete mode 100644 config/settings/test_taskflow.py delete mode 100644 docs/source/app_taskflow.rst create mode 100644 projectroles/management/commands/syncmodifyapi.py delete mode 100644 projectroles/tests/test_commands_taskflow.py delete mode 100644 projectroles/tests/test_views_api_taskflow.py delete mode 100644 projectroles/tests/test_views_taskflow.py delete mode 100644 projectroles/views_taskflow.py delete mode 100644 taskflowbackend/__init__.py delete mode 100644 taskflowbackend/api.py delete mode 100644 taskflowbackend/apps.py delete mode 100644 taskflowbackend/management/commands/__init__.py delete mode 100644 taskflowbackend/management/commands/synctaskflow.py delete mode 100644 taskflowbackend/plugins.py delete mode 100644 timeline/tests/test_views_taskflow.py delete mode 100644 timeline/views_taskflow.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5fe6ead0..9fb02e08 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,45 @@ Changelog for the **SODAR Core** Django app package. Loosely follows the `Keep a Changelog `_ guidelines. +Unreleased +========== + +Added +----- + +- **Projectroles** + - Project modifying API in ``ProjectModifyPluginMixin`` (#387) + - ``PROJECTROLES_ENABLE_MODIFY_API`` Django setting (#387) + - ``PROJECTROLES_MODIFY_API_APPS`` Django setting (#387) + - ``syncmodifyapi`` management command (#387) + +Changed +------- + +- **Projectroles** + - Replace Taskflow specific code with project modifying API calls (#387) + - Rename ``revoke_failed_invite()`` to ``revoke_invite()`` + +Fixed +----- + +- **Projectroles** + - Crash at exception handling in ``clean_new_owner()`` (#981) +- **Timeline** + - Uncaught exceptions in ``get_plugin_lookup()`` (#979) + +Removed +------- + +- **Projectroles** + - Taskflow specific views, tests and API calls (#387) + - ``get_taskflow_sync_data()`` method from ``ProjectAppPluginPoint`` (#387) +- **Taskflowbackend** + - Remove app and implement in SODAR (#387) +- **Timeline** + - Taskflow API views (#387) + + v0.10.13 (2022-07-15) ===================== diff --git a/MANIFEST.in b/MANIFEST.in index 13ceff97..b9a3bf60 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -8,7 +8,6 @@ recursive-include bgjobs * recursive-include filesfolders * recursive-include siteinfo * recursive-include sodarcache * -recursive-include taskflowbackend * recursive-include timeline * recursive-include tokens * recursive-include userprofile * diff --git a/Makefile b/Makefile index e18013a3..b1c183c5 100644 --- a/Makefile +++ b/Makefile @@ -6,10 +6,8 @@ define USAGE= @echo -e "\tmake black [arg=--] -- black formatting" @echo -e "\tmake serve -- start source server" @echo -e "\tmake serve_target -- start target server" -@echo -e "\tmake serve_taskflow [arg=sync] -- start server with SODAR Taskflow" @echo -e "\tmake collectstatic -- run collectstatic" @echo -e "\tmake test [arg=] -- run all tests or specify module/class/function" -@echo -e "\tmake test_taskflow [arg=] -- run all tests and taskflow tests or specify module/class/function" @echo -e "\tmake manage_target arg= -- run management command on target site, arg is mandatory" @echo -e endef @@ -36,15 +34,6 @@ serve_target: $(MANAGE) runserver 0.0.0.0:$(target_port) --settings=config.settings.local_target -.PHONY: serve_taskflow -ifeq ($(arg),sync) -serve_taskflow: sync_taskflow -else -serve_taskflow: -endif - $(MANAGE) runserver --settings=config.settings.local_taskflow - - .PHONY: collectstatic collectstatic: $(MANAGE) collectstatic --no-input @@ -55,11 +44,6 @@ test: collectstatic $(MANAGE) test -v 2 --parallel --settings=config.settings.test $(arg) -.PHONY: test_taskflow -test_taskflow: test - $(MANAGE) test -v 2 --tag=Taskflow --settings=config.settings.test_taskflow $(arg) - - .PHONY: manage_target manage_target: ifeq ($(arg),) @@ -71,11 +55,6 @@ else endif -.PHONY: sync_taskflow -sync_taskflow: - $(MANAGE) synctaskflow --settings=config.settings.local_taskflow - - .PHONY: usage usage: $(USAGE) diff --git a/README.rst b/README.rst index b6aa5fac..d24cd6db 100644 --- a/README.rst +++ b/README.rst @@ -101,8 +101,6 @@ This repository provides the following installable Django apps: administrators. - **sodarcache**: Generic caching and aggregation of data referring to external services. -- **taskflowbackend**: Backend app providing an API for the optional - ``sodar_taskflow`` transaction service. - **timeline**: Project app for logging and viewing project-related activity. - **tokens**: Token management for API access. - **userprofile**: Site app for viewing user profiles. diff --git a/config/settings/base.py b/config/settings/base.py index bcd1dc08..6af93ea1 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -87,8 +87,6 @@ 'siteinfo.apps.SiteinfoConfig', # API Tokens site app 'tokens.apps.TokensConfig', - # SODAR Taskflow backend app - 'taskflowbackend.apps.TaskflowbackendConfig', # Background Jobs app 'bgjobs.apps.BgjobsConfig', # External Data Cache app @@ -492,7 +490,6 @@ 'projectroles', 'siteinfo', 'sodarcache', - 'taskflowbackend', 'timeline', ], ) @@ -591,6 +588,12 @@ def set_logging(level=None): # Allow unauthenticated users to access public projects if set true PROJECTROLES_ALLOW_ANONYMOUS = env.bool('PROJECTROLES_ALLOW_ANONYMOUS', False) +# Enable project modify API +PROJECTROLES_ENABLE_MODIFY_API = False +# List of apps for executing project modify API actions in the given order. If +# not set, backend and project apps will execute in alphabetical order by name. +PROJECTROLES_MODIFY_API_APPS = [] + # General projectroles settings PROJECTROLES_DISABLE_CATEGORIES = env.bool( 'PROJECTROLES_DISABLE_CATEGORIES', False @@ -680,10 +683,5 @@ def set_logging(level=None): APPALERTS_STATUS_INTERVAL = env.int('APPALERTS_STATUS_INTERVAL', 5) -# Taskflow backend settings -TASKFLOW_SODAR_SECRET = env.str('TASKFLOW_SODAR_SECRET', 'CHANGE ME!') -TASKFLOW_TEST_MODE = False # Important! Disallow cleanup() command by default - - # SODAR constants # SODAR_CONSTANTS = get_sodar_constants(default=True) diff --git a/config/settings/local_taskflow.py b/config/settings/local_taskflow.py deleted file mode 100644 index 46bba1cf..00000000 --- a/config/settings/local_taskflow.py +++ /dev/null @@ -1,16 +0,0 @@ -from .local import * - -# Taskflow backend settings -TASKFLOW_TARGETS = ['irods', 'sodar'] -TASKFLOW_BACKEND_HOST = env.str('TASKFLOW_BACKEND_HOST', 'http://0.0.0.0') -TASKFLOW_BACKEND_PORT = env.int('TASKFLOW_BACKEND_PORT', 5005) - - -# Plugin settings -ENABLED_BACKEND_PLUGINS = [ - 'appalerts_backend', - 'sodar_cache', - 'timeline_backend', - 'example_backend_app', - 'taskflow', -] diff --git a/config/settings/test.py b/config/settings/test.py index e6ee7757..cf4a1a4e 100644 --- a/config/settings/test.py +++ b/config/settings/test.py @@ -85,6 +85,7 @@ PROJECTROLES_DEFAULT_ADMIN = 'admin' PROJECTROLES_ALLOW_LOCAL_USERS = True PROJECTROLES_ALLOW_ANONYMOUS = False +PROJECTROLES_ENABLE_MODIFY_API = True PROJECTROLES_DISABLE_CATEGORIES = False PROJECTROLES_INVITE_EXPIRY_DAYS = 14 PROJECTROLES_SEND_EMAIL = True diff --git a/config/settings/test_taskflow.py b/config/settings/test_taskflow.py deleted file mode 100644 index 1c774882..00000000 --- a/config/settings/test_taskflow.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -Taskflow test settings - -- Used to run tests against a SODAR Taskflow instance -""" - -from .test import * # noqa - - -# Taskflow backend settings -TASKFLOW_TARGETS = ['irods', 'sodar'] -TASKFLOW_BACKEND_HOST = env.str('TASKFLOW_BACKEND_HOST', 'http://0.0.0.0') -TASKFLOW_BACKEND_PORT = env.int('TASKFLOW_BACKEND_PORT', 5005) -TASKFLOW_TEST_MODE = True # Important! Make taskflow use a test iRODS server -# Override this if host is e.g. the host of a Docker Compose network -TASKFLOW_TEST_SODAR_HOST = env.str( - 'TASKFLOW_TEST_SODAR_HOST', 'http://127.0.0.1' -) - -# Plugin settings -ENABLED_BACKEND_PLUGINS = [ - 'appalerts_backend', - 'sodar_cache', - 'timeline_backend', - 'example_backend_app', - 'taskflow', -] diff --git a/docs/source/app_projectroles_basics.rst b/docs/source/app_projectroles_basics.rst index 02b81f93..6ad2ad5b 100644 --- a/docs/source/app_projectroles_basics.rst +++ b/docs/source/app_projectroles_basics.rst @@ -146,8 +146,8 @@ Other features in the projectroles app: - **Custom user model**: Additions to the standard Django user model - **Multi-domain LDAP/AD support**: Support for LDAP/AD users from multiple domains -- **SODAR Taskflow and Timeline integration**: Included but disabled unless - backend apps for Taskflow and Timeline are integrated in the Django site +- **SODAR Timeline integration**: Included but disabled unless the backend app + for Timeline is enabled in your Django site Templates and Styles diff --git a/docs/source/app_projectroles_settings.rst b/docs/source/app_projectroles_settings.rst index dbbf7081..70ae5bdf 100644 --- a/docs/source/app_projectroles_settings.rst +++ b/docs/source/app_projectroles_settings.rst @@ -80,13 +80,6 @@ Under ``DATABASES``, the setting below is recommended: If this conflicts with your existing set up, you can modify the code in your other apps to use e.g. ``@transaction.atomic``. -.. note:: - - This setting mostly is used for the ``sodar_taskflow`` transactions - supported by projectroles but not commonly used, so having this setting as - True *may* cause no issues. However, it is not officially supported at this - time. - Templates ========= diff --git a/docs/source/app_taskflow.rst b/docs/source/app_taskflow.rst deleted file mode 100644 index cda54aa3..00000000 --- a/docs/source/app_taskflow.rst +++ /dev/null @@ -1,123 +0,0 @@ -.. _app_taskflow: - - -Taskflow Backend -^^^^^^^^^^^^^^^^ - -The ``taskflowbackend`` backend app is an optional add-on used if your site -setup contains the separate **SODAR Taskflow** data transaction service. - -If you have not set up a SODAR Taskflow service for any purpose, this backend -is not needed and can be ignored. - - -.. note:: - - This app will be removed in SODAR Core v0.11.0. Its functionality along with - SODAR Taskflow will be integrated into the SODAR project. - -Basics -====== - -The ``taskflowbackend`` backend app is used in the main SODAR site to -communicate with an external SODAR Taskflow service to manage large-scale data -transactions. It has no views or database models and only provides an API for -other apps to use. - -Installation -============ - -.. warning:: - - To install this app you **must** have the ``django-sodar-core`` package - installed and the ``projectroles`` app integrated into your Django site. - See the :ref:`projectroles integration document ` - for instructions. - -Django Settings ---------------- - -The taskflowbackend app is available for your Django site after installing -``django-sodar-core``. Add the app into ``THIRD_PARTY_APPS`` as follows: - -.. code-block:: python - - THIRD_PARTY_APPS = [ - # ... - 'taskflowbackend.apps.TaskflowbackendConfig', - ] - -Next add the backend to the list of enabled backend plugins: - -.. code-block:: python - - ENABLED_BACKEND_PLUGINS = env.list('ENABLED_BACKEND_PLUGINS', None, [ - # ... - 'taskflow', - ]) - -The following app settings **must** be included in order to use the backend. -Note that the values for ``TASKFLOW_TARGETS`` depend on your SODAR Taskflow -configuration. - -.. code-block:: python - - # Taskflow backend settings - TASKFLOW_BACKEND_HOST = env.str('TASKFLOW_BACKEND_HOST', 'http://0.0.0.0') - TASKFLOW_BACKEND_PORT = env.int('TASKFLOW_BACKEND_PORT', 5005) - TASKFLOW_SODAR_SECRET = env.str('TASKFLOW_SODAR_SECRET', 'CHANGE ME!') - TASKFLOW_TARGETS = [ - 'sodar', - # .. - ] - -If testing with a SODAR Taskflow instance running on somewhere else than on -your localhost, e.g. as a Docker container, you should set the value of -``TASKFLOW_TEST_SODAR_HOST`` to point to the approppriate host accessible from -SODAR Taskflow. - -Register Plugin ---------------- - -To register the taskflowbackend plugin, run the following management command: - -.. code-block:: console - - $ ./manage.py syncplugins - -You should see the following output: - -.. code-block:: console - - Registering Plugin for taskflowbackend.plugins.BackendPlugin - - -Usage -===== - -Once enabled, Retrieve the backend API class with the following in your Django -app python code: - -.. code-block:: python - - from projectroles.plugins import get_backend_api - taskflow = get_backend_api('taskflow') - -See the docstrings of the API for more details. - -To initiate sync of existing data with your SODAR Taskflow service, you can use -the following management command: - -.. code-block:: console - - ./manage.py synctaskflow - - -Django API Documentation -======================== - -The ``TaskflowAPI`` class contains the SODAR Taskflow backend API. It should be -initialized using the ``Projectroles.plugins.get_backend_api()`` function. - -.. autoclass:: taskflowbackend.api.TaskflowAPI - :members: diff --git a/docs/source/conf.py b/docs/source/conf.py index d9e92eed..edc7f8ff 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -27,9 +27,9 @@ author = 'BIH Core Unit Bioinformatics' # The short X.Y version -version = '0.10' +version = '0.11' # The full version, including alpha/beta/rc tags -release = '0.10.13' +release = '0.11.0-WIP' # -- General configuration --------------------------------------------------- diff --git a/docs/source/dev_core_resource.rst b/docs/source/dev_core_resource.rst index 1eb64014..321e0829 100644 --- a/docs/source/dev_core_resource.rst +++ b/docs/source/dev_core_resource.rst @@ -98,13 +98,6 @@ If you want to only run a certain subset of tests, use e.g.: $ make test arg=projectroles.tests.test_views -For running tests with SODAR Taskflow (to be removed in SODAR Core v0.11.0), -you can use the supplied make command: - -.. code-block:: console - - $ make test_taskflow - Remote Site Development ======================= diff --git a/docs/source/dev_project_app.rst b/docs/source/dev_project_app.rst index 2e3c642d..a7f91273 100644 --- a/docs/source/dev_project_app.rst +++ b/docs/source/dev_project_app.rst @@ -231,8 +231,6 @@ Implementing the following is **optional**: ``info_settings`` List of names for app-specific Django settings to be displayed for administrators in the siteinfo app. -``get_taskflow_sync_data()`` - Applicable only if working with ``sodar_taskflow`` and iRODS. ``get_object_link()`` Return object link for a Timeline event. ``get_extra_data_link()`` diff --git a/docs/source/dev_resource.rst b/docs/source/dev_resource.rst index b93be44c..de0e9a85 100644 --- a/docs/source/dev_resource.rst +++ b/docs/source/dev_resource.rst @@ -234,6 +234,60 @@ Example: setup! +Project Modifying API +===================== + +If your site needs to perform specific actions when projects are created or +modified, or when project membership is altered, you can implement the project +modifying API in your app plugin. This can be useful if your site e.g. maintains +project data and access in other external databases or needs to set up some +specific data on project changes. + +.. note:: + + This API is intended for special cases. If you're unsure why you wouldn't + need it on your site, it is possible you don't. Using it unnecessarily might + complicate your site implementation. + +This API works for :ref:`project apps ` and +:ref:`backend apps `. To use it, it is recommend to include the +``ProjectModifyPluginMixin`` in your plugin class and implement the methods +relevant to your site's needs. An example of this can be seen below. + +.. code-block:: python + + from projectroles.plugins import ProjectModifyPluginMixin + + class ProjectAppPlugin(ProjectModifyPluginMixin, ProjectAppPluginPoint): + # ... + def perform_project_modify( + self, + project, + action, + project_settings, + old_data=None, + old_settings=None, + request=None, + ): + pass # Your implementation goes here + +You will also need to set ``PROJECTROLES_ENABLE_MODIFY_API=True`` in your site's +Django settings to enable calling this API. + +Project modification operations will be cancelled and reverted if errors are +encountered at any point in the project modify API calls. If your site has +multiple apps implementing this API, you should also implement reversion methods +for each operations to assert a clean rollback. These methods are also included +in the class. + +You can control the order of the apps in which this API is called by listing +your plugins in the ``PROJECTROLES_MODIFY_API_APPS`` Django setting. This will +also affect the order of reversing. + +To synchronize data for existing projects in development, you can implement the +``perform_project_sync()`` method. + + Management Command Logger ========================= diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst index 293ed3ec..b5f93675 100644 --- a/docs/source/getting_started.rst +++ b/docs/source/getting_started.rst @@ -41,8 +41,6 @@ The following Django apps will be installed when installing the administrators. - **sodarcache**: Generic caching and aggregation of data referring to external services. -- **taskflowbackend**: Backend app providing an API for the optional - ``sodar_taskflow`` transaction service. - **timeline**: Project app for logging and viewing project-related activity. - **tokens**: Token management for API access. - **userprofile**: Site app for viewing user profiles. diff --git a/docs/source/index.rst b/docs/source/index.rst index 2c7c03d6..b7becea2 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -124,7 +124,6 @@ HTML / Javascript / CSS / Bootstrap 4 app_filesfolders app_siteinfo app_sodarcache - app_taskflow app_timeline app_tokens app_userprofile diff --git a/docs/source/major_changes.rst b/docs/source/major_changes.rst index 2c37b871..cae2836f 100644 --- a/docs/source/major_changes.rst +++ b/docs/source/major_changes.rst @@ -10,6 +10,41 @@ older SODAR Core version. For a complete list of changes in current and previous releases, see the :ref:`full changelog`. +v0.11.0 (WIP) +************* + +Release Highlights +================== + +- Remove taskflowbackend app +- Add project modifying API to replace built-in taskflowbackend + +Breaking Changes +================ + +Taskflowbackend Removed +----------------------- + +This release of SODAR Core removes the ``taskflowbackend`` app. To our knowledge +it has not been used in any other projects than SODAR itself. However, it is +possible for the app to have been inadvertently enabled on your Django site, +resulting in unexpected server errors once removed. + +In case this happens, you need to first edit ``config/settings/base.py`` to +remove ``taskflowbackend.apps.TaskflowbackendConfig`` from ``LOCAL_APPS``. Also +make sure ``taskflow`` is not included in the ``ENABLED_BACKEND_PLUGINS`` +setting. + +Next, run the Django shell and enter the following: + +.. code-block:: python + + from djangoplugins.models import Plugin + Plugin.objects.get(name='taskflow').delete() + +After this the server should run without issues. + + v0.10.13 (2022-07-15) ********************* diff --git a/docs/source/repository.rst b/docs/source/repository.rst index 7bbef92d..2cba4f96 100644 --- a/docs/source/repository.rst +++ b/docs/source/repository.rst @@ -42,8 +42,6 @@ Django development site, as well as a CI and issue tracker setup. :ref:`app_siteinfo`. ``sodarcache`` :ref:`app_sodarcache`. -``taskflowbackend`` - :ref:`app_taskflow`. To be removed in SODAR Core v0.11. ``timeline`` :ref:`app_timeline`. ``tokens`` diff --git a/example_project_app/plugins.py b/example_project_app/plugins.py index 7dccc356..e3e13336 100644 --- a/example_project_app/plugins.py +++ b/example_project_app/plugins.py @@ -1,13 +1,25 @@ +"""Plugins for the example_project_app Django app""" + +from django.contrib import messages from django.urls import reverse # Projectroles dependency from projectroles.models import SODAR_CONSTANTS -from projectroles.plugins import ProjectAppPluginPoint +from projectroles.plugins import ( + ProjectAppPluginPoint, + ProjectModifyPluginMixin, +) +from projectroles.utils import get_display_name from example_project_app.urls import urlpatterns -class ProjectAppPlugin(ProjectAppPluginPoint): +EXAMPLE_MODIFY_API_MSG = ( + 'Example project app plugin API called from ' '{project_type} {action}.' +) + + +class ProjectAppPlugin(ProjectModifyPluginMixin, ProjectAppPluginPoint): """Plugin for registering app with Projectroles""" # Properties required by django-plugins ------------------------------ @@ -243,3 +255,22 @@ def get_statistics(self): 'url': reverse('home'), }, } + + def perform_project_modify( + self, + project, + action, + project_settings, + old_data=None, + old_settings=None, + request=None, + ): + """Example implementation for project modifying plugin API""" + if request: + messages.info( + request, + EXAMPLE_MODIFY_API_MSG.format( + project_type=get_display_name(project.type), + action=action.lower(), + ), + ) diff --git a/filesfolders/plugins.py b/filesfolders/plugins.py index bbd44efd..6e40e23c 100644 --- a/filesfolders/plugins.py +++ b/filesfolders/plugins.py @@ -104,14 +104,6 @@ class ProjectAppPlugin(ProjectAppPluginPoint): 'FILESFOLDERS_SHOW_LIST_COLUMNS', ] - def get_taskflow_sync_data(self): - """ - Return data for synchronizing taskflow operations. - - :return: List of dicts or None. - """ - return None - def get_object_link(self, model_str, uuid): """ Return the URL for referring to a object used by the app, along with a diff --git a/filesfolders/tests/test_plugins.py b/filesfolders/tests/test_plugins.py index 91911723..91bf506d 100644 --- a/filesfolders/tests/test_plugins.py +++ b/filesfolders/tests/test_plugins.py @@ -146,11 +146,6 @@ def test_get_object_link_hyperlink(self): self.assertEqual(ret['label'], self.hyperlink.name) self.assertEqual(ret['blank'], True) - def test_get_taskflow_sync_data(self): - """Test get_taskflow_sync_data()""" - plugin = ProjectAppPluginPoint.get_plugin(PLUGIN_NAME) - self.assertEqual(plugin.get_taskflow_sync_data(), None) - def test_get_object_link_fail(self): """Test get_object_link() with a non-existent object""" plugin = ProjectAppPluginPoint.get_plugin(PLUGIN_NAME) diff --git a/projectroles/constants.py b/projectroles/constants.py index 70b440fc..504b8869 100644 --- a/projectroles/constants.py +++ b/projectroles/constants.py @@ -46,6 +46,9 @@ }, # System user group 'SYSTEM_USER_GROUP': 'system', + # Project modification + 'PROJECT_ACTION_CREATE': 'CREATE', + 'PROJECT_ACTION_UPDATE': 'UPDATE', } diff --git a/projectroles/forms.py b/projectroles/forms.py index d94708a3..b6364b55 100644 --- a/projectroles/forms.py +++ b/projectroles/forms.py @@ -45,7 +45,6 @@ PROJECT_TYPE_PROJECT = SODAR_CONSTANTS['PROJECT_TYPE_PROJECT'] SUBMIT_STATUS_OK = SODAR_CONSTANTS['SUBMIT_STATUS_OK'] SUBMIT_STATUS_PENDING = SODAR_CONSTANTS['SUBMIT_STATUS_PENDING'] -SUBMIT_STATUS_PENDING_TASKFLOW = SODAR_CONSTANTS['SUBMIT_STATUS_PENDING'] SITE_MODE_SOURCE = SODAR_CONSTANTS['SITE_MODE_SOURCE'] SITE_MODE_TARGET = SODAR_CONSTANTS['SITE_MODE_TARGET'] APP_SETTING_SCOPE_PROJECT = SODAR_CONSTANTS['APP_SETTING_SCOPE_PROJECT'] @@ -876,8 +875,8 @@ def clean_new_owner(self): not role_as and user not in inh_owners ): raise forms.ValidationError( - 'The new owner has no roles in the {}.'.get_display_name( - self.project.type + 'The new owner has no roles in the {}.'.format( + get_display_name(self.project.type) ) ) diff --git a/projectroles/management/commands/batchupdateroles.py b/projectroles/management/commands/batchupdateroles.py index 07aecc58..b49223b5 100644 --- a/projectroles/management/commands/batchupdateroles.py +++ b/projectroles/management/commands/batchupdateroles.py @@ -48,13 +48,6 @@ class Command(RoleAssignmentModifyMixin, ProjectInviteMixin, BaseCommand): update_count = 0 invite_count = 0 request = None - sodar_url = None - - def __init__( - self, stdout=None, stderr=None, no_color=False, sodar_url=None - ): - self.sodar_url = sodar_url - super().__init__(stdout, stderr, no_color) # Internal helpers --------------------------------------------------------- @@ -95,7 +88,6 @@ def _update_role(self, project, user, role): request=self.request, project=project, instance=role_as, - sodar_url=self.sodar_url, ) self.update_count += 1 diff --git a/projectroles/management/commands/geticons.py b/projectroles/management/commands/geticons.py index 6dad2ef7..0166cc1e 100644 --- a/projectroles/management/commands/geticons.py +++ b/projectroles/management/commands/geticons.py @@ -24,12 +24,6 @@ class Command(BaseCommand): help = 'Retrieves or updates JSON Iconify icons' - def __init__( - self, stdout=None, stderr=None, no_color=False, sodar_url=None - ): - self.sodar_url = sodar_url - super().__init__(stdout, stderr, no_color) - def _download(self, url, base_path, file_name): """ Download file. diff --git a/projectroles/management/commands/syncmodifyapi.py b/projectroles/management/commands/syncmodifyapi.py new file mode 100644 index 00000000..e56d838d --- /dev/null +++ b/projectroles/management/commands/syncmodifyapi.py @@ -0,0 +1,65 @@ +""" +Syncmodifyapi management command for synchronizing existing projects using the +project modify API +""" + +import sys + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand + +# Projectroles dependency +from projectroles.management.logging import ManagementCommandLogger +from projectroles.models import Project, SODAR_CONSTANTS +from projectroles.views import ProjectModifyPluginViewMixin + + +logger = ManagementCommandLogger(__name__) +User = get_user_model() + + +# SODAR constants +PROJECT_TYPE_PROJECT = SODAR_CONSTANTS['PROJECT_TYPE_PROJECT'] +PROJECT_TYPE_CATEGORY = SODAR_CONSTANTS['PROJECT_TYPE_CATEGORY'] + + +class Command(ProjectModifyPluginViewMixin, BaseCommand): + help = 'Synchronizes existing projects using the project modify API' + + def _get_projects(self, project, project_list): + """ + Retrieve projects recursively in inheritance order. + + :param project: Current project + :param project_list: List of Project objects + """ + project_list.append(project) + logger.debug('Added: {}'.format(project.full_title)) + for c in Project.objects.filter(parent=project): + self._get_projects(c, project_list) + return project_list + + def add_arguments(self, parser): + pass + + def handle(self, *args, **options): + """Run management command""" + logger.info('Synchronizing projects..') + project_list = [] + top_cats = Project.objects.filter( + type=PROJECT_TYPE_CATEGORY, parent=None + ) + for c in top_cats: + self._get_projects(c, project_list) + logger.debug( + 'Found {} projects and categories'.format(len(project_list)) + ) + try: + for p in project_list: + logger.debug('Synchronizing project: {}'.format(p.full_title)) + self.call_project_modify_api('perform_project_sync', None, [p]) + except Exception as ex: + logger.error('Exception in project sync: {}'.format(ex)) + logger.error('Project sync failed! Unable to continue, exiting..') + sys.exit(1) + logger.info('Project data synchronized.') diff --git a/projectroles/plugins.py b/projectroles/plugins.py index 607f5aa7..0625338d 100644 --- a/projectroles/plugins.py +++ b/projectroles/plugins.py @@ -17,7 +17,154 @@ REMOVED = 2 -# Plugin points ---------------------------------------------------------------- +# Plugin Mixins ---------------------------------------------------------------- + + +class ProjectModifyPluginMixin: + """ + Mixin for project plugin API extensions for additional actions to be + performed for project and role modifications. Used if e.g. updating external + resources based on SODAR Core projects. + + Add this into your project app or backend plugin if you want to implement + additional modification features. It is not supported on site app plugins. + """ + + def perform_project_modify( + self, + project, + action, + project_settings, + old_data=None, + old_settings=None, + request=None, + ): + """ + Perform additional actions to finalize project creation or update. + + :param project: Current project object (Project) + :param action: Action to perform (CREATE or UPDATE) + :param project_settings: Project app settings (dict) + :param old_data: Old project data in case of an update (dict or None) + :param old_settings: Old app settings in case of update (dict or None) + :param request: Request object or None + """ + # TODO: Implement this in your app plugin + pass + + def revert_project_modify( + self, + project, + action, + project_settings, + old_data=None, + old_settings=None, + request=None, + ): + """ + Revert project creation or update if errors have occurred in other apps. + + :param project: Current project object (Project) + :param action: Action which was performed (CREATE or UPDATE) + :param project_settings: Project app settings (dict) + :param old_data: Old project data in case of update (dict or None) + :param old_settings: Old app settings in case of update (dict or None) + :param request: Request object or None + """ + # TODO: Implement this in your app plugin + pass + + def perform_role_modify(self, role_as, action, old_role=None, request=None): + """ + Perform additional actions to finalize role assignment creation or + update. + + :param role_as: RoleAssignment object + :param action: Action to perform (CREATE or UPDATE) + :param old_role: Role object for previous role in case of an update + :param request: Request object or None + """ + # TODO: Implement this in your app plugin + pass + + def revert_role_modify(self, role_as, action, old_role=None, request=None): + """ + Revert role assignment creation or update if errors have occurred in + other apps. + + :param role_as: RoleAssignment object + :param action: Action which was performed (CREATE or UPDATE) + :param old_role: Role object for previous role in case of an update + :param request: Request object or None + """ + # TODO: Implement this in your app plugin + pass + + def perform_role_delete(self, role_as, request=None): + """ + Perform additional actions to finalize role assignment deletion. + + :param role_as: RoleAssignment object + :param request: Request object or None + """ + # TODO: Implement this in your app plugin + pass + + def revert_role_delete(self, role_as, request=None): + """ + Revert role assignment deletion deletion if errors have occurred in + other apps. + + :param role_as: RoleAssignment object + :param request: Request object or None + """ + # TODO: Implement this in your app plugin + pass + + def perform_owner_transfer( + self, project, new_owner, old_owner, old_owner_role, request=None + ): + """ + Perform additional actions to finalize project ownership transfer. + + :param project: Project object + :param new_owner: SODARUser object for new owner + :param old_owner: SODARUser object for previous owner + :param old_owner_role: Role object for new role of previous owner + :param request: Request object or None + """ + # TODO: Implement this in your app plugin + pass + + def revert_owner_transfer( + self, project, new_owner, old_owner, old_owner_role, request=None + ): + """ + Revert project ownership transfer if errors have occurred in other apps. + + :param project: Project object + :param new_owner: SODARUser object for new owner + :param old_owner: SODARUser object for previous owner + :param old_owner_role: Role object for new role of previous owner + :param request: Request object or None + """ + # TODO: Implement this in your app plugin + pass + + def perform_project_sync(self, project): + """ + Synchronize existing projects to ensure related data exists when the + syncmodifyapi management comment is called. Should mostly be used in + development when the development databases have been e.g. modified or + recreated. + + :param project: Current project object (Project) + """ + # TODO: Implement this in your app plugin + pass + + +# Plugin Points ---------------------------------------------------------------- class ProjectAppPluginPoint(PluginPoint): @@ -116,26 +263,6 @@ class ProjectAppPluginPoint(PluginPoint): # TODO: Override this in your app plugin if needed info_settings = [] - # NOTE: For projectroles, this is implemented directly in synctaskflow - def get_taskflow_sync_data(self): - """ - Return data for synchronizing taskflow operations. - - :return: List of dicts or None. - """ - ''' - Example of valid return data: - [ - { - 'flow_name': '' - 'project_pk: '' - 'flow_data': {} - } - ] - ''' - # TODO: Implement this in your app plugin - return None - def get_object(self, model, uuid): """ Return object based on a model class and the object's SODAR UUID. @@ -226,17 +353,6 @@ def get_project_list_value(self, column_id, project, user): # TODO: Implement this in your app plugin (optional) return None - def handle_project_update(self, project, old_data): - """ - Perform actions to handle project update. - # NOTE: This is a WIP feature to be altered/expanded in a later release - - :param project: Current project (Project) - :param old_data: Old project data prior to update (dict) - """ - # TODO: Implement this in your app plugin (optional) - pass - class BackendPluginPoint(PluginPoint): """Projectroles plugin point for registering backend apps""" diff --git a/projectroles/remote_projects.py b/projectroles/remote_projects.py index 89c1bbec..6061bdcd 100644 --- a/projectroles/remote_projects.py +++ b/projectroles/remote_projects.py @@ -40,9 +40,6 @@ PROJECT_ROLE_DELEGATE = SODAR_CONSTANTS['PROJECT_ROLE_DELEGATE'] SUBMIT_STATUS_OK = SODAR_CONSTANTS['SUBMIT_STATUS_OK'] SUBMIT_STATUS_PENDING = SODAR_CONSTANTS['SUBMIT_STATUS_PENDING'] -SUBMIT_STATUS_PENDING_TASKFLOW = SODAR_CONSTANTS[ - 'SUBMIT_STATUS_PENDING_TASKFLOW' -] SITE_MODE_TARGET = SODAR_CONSTANTS['SITE_MODE_TARGET'] SITE_MODE_SOURCE = SODAR_CONSTANTS['SITE_MODE_SOURCE'] SITE_MODE_PEER = SODAR_CONSTANTS['SITE_MODE_PEER'] @@ -701,7 +698,7 @@ def _update_roles(self, project, project_data): r_uuid ]['status'] = 'updated' - if self.tl_user: # Taskflow + if self.tl_user: tl_desc = ( 'update role to "{}" for {{{}}} from site ' '{{{}}}'.format(role.name, 'user', 'site') @@ -738,7 +735,7 @@ def _update_roles(self, project, project_data): r_uuid ]['status'] = 'created' - if self.tl_user: # Taskflow + if self.tl_user: tl_desc = ( 'add role "{}" for {{{}}} ' 'from site {{{}}}'.format(role.name, 'user', 'site') diff --git a/projectroles/serializers.py b/projectroles/serializers.py index 2e41ca7b..2144f389 100644 --- a/projectroles/serializers.py +++ b/projectroles/serializers.py @@ -246,7 +246,7 @@ def validate(self, attrs): return attrs def save(self, **kwargs): - """Override save() to handle saving locally or through Taskflow""" + """Override save() to handle saving with project modify API""" # NOTE: Role not updated in response data unless we set self.instance # TODO: Figure out a clean fix self.instance = self.post_save( @@ -490,7 +490,7 @@ def validate(self, attrs): return attrs def save(self, **kwargs): - """Override save() to handle saving locally or through Taskflow""" + """Override save() to handle saving with project modify API""" # NOTE: post_save() not needed here since we do an atomic model.save() return self.modify_project( data=self.validated_data, diff --git a/projectroles/tests/test_commands.py b/projectroles/tests/test_commands.py index 124490f5..04296d2b 100644 --- a/projectroles/tests/test_commands.py +++ b/projectroles/tests/test_commands.py @@ -93,7 +93,6 @@ def setUp(self): self.cat_owner_as = self._make_assignment( self.category, self.user_owner_cat, self.role_owner ) - self.project = self._make_project( 'sub_project', PROJECT_TYPE_PROJECT, self.category ) @@ -103,7 +102,6 @@ def setUp(self): # Init command class self.command = BatchUpdateRolesCommand() - # Init file self.file = NamedTemporaryFile(delete=False) @@ -116,8 +114,6 @@ def test_invite(self): """Test inviting a single user via email to project""" p_uuid = str(self.project.sodar_uuid) email = 'new@example.com' - - # Assert preconditions self.assertEqual(ProjectInvite.objects.count(), 0) self._write_file([p_uuid, email, PROJECT_ROLE_GUEST]) @@ -125,7 +121,6 @@ def test_invite(self): **{'file': self.file.name, 'issuer': self.user_owner.username} ) - # Assert postconditions self.assertEqual(ProjectInvite.objects.count(), 1) invite = ProjectInvite.objects.first() self.assertEqual(invite.email, email) @@ -139,8 +134,6 @@ def test_invite_existing(self): p_uuid = str(self.project.sodar_uuid) email = 'new@example.com' self._make_invite(email, self.project, self.role_guest, self.user_owner) - - # Assert preconditions self.assertEqual(ProjectInvite.objects.count(), 1) self._write_file([p_uuid, email, PROJECT_ROLE_GUEST]) @@ -148,7 +141,6 @@ def test_invite_existing(self): **{'file': self.file.name, 'issuer': self.user_owner.username} ) - # Assert postconditions self.assertEqual(ProjectInvite.objects.count(), 1) self.assertEqual(len(mail.outbox), 0) @@ -157,8 +149,6 @@ def test_invite_multi_user(self): p_uuid = str(self.project.sodar_uuid) email = 'new@example.com' email2 = 'new2@example.com' - - # Assert preconditions self.assertEqual(ProjectInvite.objects.count(), 0) fd = [ @@ -170,7 +160,6 @@ def test_invite_multi_user(self): **{'file': self.file.name, 'issuer': self.user_owner.username} ) - # Assert postconditions self.assertEqual(ProjectInvite.objects.count(), 2) self.assertEqual(len(mail.outbox), 2) @@ -179,8 +168,6 @@ def test_invite_multi_project(self): c_uuid = str(self.category.sodar_uuid) p_uuid = str(self.project.sodar_uuid) email = 'new@example.com' - - # Assert preconditions self.assertEqual(ProjectInvite.objects.count(), 0) fd = [ @@ -193,7 +180,6 @@ def test_invite_multi_project(self): **{'file': self.file.name, 'issuer': self.user_owner_cat.username} ) - # Assert postconditions self.assertEqual( ProjectInvite.objects.filter(project=self.category).count(), 1 ) @@ -210,8 +196,6 @@ def test_invite_owner(self): self.command.handle( **{'file': self.file.name, 'issuer': self.user_owner.username} ) - - # Assert postconditions self.assertEqual(ProjectInvite.objects.count(), 0) self.assertEqual(len(mail.outbox), 0) @@ -223,8 +207,6 @@ def test_invite_delegate(self): self.command.handle( **{'file': self.file.name, 'issuer': self.user_owner.username} ) - - # Assert postconditions self.assertEqual(ProjectInvite.objects.count(), 1) self.assertEqual(len(mail.outbox), 1) @@ -239,8 +221,6 @@ def test_invite_delegate_no_perms(self): self.command.handle( **{'file': self.file.name, 'issuer': user_delegate.username} ) - - # Assert postconditions self.assertEqual(ProjectInvite.objects.count(), 0) self.assertEqual(len(mail.outbox), 0) @@ -256,7 +236,6 @@ def test_invite_delegate_limit(self): **{'file': self.file.name, 'issuer': self.user_owner.username} ) - # Assert postconditions self.assertEqual(ProjectInvite.objects.count(), 0) self.assertEqual(len(mail.outbox), 0) @@ -267,8 +246,6 @@ def test_role_add(self): user_new = self.make_user('user_new') user_new.email = email user_new.save() - - # Assert preconditions self.assertEqual(ProjectInvite.objects.count(), 0) self._write_file([p_uuid, email, PROJECT_ROLE_GUEST]) @@ -276,7 +253,6 @@ def test_role_add(self): **{'file': self.file.name, 'issuer': self.user_owner.username} ) - # Assert postconditions self.assertEqual(ProjectInvite.objects.count(), 0) role_as = RoleAssignment.objects.get( project=self.project, user=user_new @@ -292,8 +268,6 @@ def test_role_update(self): user_new.email = email user_new.save() role_as = self._make_assignment(self.project, user_new, self.role_guest) - - # Assert preconditions self.assertEqual( RoleAssignment.objects.filter( project=self.project, user=user_new @@ -306,7 +280,6 @@ def test_role_update(self): **{'file': self.file.name, 'issuer': self.user_owner.username} ) - # Assert postconditions self.assertEqual(ProjectInvite.objects.count(), 0) role_as.refresh_from_db() self.assertEqual(role_as.role, self.role_contributor) @@ -324,7 +297,6 @@ def test_role_update_owner(self): **{'file': self.file.name, 'issuer': self.user_owner.username} ) - # Assert postconditions self.assertEqual(ProjectInvite.objects.count(), 0) self.owner_as.refresh_from_db() self.assertEqual(self.owner_as.role, self.role_owner) @@ -353,7 +325,6 @@ def test_role_update_delegate_no_perms(self): **{'file': self.file.name, 'issuer': user_delegate.username} ) - # Assert postconditions self.assertEqual(ProjectInvite.objects.count(), 0) role_as.refresh_from_db() self.assertEqual(role_as.role, self.role_guest) @@ -375,7 +346,6 @@ def test_role_update_delegate_limit(self): **{'file': self.file.name, 'issuer': self.user_owner.username} ) - # Assert postconditions self.assertEqual(ProjectInvite.objects.count(), 0) role_as.refresh_from_db() self.assertEqual(role_as.role, self.role_guest) @@ -391,7 +361,6 @@ def test_role_add_inherited_owner(self): **{'file': self.file.name, 'issuer': self.user_owner.username} ) - # Assert postconditions self.assertEqual(ProjectInvite.objects.count(), 0) self.assertEqual( RoleAssignment.objects.filter( @@ -409,8 +378,6 @@ def test_invite_and_update(self): user_new.email = email user_new.save() email2 = 'new2@example.com' - - # Assert preconditions self.assertEqual(ProjectInvite.objects.count(), 0) fd = [ @@ -422,7 +389,6 @@ def test_invite_and_update(self): **{'file': self.file.name, 'issuer': self.user_owner.username} ) - # Assert postconditions self.assertEqual(ProjectInvite.objects.count(), 1) self.assertIsNotNone( RoleAssignment.objects.filter( @@ -443,7 +409,6 @@ def test_command_no_issuer(self): self._write_file([p_uuid, email, PROJECT_ROLE_GUEST]) self.command.handle(**{'file': self.file.name, 'issuer': None}) - # Assert postconditions invite = ProjectInvite.objects.first() self.assertEqual(invite.issuer, admin) @@ -458,7 +423,6 @@ def test_command_no_perms(self): **{'file': self.file.name, 'issuer': issuer.username} ) - # Assert postconditions self.assertEqual(ProjectInvite.objects.count(), 0) self.assertEqual(len(mail.outbox), 0) @@ -505,7 +469,6 @@ def setUp(self): self.cat_owner_as = self._make_assignment( self.category, self.user_owner, self.role_owner ) - self.project = self._make_project( 'sub_project', PROJECT_TYPE_PROJECT, self.category ) diff --git a/projectroles/tests/test_commands_taskflow.py b/projectroles/tests/test_commands_taskflow.py deleted file mode 100644 index f7c1fc1d..00000000 --- a/projectroles/tests/test_commands_taskflow.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Taskflow view tests for the projectroles app""" - -import os -from tempfile import NamedTemporaryFile - -from unittest import skipIf - -from projectroles.management.commands.batchupdateroles import ( - Command as BatchUpdateRolesCommand, -) -from projectroles.models import ( - RoleAssignment, - ProjectInvite, - SODAR_CONSTANTS, -) -from projectroles.tests.test_commands import BatchUpdateRolesMixin -from projectroles.tests.test_views_taskflow import ( - TestTaskflowBase, - TASKFLOW_ENABLED, - TASKFLOW_SKIP_MSG, -) - -# 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'] - - -@skipIf(not TASKFLOW_ENABLED, TASKFLOW_SKIP_MSG) -class TestBatchUpdateRoles(BatchUpdateRolesMixin, TestTaskflowBase): - """Tests for batchupdateroles command""" - - 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, - parent=self.category, - owner=self.user, - description='description', - ) - - # Init command class - self.command = BatchUpdateRolesCommand(sodar_url=self.get_sodar_url()) - # Init file - self.file = NamedTemporaryFile(delete=False) - - def tearDown(self): - if self.file: - os.remove(self.file.name) - super().tearDown() - - def test_role_update(self): - """Test updating an existing role for user with taskflow""" - p_uuid = str(self.project.sodar_uuid) - email = 'new@example.com' - user_new = self.make_user('user_new') - user_new.email = email - user_new.save() - role_as = self._make_assignment_taskflow( - self.project, user_new, self.role_guest - ) - - # Assert preconditions - self.assertEqual( - RoleAssignment.objects.filter( - project=self.project, user=user_new - ).count(), - 1, - ) - - self._write_file([p_uuid, email, PROJECT_ROLE_CONTRIBUTOR]) - self.command.handle( - **{'file': self.file.name, 'issuer': self.user.username} - ) - - # Assert postconditions - self.assertEqual(ProjectInvite.objects.count(), 0) - role_as.refresh_from_db() - self.assertEqual(role_as.role, self.role_contributor) diff --git a/projectroles/tests/test_views_api_taskflow.py b/projectroles/tests/test_views_api_taskflow.py deleted file mode 100644 index 06aa64f1..00000000 --- a/projectroles/tests/test_views_api_taskflow.py +++ /dev/null @@ -1,709 +0,0 @@ -"""REST API view tests for the projectroles app with SODAR Taskflow enabled""" - -from django.conf import settings -from django.contrib import auth -from django.core.exceptions import ImproperlyConfigured -from django.forms.models import model_to_dict -from django.test import tag -from django.urls import reverse - -from rest_framework.test import APILiveServerTestCase - -from unittest import skipIf - -from projectroles.app_settings import AppSettingAPI -from projectroles.models import Project, Role, RoleAssignment, SODAR_CONSTANTS -from projectroles.plugins import get_backend_api, change_plugin_status -from projectroles.tests.taskflow_testcase import TestCase -from projectroles.tests.test_models import ProjectMixin, RoleAssignmentMixin -from projectroles.tests.test_views_api import SODARAPIViewTestMixin -from projectroles.tests.test_views_taskflow import TaskflowRequestDataMixin -from projectroles.views_api import CORE_API_MEDIA_TYPE, CORE_API_DEFAULT_VERSION - - -# 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'] -SUBMIT_STATUS_OK = SODAR_CONSTANTS['SUBMIT_STATUS_OK'] -SUBMIT_STATUS_PENDING = SODAR_CONSTANTS['SUBMIT_STATUS_PENDING'] -SUBMIT_STATUS_PENDING_TASKFLOW = SODAR_CONSTANTS[ - 'SUBMIT_STATUS_PENDING_TASKFLOW' -] -APP_SETTING_SCOPE_PROJECT = SODAR_CONSTANTS['APP_SETTING_SCOPE_PROJECT'] - -# Local constants -INVITE_EMAIL = 'test@example.com' -SECRET = 'rsd886hi8276nypuvw066sbvv0rb2a6x' -TASKFLOW_ENABLED = ( - True if 'taskflow' in settings.ENABLED_BACKEND_PLUGINS else False -) -TASKFLOW_SKIP_MSG = 'Taskflow not enabled in settings' -TASKFLOW_TEST_MODE = getattr(settings, 'TASKFLOW_TEST_MODE', False) -NEW_PROJECT_TITLE = 'New Project' -UPDATED_TITLE = 'Updated Title' -UPDATED_DESC = 'Updated description' -UPDATED_README = 'Updated readme' - -# Access Django user model -User = auth.get_user_model() - -# App settings API -app_settings = AppSettingAPI() - - -# Tests with Taskflow ---------------------------------------------------------- - - -@tag('Taskflow') # Run tests if iRODS and SODAR Taskflow are enabled -class TestTaskflowAPIBase( - ProjectMixin, - RoleAssignmentMixin, - SODARAPIViewTestMixin, - TaskflowRequestDataMixin, - APILiveServerTestCase, - TestCase, -): - """Base class for testing API views with taskflow""" - - def _make_project_taskflow( - self, title, type, parent, owner, description='', readme='' - ): - """Make Project with taskflow for API view tests.""" - post_data = dict(self.request_data) - post_data.update( - { - 'title': title, - 'type': type, - 'parent': parent.sodar_uuid if parent else None, - 'owner': owner.sodar_uuid, - 'description': description, - 'readme': readme, - } - ) - response = self.request_knox( - reverse('projectroles:api_project_create'), - method='POST', - data=post_data, - media_type=CORE_API_MEDIA_TYPE, - version=CORE_API_DEFAULT_VERSION, - ) - - # Assert response and object status - self.assertEqual(response.status_code, 201, msg=response.content) - project = Project.objects.get(title=title) - return project, project.get_owner() - - def _make_assignment_taskflow(self, project, user, role): - """Make RoleAssignment with taskflow for API view tests.""" - url = reverse( - 'projectroles:api_role_create', - kwargs={'project': project.sodar_uuid}, - ) - self.request_data.update( - {'role': role.name, 'user': str(user.sodar_uuid)} - ) - response = self.request_knox( - url, - method='POST', - data=self.request_data, - media_type=CORE_API_MEDIA_TYPE, - version=CORE_API_DEFAULT_VERSION, - ) - self.assertEqual(response.status_code, 201, msg=response.content) - return RoleAssignment.objects.get(project=project, user=user, role=role) - - def setUp(self): - # Ensure TASKFLOW_TEST_MODE is True to avoid data loss - if not TASKFLOW_TEST_MODE: - raise ImproperlyConfigured( - 'TASKFLOW_TEST_MODE not True, ' - 'testing with SODAR Taskflow disabled' - ) - - # Set up live server URL for requests - self.request_data = {'sodar_url': self.get_sodar_url()} - - # Get taskflow plugin (or None if taskflow not enabled) - change_plugin_status( - name='taskflow', status=0, plugin_type='backend' # 0 = Enabled - ) - self.taskflow = get_backend_api('taskflow', force=True) - - # 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 user - self.user = self.make_user('superuser') - self.user.is_staff = True - self.user.is_superuser = True - self.user.save() - - # Create category locally (categories are not handled with taskflow) - self.category = self._make_project( - 'TestCategory', PROJECT_TYPE_CATEGORY, None - ) - self._make_assignment(self.category, self.user, self.role_owner) - - # Get knox token for self.user - self.knox_token = self.get_token(self.user) - - def tearDown(self): - self.taskflow.cleanup() - - -@tag('Taskflow') # Run tests if iRODS and SODAR Taskflow are enabled -class TestCoreTaskflowAPIBase(TestTaskflowAPIBase): - """Override of TestTaskflowAPIBase for SODAR Core API views""" - - media_type = CORE_API_MEDIA_TYPE - api_version = CORE_API_DEFAULT_VERSION - - -@skipIf(not TASKFLOW_ENABLED, TASKFLOW_SKIP_MSG) -class TestProjectCreateAPIView(TestCoreTaskflowAPIBase): - """Tests for ProjectCreateAPIView with taskflow""" - - def test_create_project(self): - """Test project creation""" - - # Assert precondition - self.assertEqual(Project.objects.all().count(), 1) - - url = reverse('projectroles:api_project_create') - self.request_data.update( - { - 'title': NEW_PROJECT_TITLE, - 'type': PROJECT_TYPE_PROJECT, - 'parent': str(self.category.sodar_uuid), - 'description': 'description', - 'readme': 'readme', - 'owner': str(self.user.sodar_uuid), - } - ) - response = self.request_knox(url, method='POST', data=self.request_data) - - # Assert response and object status - self.assertEqual(response.status_code, 201, msg=response.content) - self.assertEqual(Project.objects.all().count(), 2) - - -@skipIf(not TASKFLOW_ENABLED, TASKFLOW_SKIP_MSG) -class TestProjectUpdateAPIView(TestCoreTaskflowAPIBase): - """Tests for ProjectUpdateAPIView with taskflow""" - - 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, - parent=self.category, - owner=self.user, - description='description', - ) - - def test_put_category(self): - """Test put() for category updating""" - new_owner = self.make_user('new_owner') - - # Assert preconditions - self.assertEqual(Project.objects.count(), 2) - - url = reverse( - 'projectroles:api_project_update', - kwargs={'project': self.category.sodar_uuid}, - ) - self.request_data.update( - { - 'title': UPDATED_TITLE, - 'type': PROJECT_TYPE_CATEGORY, - 'parent': '', - 'description': UPDATED_DESC, - 'readme': UPDATED_README, - 'owner': str(new_owner.sodar_uuid), - } - ) - response = self.request_knox(url, method='PUT', data=self.request_data) - - # Assert response and project status - self.assertEqual(response.status_code, 200, msg=response.content) - self.assertEqual(Project.objects.count(), 2) - - # Assert object content - self.category.refresh_from_db() - model_dict = model_to_dict(self.category) - model_dict['readme'] = model_dict['readme'].raw - expected = { - 'id': self.category.pk, - 'title': UPDATED_TITLE, - 'type': PROJECT_TYPE_CATEGORY, - 'parent': None, - 'description': UPDATED_DESC, - 'readme': UPDATED_README, - 'public_guest_access': False, - 'submit_status': SODAR_CONSTANTS['SUBMIT_STATUS_OK'], - 'full_title': UPDATED_TITLE, - 'has_public_children': False, - 'sodar_uuid': self.category.sodar_uuid, - } - self.assertEqual(model_dict, expected) - - # Assert role assignment - self.assertEqual( - RoleAssignment.objects.filter(project=self.category).count(), 1 - ) - self.assertEqual(self.category.get_owner().user, new_owner) - - def test_put_project(self): - """Test put() for project updating""" - new_owner = self.make_user('new_owner') - - # Assert preconditions - self.assertEqual(Project.objects.count(), 2) - - url = reverse( - 'projectroles:api_project_update', - kwargs={'project': self.project.sodar_uuid}, - ) - self.request_data.update( - { - 'title': UPDATED_TITLE, - 'type': PROJECT_TYPE_PROJECT, - 'parent': str(self.category.sodar_uuid), - 'description': UPDATED_DESC, - 'readme': UPDATED_README, - 'public_guest_access': True, - 'owner': str(new_owner.sodar_uuid), - } - ) - response = self.request_knox(url, method='PUT', data=self.request_data) - - # Assert response and project status - self.assertEqual(response.status_code, 200, msg=response.content) - self.assertEqual(Project.objects.count(), 2) - - # Assert object content - self.project.refresh_from_db() - model_dict = model_to_dict(self.project) - model_dict['readme'] = model_dict['readme'].raw - expected = { - 'id': self.project.pk, - 'title': UPDATED_TITLE, - 'type': PROJECT_TYPE_PROJECT, - 'parent': self.category.pk, - 'description': UPDATED_DESC, - 'readme': UPDATED_README, - 'public_guest_access': True, - 'submit_status': SODAR_CONSTANTS['SUBMIT_STATUS_OK'], - 'full_title': self.category.title + ' / ' + UPDATED_TITLE, - 'has_public_children': False, - 'sodar_uuid': self.project.sodar_uuid, - } - self.assertEqual(model_dict, expected) - - # Assert role assignment - self.assertEqual( - RoleAssignment.objects.filter(project=self.project).count(), 1 - ) - self.assertEqual(self.project.get_owner().user, new_owner) - - def test_patch_category(self): - """Test patch() for updating category metadata""" - - # Assert preconditions - self.assertEqual(Project.objects.count(), 2) - - url = reverse( - 'projectroles:api_project_update', - kwargs={'project': self.category.sodar_uuid}, - ) - self.request_data.update( - { - 'title': UPDATED_TITLE, - 'description': UPDATED_DESC, - 'readme': UPDATED_README, - } - ) - response = self.request_knox( - url, method='PATCH', data=self.request_data - ) - - # Assert response and project status - self.assertEqual(response.status_code, 200, msg=response.content) - self.assertEqual(Project.objects.count(), 2) - - # Assert object content - self.category.refresh_from_db() - model_dict = model_to_dict(self.category) - model_dict['readme'] = model_dict['readme'].raw - expected = { - 'id': self.category.pk, - 'title': UPDATED_TITLE, - 'type': PROJECT_TYPE_CATEGORY, - 'parent': None, - 'description': UPDATED_DESC, - 'readme': UPDATED_README, - 'public_guest_access': False, - 'submit_status': SODAR_CONSTANTS['SUBMIT_STATUS_OK'], - 'full_title': UPDATED_TITLE, - 'has_public_children': False, - 'sodar_uuid': self.category.sodar_uuid, - } - self.assertEqual(model_dict, expected) - - # Assert role assignment - self.assertEqual(self.category.get_owner().user, self.user) - - def test_patch_project(self): - """Test patch() for updating project metadata""" - - # Assert preconditions - self.assertEqual(Project.objects.count(), 2) - - url = reverse( - 'projectroles:api_project_update', - kwargs={'project': self.project.sodar_uuid}, - ) - self.request_data.update( - { - 'title': UPDATED_TITLE, - 'description': UPDATED_DESC, - 'readme': UPDATED_README, - } - ) - response = self.request_knox( - url, method='PATCH', data=self.request_data - ) - - # Assert response and project status - self.assertEqual(response.status_code, 200, msg=response.content) - self.assertEqual(Project.objects.count(), 2) - - # Assert object content - self.project.refresh_from_db() - model_dict = model_to_dict(self.project) - model_dict['readme'] = model_dict['readme'].raw - expected = { - 'id': self.project.pk, - 'title': UPDATED_TITLE, - 'type': PROJECT_TYPE_PROJECT, - 'parent': self.category.pk, - 'description': UPDATED_DESC, - 'readme': UPDATED_README, - 'public_guest_access': False, - 'submit_status': SODAR_CONSTANTS['SUBMIT_STATUS_OK'], - 'full_title': self.category.title + ' / ' + UPDATED_TITLE, - 'has_public_children': False, - 'sodar_uuid': self.project.sodar_uuid, - } - self.assertEqual(model_dict, expected) - - # Assert role assignment - self.assertEqual(self.project.get_owner().user, self.user) - - def test_patch_project_move(self): - """Test patch() for moving project under a different category""" - - new_category = self._make_project( - 'NewCategory', PROJECT_TYPE_CATEGORY, None - ) - self._make_assignment(new_category, self.user, self.role_owner) - url = reverse( - 'projectroles:api_project_update', - kwargs={'project': self.project.sodar_uuid}, - ) - self.request_data.update({'parent': str(new_category.sodar_uuid)}) - response = self.request_knox( - url, method='PATCH', data=self.request_data - ) - - # Assert response - self.assertEqual(response.status_code, 200, msg=response.content) - - # Assert object content - self.project.refresh_from_db() - model_dict = model_to_dict(self.project) - self.assertEqual(model_dict['parent'], new_category.pk) - - # Assert role assignment - self.assertEqual(self.project.get_owner().user, self.user) - - -@skipIf(not TASKFLOW_ENABLED, TASKFLOW_SKIP_MSG) -class TestRoleAssignmentCreateAPIView(TestCoreTaskflowAPIBase): - """Tests for RoleAssignmentCreateAPIView with taskflow""" - - 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, - parent=self.category, - owner=self.user, - description='description', - ) - - # Create user for assignments - self.assign_user = self.make_user('assign_user') - - def test_create_role(self): - """Test role assignment creation""" - - # Assert precondition - self.assertEqual(RoleAssignment.objects.all().count(), 2) - - url = reverse( - 'projectroles:api_role_create', - kwargs={'project': self.project.sodar_uuid}, - ) - self.request_data.update( - { - 'role': PROJECT_ROLE_CONTRIBUTOR, - 'user': str(self.assign_user.sodar_uuid), - } - ) - response = self.request_knox(url, method='POST', data=self.request_data) - - # Assert response and object status - self.assertEqual(response.status_code, 201, msg=response.content) - self.assertEqual(RoleAssignment.objects.all().count(), 3) - - -@skipIf(not TASKFLOW_ENABLED, TASKFLOW_SKIP_MSG) -class TestRoleAssignmentUpdateAPIView(TestCoreTaskflowAPIBase): - """Tests for RoleAssignmentUpdateAPIView with taskflow""" - - 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, - parent=self.category, - owner=self.user, - description='description', - ) - - # Create user for assignments - self.assign_user = self.make_user('assign_user') - - # Make extra assignment with Taskflow - self.update_as = self._make_assignment_taskflow( - project=self.project, - user=self.assign_user, - role=self.role_contributor, - ) - - def test_put_role(self): - """Test put() for role assignment updating""" - - # Assert precondition - self.assertEqual(RoleAssignment.objects.all().count(), 3) - - url = reverse( - 'projectroles:api_role_update', - kwargs={'roleassignment': self.update_as.sodar_uuid}, - ) - self.request_data.update( - { - 'role': PROJECT_ROLE_GUEST, - 'user': str(self.assign_user.sodar_uuid), - } - ) - response = self.request_knox(url, method='PUT', data=self.request_data) - - # Assert response and object status - self.assertEqual(response.status_code, 200, msg=response.content) - self.assertEqual(RoleAssignment.objects.all().count(), 3) - - def test_patch_role(self): - """Test patch() for role assignment updating""" - - # Assert precondition - self.assertEqual(RoleAssignment.objects.all().count(), 3) - - url = reverse( - 'projectroles:api_role_update', - kwargs={'roleassignment': self.update_as.sodar_uuid}, - ) - self.request_data.update({'role': PROJECT_ROLE_GUEST}) - response = self.request_knox( - url, method='PATCH', data=self.request_data - ) - - # Assert response and object status - self.assertEqual(response.status_code, 200, msg=response.content) - self.assertEqual(RoleAssignment.objects.all().count(), 3) - - -@skipIf(not TASKFLOW_ENABLED, TASKFLOW_SKIP_MSG) -class TestRoleAssignmentDestroyAPIView(TestCoreTaskflowAPIBase): - """Tests for RoleAssignmentDestroyAPIView with taskflow""" - - 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, - parent=self.category, - owner=self.user, - description='description', - ) - - # Create user for assignments - self.assign_user = self.make_user('assign_user') - - # Make extra assignment with Taskflow - self.update_as = self._make_assignment_taskflow( - project=self.project, - user=self.assign_user, - role=self.role_contributor, - ) - - def test_delete_role(self): - """Test delete() for role assignment deletion""" - - # Assert precondition - self.assertEqual(RoleAssignment.objects.all().count(), 3) - - url = reverse( - 'projectroles:api_role_destroy', - kwargs={'roleassignment': self.update_as.sodar_uuid}, - ) - response = self.request_knox( - url, method='DELETE', data=self.request_data - ) - - # Assert response and object status - self.assertEqual(response.status_code, 204, msg=response.content) - self.assertEqual(RoleAssignment.objects.all().count(), 2) - - -@skipIf(not TASKFLOW_ENABLED, TASKFLOW_SKIP_MSG) -class TestRoleAssignmentOwnerTransferAPIView(TestCoreTaskflowAPIBase): - """Tests for RoleAssignmentOwnerTransferAPIView""" - - def setUp(self): - super().setUp() - - # Make project with owner in Taskflow and Django - self.user_owner = self.make_user('user_owner') - self.project, self.owner_as = self._make_project_taskflow( - title='TestProject', - type=PROJECT_TYPE_PROJECT, - parent=self.category, - owner=self.user_owner, - description='description', - ) - - # Create user for assignments - self.assign_user = self.make_user('assign_user') - - def test_transfer_owner(self): - """Test transferring ownership for a project""" - - # Make extra assignment with Taskflow - self._make_assignment_taskflow( - project=self.project, - user=self.assign_user, - role=self.role_contributor, - ) - - # Assert preconditions - self.assertEqual(self.project.get_owner().user, self.user_owner) - - url = reverse( - 'projectroles:api_role_owner_transfer', - kwargs={'project': self.project.sodar_uuid}, - ) - post_data = { - 'new_owner': self.assign_user.username, - 'old_owner_role': self.role_contributor.name, - } - response = self.request_knox(url, method='POST', data=post_data) - - # Assert response and project status - self.assertEqual(response.status_code, 200, msg=response.content) - self.assertEqual(self.project.get_owner().user, self.assign_user) - self.assertEqual( - RoleAssignment.objects.get( - project=self.project, user=self.user_owner - ).role, - self.role_contributor, - ) - - def test_transfer_owner_category(self): - """Test transferring ownership for a category""" - - # Make extra assignment with Taskflow - self._make_assignment_taskflow( - project=self.category, - user=self.assign_user, - role=self.role_contributor, - ) - # Assert preconditions - self.assertEqual(self.category.get_owner().user, self.user) - - url = reverse( - 'projectroles:api_role_owner_transfer', - kwargs={'project': self.category.sodar_uuid}, - ) - post_data = { - 'new_owner': self.assign_user.username, - 'old_owner_role': self.role_contributor.name, - } - response = self.request_knox(url, method='POST', data=post_data) - - # Assert response and project status - self.assertEqual(response.status_code, 200, msg=response.content) - self.assertEqual(self.category.get_owner().user, self.assign_user) - - def test_transfer_owner_inherit(self): - """Test transferring ownership to an inherited owner""" - - # Make extra assignment with Taskflow - self._make_assignment_taskflow( - project=self.project, - user=self.assign_user, - role=self.role_contributor, - ) - - # Assert preconditions - self.assertEqual(self.project.get_owner().user, self.user_owner) - - url = reverse( - 'projectroles:api_role_owner_transfer', - kwargs={'project': self.project.sodar_uuid}, - ) - post_data = { - 'new_owner': self.user.username, # self.user = category owner - 'old_owner_role': self.role_contributor.name, - } - response = self.request_knox(url, method='POST', data=post_data) - - # Assert response and project status - self.assertEqual(response.status_code, 200, msg=response.content) - self.assertEqual(self.project.get_owner().user, self.user) - self.assertEqual( - RoleAssignment.objects.get( - project=self.project, user=self.user_owner - ).role, - self.role_contributor, - ) diff --git a/projectroles/tests/test_views_taskflow.py b/projectroles/tests/test_views_taskflow.py deleted file mode 100644 index c1e12ae2..00000000 --- a/projectroles/tests/test_views_taskflow.py +++ /dev/null @@ -1,1213 +0,0 @@ -"""Taskflow tests for management commands in the projectroles app""" - -# NOTE: You must supply 'sodar_url': self.live_server_url in taskflow requests! -# This is due to the Django 1.10.x feature described here: -# https://code.djangoproject.com/ticket/27596 - -from django.conf import settings -from django.contrib import auth -from django.core.exceptions import ImproperlyConfigured -from django.forms.models import model_to_dict -from django.test import ( - LiveServerTestCase, - RequestFactory, - tag, - override_settings, -) - -# HACK to get around https://stackoverflow.com/a/25081791 -from django.urls import reverse - -from unittest import skipIf - -from projectroles import views_taskflow -from projectroles.app_settings import AppSettingAPI -from projectroles.models import ( - Project, - Role, - RoleAssignment, - ProjectInvite, - SODAR_CONSTANTS, -) -from projectroles.plugins import get_backend_api, change_plugin_status -from projectroles.tests.taskflow_testcase import TestCase -from projectroles.tests.test_models import ( - ProjectInviteMixin, - ProjectMixin, - RoleAssignmentMixin, -) -from projectroles.tests.test_views import TestViewsBase, TASKFLOW_SECRET_INVALID - -# Access Django user model -User = auth.get_user_model() - -# App settings API -app_settings = AppSettingAPI() - - -# 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'] -SUBMIT_STATUS_OK = SODAR_CONSTANTS['SUBMIT_STATUS_OK'] -SUBMIT_STATUS_PENDING = SODAR_CONSTANTS['SUBMIT_STATUS_PENDING'] -SUBMIT_STATUS_PENDING_TASKFLOW = SODAR_CONSTANTS[ - 'SUBMIT_STATUS_PENDING_TASKFLOW' -] -APP_SETTING_SCOPE_PROJECT = SODAR_CONSTANTS['APP_SETTING_SCOPE_PROJECT'] - -# Local constants -INVITE_EMAIL = 'test@example.com' -SECRET = 'rsd886hi8276nypuvw066sbvv0rb2a6x' -TASKFLOW_ENABLED = ( - True if 'taskflow' in settings.ENABLED_BACKEND_PLUGINS else False -) -TASKFLOW_SKIP_MSG = 'Taskflow not enabled in settings' -TASKFLOW_TEST_MODE = getattr(settings, 'TASKFLOW_TEST_MODE', False) - - -# Base Classes ----------------------------------------------------------------- - - -class TaskflowRequestDataMixin: - """ - Default request data mixin for Taskflow tests. Should be used on a class - based on LiveServerTestCase. - """ - - #: Override live server host for access from Docker - host = '0.0.0.0' - - def get_sodar_url(self): - """Return SODAR URL for callbacks in testing""" - return '{}:{}'.format( - settings.TASKFLOW_TEST_SODAR_HOST, - self.live_server_url.split(':')[2], - ) - - -@tag('Taskflow') # Run tests if iRODS and SODAR Taskflow are enabled -class TestTaskflowBase( - ProjectMixin, - RoleAssignmentMixin, - TaskflowRequestDataMixin, - LiveServerTestCase, - TestCase, -): - """Base class for testing UI views with taskflow""" - - def _make_project_taskflow( - self, title, type, parent, owner, description, public_guest_access=False - ): - """Make Project with taskflow for UI view tests""" - post_data = dict(self.request_data) - post_data.update( - { - 'title': title, - 'type': type, - 'parent': parent.sodar_uuid if parent else None, - 'owner': owner.sodar_uuid, - 'description': description, - 'public_guest_access': public_guest_access, - } - ) - post_data.update( - app_settings.get_all_defaults( - APP_SETTING_SCOPE_PROJECT, post_safe=True - ) - ) # Add default settings - - post_kwargs = {'project': parent.sodar_uuid} if parent else {} - - with self.login(self.user): - response = self.client.post( - reverse('projectroles:create', kwargs=post_kwargs), post_data - ) - self.assertEqual(response.status_code, 302) - project = Project.objects.get(title=title) - self.assertRedirects( - response, - reverse( - 'projectroles:detail', - kwargs={'project': project.sodar_uuid}, - ), - ) - - owner_as = project.get_owner() - return project, owner_as - - def _make_assignment_taskflow(self, project, user, role): - """Make RoleAssignment with taskflow for UI view tests""" - post_data = dict(self.request_data) - post_data.update( - { - 'project': project.sodar_uuid, - 'user': user.sodar_uuid, - 'role': role.pk, - } - ) - - with self.login(self.user): - response = self.client.post( - reverse( - 'projectroles:role_create', - kwargs={'project': project.sodar_uuid}, - ), - post_data, - ) - role_as = RoleAssignment.objects.get(project=project, user=user) - self.assertRedirects( - response, - reverse( - 'projectroles:roles', kwargs={'project': project.sodar_uuid} - ), - ) - - return role_as - - def setUp(self): - # Ensure TASKFLOW_TEST_MODE is True to avoid data loss - if not TASKFLOW_TEST_MODE: - raise ImproperlyConfigured( - 'TASKFLOW_TEST_MODE not True, ' - 'testing with SODAR Taskflow disabled' - ) - - # Set up live server URL for requests - self.request_data = {'sodar_url': self.get_sodar_url()} - - # Get taskflow plugin (or None if taskflow not enabled) - change_plugin_status( - name='taskflow', status=0, plugin_type='backend' # 0 = Enabled - ) - self.taskflow = get_backend_api('taskflow', force=True) - - # 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 users - self.user_cat = self.make_user('user_cat') - self.user = self.make_user('superuser') - self.user.is_staff = True - self.user.is_superuser = True - self.user.save() - - # Create category locally - self.category = self._make_project( - 'TestCategory', PROJECT_TYPE_CATEGORY, None - ) - self._make_assignment(self.category, self.user_cat, self.role_owner) - - def tearDown(self): - self.taskflow.cleanup() - - -# Local Tests ------------------------------------------------------------------ - - -class TestTaskflowLocalAPIBase( - ProjectMixin, RoleAssignmentMixin, TestViewsBase -): - """Base class for testing the local taskflow API views""" - - def setUp(self): - super().setUp() - self.req_factory = RequestFactory() - - -@override_settings(ENABLED_BACKEND_PLUGINS=['taskflow']) -class TestProjectGetAPIView(TestTaskflowLocalAPIBase): - """Tests for the project retrieve API view""" - - def setUp(self): - super().setUp() - self.project = self._make_project( - 'TestProject', PROJECT_TYPE_PROJECT, None - ) - self.owner_as = self._make_assignment( - self.project, self.user, self.role_owner - ) - - def test_post(self): - """Test POST request for getting a project""" - request = self.req_factory.post( - reverse('projectroles:taskflow_project_get'), - data={ - 'project_uuid': str(self.project.sodar_uuid), - 'sodar_secret': settings.TASKFLOW_SODAR_SECRET, - }, - ) - response = views_taskflow.TaskflowProjectGetAPIView.as_view()(request) - self.assertEqual(response.status_code, 200) - expected = { - 'project_uuid': str(self.project.sodar_uuid), - 'title': self.project.title, - 'description': self.project.description, - } - self.assertEqual(response.data, expected) - - @override_settings(ENABLED_BACKEND_PLUGINS=['taskflow']) - def test_get_pending(self): - """Test POST request to get a pending project""" - pd_project = self._make_project( - title='TestProject2', - type=PROJECT_TYPE_PROJECT, - parent=None, - submit_status=SUBMIT_STATUS_PENDING_TASKFLOW, - ) - request = self.req_factory.post( - reverse('projectroles:taskflow_project_get'), - data={ - 'project_uuid': str(pd_project.sodar_uuid), - 'sodar_secret': settings.TASKFLOW_SODAR_SECRET, - }, - ) - response = views_taskflow.TaskflowProjectGetAPIView.as_view()(request) - self.assertEqual(response.status_code, 404) - - -@override_settings(ENABLED_BACKEND_PLUGINS=['taskflow']) -class TestProjectUpdateAPIView(TestTaskflowLocalAPIBase): - """Tests for the project updating API view""" - - def setUp(self): - super().setUp() - self.category = self._make_project( - 'TestCategory', PROJECT_TYPE_CATEGORY, None - ) - self.cat_owner_as = self._make_assignment( - self.category, self.user, self.role_owner - ) - self.project = self._make_project( - 'TestProject', PROJECT_TYPE_PROJECT, self.category - ) - self.owner_as = self._make_assignment( - self.project, self.user, self.role_owner - ) - - def test_post(self): - """Test POST request for updating a project""" - # NOTE: Duplicate titles not checked here, not allowed in the form - title = 'New title' - desc = 'New desc' - readme = 'New readme' - request = self.req_factory.post( - reverse('projectroles:taskflow_project_update'), - data={ - 'project_uuid': str(self.project.sodar_uuid), - 'title': title, - 'parent_uuid': str(self.category.sodar_uuid), - 'description': desc, - 'readme': readme, - 'sodar_secret': settings.TASKFLOW_SODAR_SECRET, - }, - ) - response = views_taskflow.TaskflowProjectUpdateAPIView.as_view()( - request - ) - self.assertEqual(response.status_code, 200) - self.project.refresh_from_db() - self.assertEqual(self.project.title, title) - self.assertEqual(self.project.description, desc) - self.assertEqual(self.project.readme.raw, readme) - - def test_post_no_description(self): - """Test POST request without a description field""" - # NOTE: Duplicate titles not checked here, not allowed in the form - title = 'New title' - readme = 'New readme' - request = self.req_factory.post( - reverse('projectroles:taskflow_project_update'), - data={ - 'project_uuid': str(self.project.sodar_uuid), - 'title': title, - 'parent_uuid': str(self.category.sodar_uuid), - 'readme': readme, - 'sodar_secret': settings.TASKFLOW_SODAR_SECRET, - }, - ) - response = views_taskflow.TaskflowProjectUpdateAPIView.as_view()( - request - ) - self.assertEqual(response.status_code, 200) - self.project.refresh_from_db() - self.assertEqual(self.project.title, title) - self.assertEqual(self.project.description, '') - self.assertEqual(self.project.readme.raw, readme) - - def test_post_move(self): - """Test POST request for moving a project""" - new_category = self._make_project('NewCat', PROJECT_TYPE_CATEGORY, None) - request = self.req_factory.post( - reverse('projectroles:taskflow_project_update'), - data={ - 'project_uuid': str(self.project.sodar_uuid), - 'title': self.project.title, - 'parent_uuid': str(new_category.sodar_uuid), - 'description': self.project.description, - 'readme': '', - 'sodar_secret': settings.TASKFLOW_SODAR_SECRET, - }, - ) - response = views_taskflow.TaskflowProjectUpdateAPIView.as_view()( - request - ) - self.assertEqual(response.status_code, 200) - self.project.refresh_from_db() - self.assertEqual(self.project.parent, new_category) - - -@override_settings(ENABLED_BACKEND_PLUGINS=['taskflow']) -class TestRoleAssignmentGetAPIView(TestTaskflowLocalAPIBase): - """Tests for the role assignment getting API view""" - - def setUp(self): - super().setUp() - self.project = self._make_project( - 'TestProject', PROJECT_TYPE_PROJECT, None - ) - self.owner_as = self._make_assignment( - self.project, self.user, self.role_owner - ) - - def test_post(self): - """Test POST request for getting a role assignment""" - request = self.req_factory.post( - reverse('projectroles:taskflow_role_get'), - data={ - 'project_uuid': str(self.project.sodar_uuid), - 'user_uuid': str(self.user.sodar_uuid), - 'sodar_secret': settings.TASKFLOW_SODAR_SECRET, - }, - ) - response = views_taskflow.TaskflowRoleAssignmentGetAPIView.as_view()( - request - ) - self.assertEqual(response.status_code, 200) - expected = { - 'assignment_uuid': str(self.owner_as.sodar_uuid), - 'project_uuid': str(self.project.sodar_uuid), - 'user_uuid': str(self.user.sodar_uuid), - 'role_pk': self.role_owner.pk, - 'role_name': self.role_owner.name, - } - self.assertEqual(response.data, expected) - - -@override_settings(ENABLED_BACKEND_PLUGINS=['taskflow']) -class TestRoleAssignmentSetAPIView(TestTaskflowLocalAPIBase): - """Tests for the role assignment setting API view""" - - def setUp(self): - super().setUp() - self.project = self._make_project( - 'TestProject', PROJECT_TYPE_PROJECT, None - ) - self.owner_as = self._make_assignment( - self.project, self.user, self.role_owner - ) - - def test_post_new(self): - """Test POST request for assigning a new role""" - new_user = self.make_user('new_user') - self.assertEqual(RoleAssignment.objects.all().count(), 1) - request = self.req_factory.post( - reverse('projectroles:taskflow_role_set'), - data={ - 'project_uuid': str(self.project.sodar_uuid), - 'user_uuid': str(new_user.sodar_uuid), - 'role_pk': self.role_contributor.pk, - 'sodar_secret': settings.TASKFLOW_SODAR_SECRET, - }, - ) - response = views_taskflow.TaskflowRoleAssignmentSetAPIView.as_view()( - request - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(RoleAssignment.objects.all().count(), 2) - new_as = RoleAssignment.objects.get(project=self.project, user=new_user) - self.assertEqual(new_as.role.pk, self.role_contributor.pk) - - def test_post_existing(self): - """Test POST request for updating an existing role""" - new_user = self.make_user('new_user') - self._make_assignment(self.project, new_user, self.role_guest) - self.assertEqual(RoleAssignment.objects.all().count(), 2) - request = self.req_factory.post( - reverse('projectroles:taskflow_role_set'), - data={ - 'project_uuid': str(self.project.sodar_uuid), - 'user_uuid': str(new_user.sodar_uuid), - 'role_pk': self.role_contributor.pk, - 'sodar_secret': settings.TASKFLOW_SODAR_SECRET, - }, - ) - response = views_taskflow.TaskflowRoleAssignmentSetAPIView.as_view()( - request - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(RoleAssignment.objects.all().count(), 2) - - new_as = RoleAssignment.objects.get(project=self.project, user=new_user) - self.assertEqual(new_as.role.pk, self.role_contributor.pk) - - -@override_settings(ENABLED_BACKEND_PLUGINS=['taskflow']) -class TestRoleAssignmentDeleteAPIView(TestTaskflowLocalAPIBase): - """Tests for the role assignment deletion API view""" - - def setUp(self): - super().setUp() - self.project = self._make_project( - 'TestProject', PROJECT_TYPE_PROJECT, None - ) - self.owner_as = self._make_assignment( - self.project, self.user, self.role_owner - ) - - def test_post(self): - """Test POST request for removing a role assignment""" - new_user = self.make_user('new_user') - self._make_assignment(self.project, new_user, self.role_guest) - self.assertEqual(RoleAssignment.objects.all().count(), 2) - request = self.req_factory.post( - reverse('projectroles:taskflow_role_delete'), - data={ - 'project_uuid': str(self.project.sodar_uuid), - 'user_uuid': str(new_user.sodar_uuid), - 'sodar_secret': settings.TASKFLOW_SODAR_SECRET, - }, - ) - response = views_taskflow.TaskflowRoleAssignmentDeleteAPIView.as_view()( - request - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(RoleAssignment.objects.all().count(), 1) - - def test_post_not_found(self): - """Test POST request for removing a non-existing role assignment""" - new_user = self.make_user('new_user') - self.assertEqual(RoleAssignment.objects.all().count(), 1) - request = self.req_factory.post( - reverse('projectroles:taskflow_role_delete'), - data={ - 'project_uuid': str(self.project.sodar_uuid), - 'user_uuid': str(new_user.sodar_uuid), - 'sodar_secret': settings.TASKFLOW_SODAR_SECRET, - }, - ) - response = views_taskflow.TaskflowRoleAssignmentDeleteAPIView.as_view()( - request - ) - self.assertEqual(response.status_code, 404) - self.assertEqual(RoleAssignment.objects.all().count(), 1) - - -class TestTaskflowAPIViewAccess(TestTaskflowLocalAPIBase): - """Tests for taskflow API view access""" - - def setUp(self): - super().setUp() - self.category = self._make_project( - 'TestCategory', PROJECT_TYPE_CATEGORY, None - ) - self.project = self._make_project( - 'TestProject', PROJECT_TYPE_PROJECT, self.category - ) - self.owner_as = self._make_assignment( - self.project, self.user, self.role_owner - ) - - @override_settings(ENABLED_BACKEND_PLUGINS=['taskflow']) - def test_access_invalid_token(self): - """Test access with an invalid token""" - urls = [ - reverse('projectroles:taskflow_project_get'), - reverse('projectroles:taskflow_project_update'), - reverse('projectroles:taskflow_role_get'), - reverse('projectroles:taskflow_role_set'), - reverse('projectroles:taskflow_role_delete'), - reverse('projectroles:taskflow_settings_get'), - reverse('projectroles:taskflow_settings_set'), - ] - for url in urls: - request = self.req_factory.post( - url, data={'sodar_secret': TASKFLOW_SECRET_INVALID} - ) - response = views_taskflow.TaskflowProjectGetAPIView.as_view()( - request - ) - self.assertEqual(response.status_code, 403) - - @override_settings(ENABLED_BACKEND_PLUGINS=['taskflow']) - def test_access_no_token(self): - """Test access with no token""" - urls = [ - reverse('projectroles:taskflow_project_get'), - reverse('projectroles:taskflow_project_update'), - reverse('projectroles:taskflow_role_get'), - reverse('projectroles:taskflow_role_set'), - reverse('projectroles:taskflow_role_delete'), - reverse('projectroles:taskflow_settings_get'), - reverse('projectroles:taskflow_settings_set'), - ] - for url in urls: - request = self.req_factory.post(url) - response = views_taskflow.TaskflowProjectGetAPIView.as_view()( - request - ) - self.assertEqual(response.status_code, 403) - - @override_settings(ENABLED_BACKEND_PLUGINS=[]) - def test_disable_api_views(self): - """Test to make sure API views are disabled without taskflow""" - urls = [ - reverse('projectroles:taskflow_project_get'), - reverse('projectroles:taskflow_project_update'), - reverse('projectroles:taskflow_role_get'), - reverse('projectroles:taskflow_role_set'), - reverse('projectroles:taskflow_role_delete'), - reverse('projectroles:taskflow_settings_get'), - reverse('projectroles:taskflow_settings_set'), - ] - for url in urls: - request = self.req_factory.post( - url, data={'sodar_secret': settings.TASKFLOW_SODAR_SECRET} - ) - response = views_taskflow.TaskflowProjectGetAPIView.as_view()( - request - ) - self.assertEqual(response.status_code, 403) - - -# Tests Requiring iRODS -------------------------------------------------------- - - -@skipIf(not TASKFLOW_ENABLED, TASKFLOW_SKIP_MSG) -class TestProjectCreateView(TestTaskflowBase): - """Tests for Project creation view with taskflow""" - - def test_create_project(self): - """Test Project creation with taskflow""" - - # Assert precondition - self.assertEqual(Project.objects.all().count(), 1) - - # Make project with owner in Taskflow and Django - self.project, self.owner_as = self._make_project_taskflow( - title='TestProject', - type=PROJECT_TYPE_PROJECT, - parent=self.category, - owner=self.user, - description='description', - ) - - # Assert Project state after creation - self.assertEqual(Project.objects.all().count(), 2) - project = Project.objects.all()[0] - self.assertIsNotNone(project) - - expected = { - 'id': project.pk, - 'title': 'TestProject', - 'type': PROJECT_TYPE_PROJECT, - 'parent': self.category.pk, - 'submit_status': SUBMIT_STATUS_OK, - 'description': 'description', - 'public_guest_access': False, - 'full_title': self.category.title + ' / TestProject', - 'has_public_children': False, - 'sodar_uuid': project.sodar_uuid, - } - - model_dict = model_to_dict(project) - model_dict.pop('readme', None) - self.assertEqual(model_dict, expected) - - # Assert owner role assignment - owner_as = RoleAssignment.objects.get( - project=project, role=self.role_owner - ) - - expected = { - 'id': owner_as.pk, - 'project': project.pk, - 'role': self.role_owner.pk, - 'user': self.user.pk, - 'sodar_uuid': owner_as.sodar_uuid, - } - - self.assertEqual(model_to_dict(owner_as), expected) - - -@skipIf(not TASKFLOW_ENABLED, TASKFLOW_SKIP_MSG) -class TestProjectUpdateView(TestTaskflowBase): - """Tests for Project updating view""" - - 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, - parent=self.category, - owner=self.user, - description='description', - ) - - def test_update_project(self): - """Test Project updating with taskflow""" - - # Assert precondition - self.assertEqual(Project.objects.all().count(), 2) - - request_data = model_to_dict(self.project) - request_data['title'] = 'updated title' - request_data['description'] = 'updated description' - request_data['owner'] = self.user.sodar_uuid # NOTE: Must add owner - request_data['readme'] = 'updated readme' - request_data['parent'] = str(self.category.sodar_uuid) - request_data['public_guest_access'] = True - request_data.update( - app_settings.get_all_settings(project=self.project, post_safe=True) - ) # Add default settings - request_data['sodar_url'] = self.get_sodar_url() - - with self.login(self.user): - response = self.client.post( - reverse( - 'projectroles:update', - kwargs={'project': self.project.sodar_uuid}, - ), - request_data, - ) - - # Assert Project state after update - self.assertEqual(Project.objects.all().count(), 2) - self.project.refresh_from_db() - - expected = { - 'id': self.project.pk, - 'title': 'updated title', - 'type': PROJECT_TYPE_PROJECT, - 'parent': self.category.pk, - 'submit_status': SUBMIT_STATUS_OK, - 'description': 'updated description', - 'public_guest_access': True, - 'full_title': self.category.title + ' / updated title', - 'has_public_children': False, - 'sodar_uuid': self.project.sodar_uuid, - } - - model_dict = model_to_dict(self.project) - model_dict.pop('readme', None) - self.assertEqual(model_dict, expected) - self.assertEqual(self.project.readme.raw, 'updated readme') - - # Assert redirect - with self.login(self.user): - self.assertRedirects( - response, - reverse( - 'projectroles:detail', - kwargs={'project': self.project.sodar_uuid}, - ), - ) - - -@skipIf(not TASKFLOW_ENABLED, TASKFLOW_SKIP_MSG) -class TestRoleAssignmentCreateView(TestTaskflowBase): - """Tests for RoleAssignment creation view""" - - 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, - parent=self.category, - owner=self.user, - description='description', - ) - - self.user_new = self.make_user('guest') - - def test_create_assignment(self): - """Test RoleAssignment creation with taskflow""" - # Assert precondition - self.assertEqual(RoleAssignment.objects.all().count(), 2) - - # Issue POST request - self.request_data.update( - { - 'project': self.project.sodar_uuid, - 'user': self.user_new.sodar_uuid, - 'role': self.role_guest.pk, - } - ) - - with self.login(self.user): - response = self.client.post( - reverse( - 'projectroles:role_create', - kwargs={'project': self.project.sodar_uuid}, - ), - self.request_data, - ) - - # Assert RoleAssignment state after creation - self.assertEqual(RoleAssignment.objects.all().count(), 3) - role_as = RoleAssignment.objects.get( - project=self.project, user=self.user_new - ) - self.assertIsNotNone(role_as) - - expected = { - 'id': role_as.pk, - 'project': self.project.pk, - 'user': self.user_new.pk, - 'role': self.role_guest.pk, - 'sodar_uuid': role_as.sodar_uuid, - } - - self.assertEqual(model_to_dict(role_as), expected) - - # Assert redirect - with self.login(self.user): - self.assertRedirects( - response, - reverse( - 'projectroles:roles', - kwargs={'project': self.project.sodar_uuid}, - ), - ) - - -@skipIf(not TASKFLOW_ENABLED, TASKFLOW_SKIP_MSG) -class TestRoleAssignmentUpdateView(TestTaskflowBase): - """Tests for RoleAssignment update view with taskflow""" - - 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, - parent=self.category, - owner=self.user, - description='description', - ) - - # Create guest user and role - self.user_new = self.make_user('newuser') - self.role_as = self._make_assignment_taskflow( - self.project, self.user_new, self.role_guest - ) - - def test_update_assignment(self): - """Test RoleAssignment updating with taskflow""" - - # Assert precondition - self.assertEqual(RoleAssignment.objects.all().count(), 3) - - self.request_data.update( - { - 'project': self.project.sodar_uuid, - 'user': self.user_new.sodar_uuid, - 'role': self.role_contributor.pk, - } - ) - - with self.login(self.user): - response = self.client.post( - reverse( - 'projectroles:role_update', - kwargs={'roleassignment': self.role_as.sodar_uuid}, - ), - self.request_data, - ) - - # Assert RoleAssignment state after update - self.assertEqual(RoleAssignment.objects.all().count(), 3) - role_as = RoleAssignment.objects.get( - project=self.project, user=self.user_new - ) - self.assertIsNotNone(role_as) - - expected = { - 'id': role_as.pk, - 'project': self.project.pk, - 'user': self.user_new.pk, - 'role': self.role_contributor.pk, - 'sodar_uuid': role_as.sodar_uuid, - } - - self.assertEqual(model_to_dict(role_as), expected) - - # Assert redirect - with self.login(self.user): - self.assertRedirects( - response, - reverse( - 'projectroles:roles', - kwargs={'project': self.project.sodar_uuid}, - ), - ) - - -@skipIf(not TASKFLOW_ENABLED, TASKFLOW_SKIP_MSG) -class TestRoleAssignmentOwnerTransferView(TestTaskflowBase): - """Tests for ownership transfer view with taskflow""" - - 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, - parent=self.category, - owner=self.user, - description='description', - ) - - # Create guest user and role - self.user_new = self.make_user('newuser') - self.role_as = self._make_assignment( - self.project, self.user_new, self.role_guest - ) - - def test_transfer_owner(self): - """Test ownership transfer with taskflow""" - - # Assert precondition - self.assertEqual(RoleAssignment.objects.all().count(), 3) - - self.request_data.update( - { - 'project': self.project.sodar_uuid, - 'user': self.user_new.sodar_uuid, - 'role': self.role_contributor.pk, - } - ) - - with self.login(self.user): - self.client.post( - reverse( - 'projectroles:role_owner_transfer', - kwargs={'project': self.project.sodar_uuid}, - ), - data={ - 'project': self.project.sodar_uuid, - 'old_owner_role': self.role_guest.pk, - 'new_owner': self.user_new.sodar_uuid, - }, - ) - - # Assert RoleAssignment state after update - self.assertEqual(RoleAssignment.objects.all().count(), 3) - role_as = RoleAssignment.objects.get( - project=self.project, user=self.user_new - ) - self.assertEqual(role_as.role, self.role_owner) - # TODO: Test resulting users in iRODS once we do issue #387 - - -@skipIf(not TASKFLOW_ENABLED, TASKFLOW_SKIP_MSG) -class TestRoleAssignmentDeleteView(TestTaskflowBase): - """Tests for RoleAssignment delete view""" - - 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, - parent=self.category, - owner=self.user, - description='description', - ) - - # Create guest user and role - self.user_new = self.make_user('newuser') - self.role_as = self._make_assignment_taskflow( - self.project, self.user_new, self.role_guest - ) - - def test_delete_assignment(self): - """Test RoleAssignment deleting with taskflow""" - - # Assert precondition - self.assertEqual(RoleAssignment.objects.all().count(), 3) - - with self.login(self.user): - response = self.client.post( - reverse( - 'projectroles:role_delete', - kwargs={'roleassignment': self.role_as.sodar_uuid}, - ), - self.request_data, - ) - - # Assert RoleAssignment state after update - self.assertEqual(RoleAssignment.objects.all().count(), 2) - - # Assert redirect - with self.login(self.user): - self.assertRedirects( - response, - reverse( - 'projectroles:roles', - kwargs={'project': self.project.sodar_uuid}, - ), - ) - - -@skipIf(not TASKFLOW_ENABLED, TASKFLOW_SKIP_MSG) -class TestProjectInviteAcceptView(ProjectInviteMixin, TestTaskflowBase): - """Tests for ProjectInvite accepting view with taskflow""" - - 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, - parent=self.category, - owner=self.user, - description='description', - ) - - # Create guest user and role - self.user_new = self.make_user('newuser') - - @override_settings(PROJECTROLES_ALLOW_LOCAL_USERS=True) - @override_settings(AUTH_LDAP_USERNAME_DOMAIN='EXAMPLE') - @override_settings(AUTH_LDAP_DOMAIN_PRINTABLE='EXAMPLE') - @override_settings(ENABLE_LDAP=True) - def test_accept_invite_ldap(self): - """Test LDAP user accepting an invite with taskflow""" - - # Init invite - invite = self._make_invite( - email=INVITE_EMAIL, - project=self.project, - role=self.role_contributor, - issuer=self.user, - message='', - ) - - # Assert preconditions - self.assertEqual(ProjectInvite.objects.filter(active=True).count(), 1) - - self.assertEqual( - RoleAssignment.objects.filter( - project=self.project, - user=self.user_new, - role=self.role_contributor, - ).count(), - 0, - ) - - with self.login(self.user_new): - response = self.client.get( - reverse( - 'projectroles:invite_accept', - kwargs={'secret': invite.secret}, - ), - self.request_data, - follow=True, - ) - - self.assertListEqual( - response.redirect_chain, - [ - ( - reverse( - 'projectroles:invite_process_ldap', - kwargs={'secret': invite.secret}, - ), - 302, - ), - ( - reverse('home'), - 302, - ), - ], - ) - - @override_settings(PROJECTROLES_ALLOW_LOCAL_USERS=True) - @override_settings(AUTH_LDAP_USERNAME_DOMAIN='EXAMPLE') - @override_settings(AUTH_LDAP_DOMAIN_PRINTABLE='EXAMPLE') - @override_settings(ENABLE_LDAP=True) - def test_accept_invite_ldap_category(self): - """Test LDAP user accepting an invite with taskflow for a category""" - - # Init invite - invite = self._make_invite( - email=INVITE_EMAIL, - project=self.category, - role=self.role_contributor, - issuer=self.user, - message='', - ) - - # Assert preconditions - self.assertEqual(ProjectInvite.objects.filter(active=True).count(), 1) - - self.assertEqual( - RoleAssignment.objects.filter( - project=self.category, - user=self.user_new, - role=self.role_contributor, - ).count(), - 0, - ) - - with self.login(self.user_new): - response = self.client.get( - reverse( - 'projectroles:invite_accept', - kwargs={'secret': invite.secret}, - ), - self.request_data, - follow=True, - ) - - self.assertListEqual( - response.redirect_chain, - [ - ( - reverse( - 'projectroles:invite_process_ldap', - kwargs={'secret': invite.secret}, - ), - 302, - ), - ( - reverse( - 'projectroles:detail', - kwargs={'project': self.category.sodar_uuid}, - ), - 302, - ), - ], - ) - - def test_accept_invite_local(self): - """Test local user accepting an invite with taskflow""" - - # Init invite - invite = self._make_invite( - email=INVITE_EMAIL, - project=self.project, - role=self.role_contributor, - issuer=self.user, - message='', - ) - - # Assert preconditions - self.assertEqual(ProjectInvite.objects.filter(active=True).count(), 1) - - with self.login(self.user_new): - response = self.client.get( - reverse( - 'projectroles:invite_accept', - kwargs={'secret': invite.secret}, - ), - self.request_data, - follow=True, - ) - - self.assertListEqual( - response.redirect_chain, - [ - ( - reverse( - 'projectroles:invite_process_local', - kwargs={'secret': invite.secret}, - ), - 302, - ), - ( - reverse('home'), - 302, - ), - ], - ) - - # Assert postconditions - self.assertEqual( - ProjectInvite.objects.filter(active=True).count(), 1 - ) - - def test_accept_invite_local_category(self): - """Test local user accepting an invite with taskflow for a category""" - - # Init invite - invite = self._make_invite( - email=INVITE_EMAIL, - project=self.category, - role=self.role_contributor, - issuer=self.user, - message='', - ) - - # Assert preconditions - self.assertEqual(ProjectInvite.objects.filter(active=True).count(), 1) - - self.assertEqual( - RoleAssignment.objects.filter( - project=self.category, - user=self.user_new, - role=self.role_contributor, - ).count(), - 0, - ) - - with self.login(self.user_new): - response = self.client.get( - reverse( - 'projectroles:invite_accept', - kwargs={'secret': invite.secret}, - ), - self.request_data, - follow=True, - ) - - self.assertListEqual( - response.redirect_chain, - [ - ( - reverse( - 'projectroles:invite_process_local', - kwargs={'secret': invite.secret}, - ), - 302, - ), - ( - reverse('home'), - 302, - ), - ], - ) - - # Assert postconditions - self.assertEqual( - ProjectInvite.objects.filter(active=True).count(), 1 - ) diff --git a/projectroles/urls.py b/projectroles/urls.py index 95eadc3d..48ef2958 100644 --- a/projectroles/urls.py +++ b/projectroles/urls.py @@ -1,6 +1,6 @@ from django.conf.urls import url -from projectroles import views, views_ajax, views_api, views_taskflow +from projectroles import views, views_ajax, views_api app_name = 'projectroles' @@ -255,43 +255,4 @@ ), ] -# Taskflow API views -urls_taskflow = [ - url( - regex=r'^taskflow/get$', - view=views_taskflow.TaskflowProjectGetAPIView.as_view(), - name='taskflow_project_get', - ), - url( - regex=r'^taskflow/update$', - view=views_taskflow.TaskflowProjectUpdateAPIView.as_view(), - name='taskflow_project_update', - ), - url( - regex=r'^taskflow/role/get$', - view=views_taskflow.TaskflowRoleAssignmentGetAPIView.as_view(), - name='taskflow_role_get', - ), - url( - regex=r'^taskflow/role/set$', - view=views_taskflow.TaskflowRoleAssignmentSetAPIView.as_view(), - name='taskflow_role_set', - ), - url( - regex=r'^taskflow/role/delete$', - view=views_taskflow.TaskflowRoleAssignmentDeleteAPIView.as_view(), - name='taskflow_role_delete', - ), - url( - regex=r'^taskflow/settings/get$', - view=views_taskflow.TaskflowProjectSettingsGetAPIView.as_view(), - name='taskflow_settings_get', - ), - url( - regex=r'^taskflow/settings/set$', - view=views_taskflow.TaskflowProjectSettingsSetAPIView.as_view(), - name='taskflow_settings_set', - ), -] - -urlpatterns = urls_ui + urls_ajax + urls_api + urls_taskflow +urlpatterns = urls_ui + urls_ajax + urls_api diff --git a/projectroles/views.py b/projectroles/views.py index 66b79ce1..6eafd644 100644 --- a/projectroles/views.py +++ b/projectroles/views.py @@ -3,7 +3,6 @@ import json import logging import re -import requests import ssl import urllib.request from ipaddress import ip_address, ip_network @@ -77,9 +76,6 @@ PROJECT_ROLE_GUEST = SODAR_CONSTANTS['PROJECT_ROLE_GUEST'] SUBMIT_STATUS_OK = SODAR_CONSTANTS['SUBMIT_STATUS_OK'] SUBMIT_STATUS_PENDING = SODAR_CONSTANTS['SUBMIT_STATUS_PENDING'] -SUBMIT_STATUS_PENDING_TASKFLOW = SODAR_CONSTANTS[ - 'SUBMIT_STATUS_PENDING_TASKFLOW' -] SITE_MODE_TARGET = SODAR_CONSTANTS['SITE_MODE_TARGET'] SITE_MODE_SOURCE = SODAR_CONSTANTS['SITE_MODE_SOURCE'] SITE_MODE_PEER = SODAR_CONSTANTS['SITE_MODE_PEER'] @@ -88,6 +84,8 @@ REMOTE_LEVEL_READ_INFO = SODAR_CONSTANTS['REMOTE_LEVEL_READ_INFO'] REMOTE_LEVEL_READ_ROLES = SODAR_CONSTANTS['REMOTE_LEVEL_READ_ROLES'] APP_SETTING_SCOPE_PROJECT = SODAR_CONSTANTS['APP_SETTING_SCOPE_PROJECT'] +PROJECT_ACTION_CREATE = SODAR_CONSTANTS['PROJECT_ACTION_CREATE'] +PROJECT_ACTION_UPDATE = SODAR_CONSTANTS['PROJECT_ACTION_UPDATE'] # Local constants APP_NAME = 'projectroles' @@ -449,6 +447,18 @@ def get_context_data(self, *args, **kwargs): return context +class CurrentUserFormMixin: + """Mixin for passing current user to form as current_user""" + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs.update({'current_user': self.request.user}) + return kwargs + + +# Base Project Views ----------------------------------------------------------- + + class ProjectListContextMixin: """Mixin for adding context data for displaying the project list.""" @@ -491,18 +501,6 @@ def get_context_data(self, *args, **kwargs): return context -class CurrentUserFormMixin: - """Mixin for passing current user to form as current_user""" - - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs.update({'current_user': self.request.user}) - return kwargs - - -# Base Project Views ----------------------------------------------------------- - - class HomeView( LoginRequiredMixin, PluginContextMixin, @@ -759,7 +757,66 @@ class ProjectAdvancedSearchView( # Project Editing Views -------------------------------------------------------- -class ProjectModifyMixin: +class ProjectModifyPluginViewMixin: + """Helpers for project modify API""" + + @classmethod + def call_project_modify_api(cls, method_name, revert_name, method_args): + """ + Call project modify API for a specific method and parameters. This + method Will run reversion methods for all plugins if execution for one + fails. + + :param method_name: Name of execution method in plugin (string) + :param revert_name: Name of revert method in plugin (string or None) + :param method_args: Arguments to be passed for the methods (list) + :raise: Exception if execution for a plugin fails. + """ + modify_api_apps = getattr(settings, 'PROJECTROLES_MODIFY_API_APPS', []) + app_plugins = [] + if modify_api_apps: + for a in modify_api_apps: + plugin = get_app_plugin(a) + if not plugin: + msg = 'Unable to find active plugin "{}"'.format(a) + logger.error(msg) + raise ImproperlyConfigured(msg) + app_plugins.append(plugin) + else: + app_plugins = get_active_plugins('backend') + get_active_plugins( + 'project_app' + ) + + called_plugins = [] + for p in app_plugins: + if not hasattr(p, method_name): + continue # Only there if using ProjectModifyPluginAPIMixin + logger.debug( + 'Calling {}() in plugin "{}"'.format(method_name, p.name) + ) + try: + getattr(p, method_name)(*method_args) + called_plugins.append(p) + except Exception as ex: + logger.error( + 'Exception in {}() for plugin "{}": {}'.format( + method_name, p.name, ex + ) + ) + if revert_name: + for cp in called_plugins: + try: + cp.getattr(revert_name)(*method_args) + except Exception as ex_revert: + logger.error( + 'Exception in {}() for plugin "{}": {}'.format( + method_name, cp.name, ex_revert + ) + ) + raise ex + + +class ProjectModifyMixin(ProjectModifyPluginViewMixin): """Mixin for Project creation/updating in UI and API views""" @staticmethod @@ -800,7 +857,6 @@ def _get_app_settings(data, instance): for s_key, s_val in p_settings.items(): s_name = 'settings.{}.{}'.format(name, s_key) s_data = data.get(s_name) - if s_data is None and not instance: s_data = app_settings.get_default_setting(name, s_key) @@ -817,29 +873,21 @@ def _get_app_settings(data, instance): def _get_project_update_data(old_data, project, owner, project_settings): extra_data = {} upd_fields = [] - if old_data['title'] != project.title: extra_data['title'] = project.title upd_fields.append('title') - if old_data['parent'] != project.parent: - extra_data['parent'] = ( - str(project.parent.sodar_uuid) if project.parent else None - ) + extra_data['parent'] = project.parent upd_fields.append('parent') - if old_data['owner'] != owner: extra_data['owner'] = owner.username upd_fields.append('owner') - if old_data['description'] != project.description: extra_data['description'] = project.description upd_fields.append('description') - if old_data['readme'] != project.readme.raw: extra_data['readme'] = project.readme.raw upd_fields.append('readme') - if old_data['public_guest_access'] != project.public_guest_access: extra_data['public_guest_access'] = project.public_guest_access upd_fields.append('public_guest_access') @@ -867,7 +915,7 @@ def _create_timeline_event( return None type_str = project.type.capitalize() - if action == 'create': + if action == PROJECT_ACTION_CREATE: tl_desc = 'create ' + type_str.lower() + ' with {owner} as owner' extra_data = { 'title': project.title, @@ -889,6 +937,8 @@ def _create_timeline_event( extra_data, upd_fields = cls._get_project_update_data( old_data, project, owner, project_settings ) + if extra_data.get('parent'): # Convert parent object into UUID + extra_data['parent'] = str(extra_data['parent'].sodar_uuid) if len(upd_fields) > 0: tl_desc += ' (' + ', '.join(x for x in upd_fields) + ')' @@ -896,120 +946,35 @@ def _create_timeline_event( project=project, app_name=APP_NAME, user=request.user, - event_name='project_{}'.format(action), + event_name='project_{}'.format(action.lower()), description=tl_desc, extra_data=extra_data, ) - if action == 'create': + if action == PROJECT_ACTION_CREATE: tl_event.add_object(owner, 'owner', owner.username) return tl_event @classmethod - def _submit_with_taskflow( - cls, - project, - owner, - project_settings, - action, - request, - old_parent=None, - tl_event=None, - ): - """ - Submit project modification flow via SODAR Taskflow. - - :param project: Project object - :param owner: User object of project owner - :param project_settings: Dict - :param action: "create" or "update" (string) - :param request: Request object for triggering the update - :param old_parent: Project object of old parent if it was changed - :param tl_event: Timeline ProjectEvent object or None - :raise: ConnectionError if unable to connect to SODAR Taskflow - :raise: FlowSubmitException if SODAR Taskflow submission fails - """ - taskflow = get_backend_api('taskflow') - if tl_event: - tl_event.set_status('SUBMIT') - flow_data = { - 'project_title': project.title, - 'project_description': project.description, - 'parent_uuid': str(project.parent.sodar_uuid) - if project.parent - else '', - 'public_guest_access': project.public_guest_access, - 'owner_username': owner.username, - 'owner_uuid': str(owner.sodar_uuid), - 'owner_role_pk': Role.objects.get(name=PROJECT_ROLE_OWNER).pk, - 'settings': project_settings, - } - - if action == 'update': - old_owner = project.get_owner().user - flow_data['old_owner_uuid'] = str(old_owner.sodar_uuid) - flow_data['old_owner_username'] = old_owner.username - flow_data['project_readme'] = project.readme.raw - if old_parent: - # Get inherited owners for project and its children to add - new_roles = taskflow.get_inherited_users(project) - flow_data['roles_add'] = new_roles - new_users = set([r['username'] for r in new_roles]) - - # Get old inherited owners from previous parent to remove - old_roles = taskflow.get_inherited_users(old_parent) - flow_data['roles_delete'] = [ - r for r in old_roles if r['username'] not in new_users - ] - else: # Create - flow_data['roles_add'] = [ - { - 'project_uuid': str(project.sodar_uuid), - 'username': a.user.username, - } - for a in project.get_owners(inherited_only=True) - ] - - try: - taskflow.submit( - project_uuid=str(project.sodar_uuid), - flow_name='project_{}'.format(action), - flow_data=flow_data, - request=request, - ) - except ( - requests.exceptions.ConnectionError, - taskflow.FlowSubmitException, - ) as ex: - # NOTE: No need to update status as project will be deleted - if action == 'create': - project.delete() - elif tl_event: # Update - tl_event.set_status('FAILED', str(ex)) - raise ex - - @classmethod - def _handle_local_save(cls, project, owner, project_settings): - """ - Handle local saving of project data if SODAR Taskflow is not - enabled. - """ - # Modify owner role if it does exist + def _update_owner(cls, project, owner): + """Create or update project owner""" try: - assignment = RoleAssignment.objects.get( + role_as = RoleAssignment.objects.get( project=project, role__name=PROJECT_ROLE_OWNER ) - assignment.user = owner - assignment.save() - # Else create a new one + role_as.user = owner + role_as.save() except RoleAssignment.DoesNotExist: - assignment = RoleAssignment( + role_as = RoleAssignment( project=project, user=owner, role=Role.objects.get(name=PROJECT_ROLE_OWNER), ) - assignment.save() + role_as.save() + return role_as - # Modify settings + @classmethod + def _update_settings(cls, project, project_settings): + """Update project settings""" for k, v in project_settings.items(): app_settings.set_app_setting( app_name=k.split('.')[1], @@ -1032,7 +997,7 @@ def _notify_users( owner_as = RoleAssignment.objects.get_assignment(owner, project) # Owner change notification if request.user != owner and ( - action == 'create' or old_data['owner'] != owner + action == PROJECT_ACTION_CREATE or old_data['owner'] != owner ): if app_alerts: app_alerts.add_alert( @@ -1050,7 +1015,7 @@ def _notify_users( ) if SEND_EMAIL: email.send_role_change_mail( - action, + action.lower(), project, owner, owner_as.role, @@ -1063,7 +1028,7 @@ def _notify_users( and project.parent.get_owner().user != request.user ): parent_owner = project.parent.get_owner().user - if action == 'create' and request.user != parent_owner: + if action == PROJECT_ACTION_CREATE and request.user != parent_owner: if app_alerts: app_alerts.add_alert( app_name=APP_NAME, @@ -1104,21 +1069,18 @@ def _notify_users( if SEND_EMAIL: email.send_project_move_mail(project, request) + @transaction.atomic def modify_project(self, data, request, instance=None): """ - Create or update a Project, either locally or using the SODAR Taskflow. - This method should be called either in form_valid() in a Django form - view or save() in a DRF serializer. + Create or update a Project. This method should be called either in + form_valid() in a Django form view or save() in a DRF serializer. :param data: Cleaned data from a form or serializer :param request: Request initiating the action :param instance: Existing Project object or None - :raise: ConnectionError if unable to connect to SODAR Taskflow - :raise: FlowSubmitException if SODAR Taskflow submission fails :return: Created or updated Project object """ - taskflow = get_backend_api('taskflow') - action = 'update' if instance else 'create' + action = PROJECT_ACTION_UPDATE if instance else PROJECT_ACTION_CREATE old_data = {} old_project = None @@ -1150,40 +1112,22 @@ def modify_project(self, data, request, instance=None): parent=data.get('parent'), public_guest_access=data.get('public_guest_access') or False, ) - - use_taskflow = ( - True - if taskflow and data.get('type') == PROJECT_TYPE_PROJECT - else False - ) - - if action == 'create': - project.submit_status = ( - SUBMIT_STATUS_PENDING_TASKFLOW - if use_taskflow - else SUBMIT_STATUS_PENDING - ) - # HACK to avoid db error when running tests with DRF serializer - # See: https://stackoverflow.com/a/60331668 - with transaction.atomic(): - project.save() # Always save locally if creating (to get UUID) - else: - project.submit_status = SUBMIT_STATUS_OK - - # Save project with changes if updating without taskflow - if action == 'update' and not use_taskflow: project.save() owner = data.get('owner') if not owner and old_project: # In case of a PATCH request owner = old_project.get_owner().user + # Get settings project_settings = self._get_app_settings(data, instance) + old_settings = None + if action == PROJECT_ACTION_UPDATE: + old_settings = json.loads(json.dumps(project_settings)) # Copy + # Create timeline event tl_event = self._create_timeline_event( project, action, owner, old_data, project_settings, request ) - # Get old parent for project moving old_parent = ( old_project.parent @@ -1193,38 +1137,24 @@ def modify_project(self, data, request, instance=None): else None ) - # Submit with Taskflow if enabled - # NOTE: may raise an exception which needs to be handled in caller - if use_taskflow: - self._submit_with_taskflow( - project, - owner, - project_settings, - action, - request, - old_parent, - tl_event, - ) - # Local save without Taskflow - else: - self._handle_local_save(project, owner, project_settings) + # Update owner and settings + self._update_owner(project, owner) + self._update_settings(project, project_settings) # Post submit/save - if action == 'create' and project.submit_status != SUBMIT_STATUS_OK: - project.submit_status = SUBMIT_STATUS_OK - project.save() + project.submit_status = SUBMIT_STATUS_OK + project.save() - # Call for additional actions for project update in plugins - # NOTE: This is a WIP feature to be altered/expanded in a later release - app_plugins = get_active_plugins(plugin_type='project_app') - for p in app_plugins: - try: - p.handle_project_update(project, old_data) - except Exception as ex: - logger.error( - 'Exception when calling handle_project_update() for app ' - 'plugin "{}": {}'.format(p.name, ex) - ) + # Call for additional actions for project creation/update in plugins + if getattr(settings, 'PROJECTROLES_ENABLE_MODIFY_API', False): + args = [project, action, project_settings] + if action == PROJECT_ACTION_UPDATE: + args.append(old_data) + args.append(old_settings) + args.append(request) + self.call_project_modify_api( + 'perform_project_modify', 'revert_project_modify', args + ) # If public access was updated, update has_public_children for parents if ( @@ -1254,7 +1184,7 @@ class ProjectModifyFormMixin(ProjectModifyMixin): def form_valid(self, form): """Handle project updating if form is valid""" instance = form.instance if form.instance.pk else None - action = 'update' if instance else 'create' + action = PROJECT_ACTION_UPDATE if instance else PROJECT_ACTION_CREATE if instance and instance.parent: redirect_url = reverse( @@ -1273,7 +1203,7 @@ def form_valid(self, form): messages.success( self.request, '{} {}d.'.format( - get_display_name(project.type, title=True), action + get_display_name(project.type, title=True), action.lower() ), ) redirect_url = reverse( @@ -1283,7 +1213,7 @@ def form_valid(self, form): messages.error( self.request, 'Unable to {} {}: {}'.format( - action, form.cleaned_data['type'].lower(), ex + action.lower(), form.cleaned_data['type'].lower(), ex ), ) if settings.DEBUG: @@ -1435,96 +1365,75 @@ def get_context_data(self, *args, **kwargs): return context -class RoleAssignmentModifyMixin: +class RoleAssignmentModifyMixin(ProjectModifyPluginViewMixin): """Mixin for RoleAssignment creation/updating in UI and API views""" - def modify_assignment( - self, data, request, project, instance=None, sodar_url=None - ): + @transaction.atomic + def modify_assignment(self, data, request, project, instance=None): """ - Create or update a RoleAssignment, either locally or using the SODAR - Taskflow. This method should be called either in form_valid() in a - Django form view or save() in a DRF serializer. + Create or update a RoleAssignment. This method should be called either + in form_valid() in a Django form view or save() in a DRF serializer. + The method calls related ProjectModifyPluginAPIMixin methods if enabled + in your plugin. :param data: Cleaned data from a form or serializer :param request: Request initiating the action :param project: Project object :param instance: Existing RoleAssignment object or None - :param sodar_url: SODAR callback URL for taskflow (string, optional) - :raise: ConnectionError if unable to connect to SODAR Taskflow - :raise: FlowSubmitException if SODAR Taskflow submission fails :return: Created or updated RoleAssignment object """ - timeline = get_backend_api('timeline_backend') - taskflow = get_backend_api('taskflow') app_alerts = get_backend_api('appalerts_backend') - action = 'update' if instance else 'create' + timeline = get_backend_api('timeline_backend') tl_event = None + action = PROJECT_ACTION_UPDATE if instance else PROJECT_ACTION_CREATE user = data.get('user') role = data.get('role') - use_taskflow = taskflow.use_taskflow(project) if taskflow else False # Init Timeline event if timeline: tl_desc = '{} role {}"{}" for {{{}}}'.format( - action, 'to ' if action == 'update' else '', role.name, 'user' + action.lower(), + 'to ' if action == PROJECT_ACTION_UPDATE else '', + role.name, + 'user', ) tl_event = timeline.add_event( project=project, app_name=APP_NAME, user=request.user, - event_name='role_{}'.format(action), + event_name='role_{}'.format(action.lower()), description=tl_desc, ) tl_event.add_object(user, 'user', user.username) - # Submit with taskflow - if use_taskflow: - if tl_event: - tl_event.set_status('SUBMIT') - flow_data = { - 'username': user.username, - 'user_uuid': str(user.sodar_uuid), - 'role_pk': role.pk, - } - - try: - taskflow.submit( - project_uuid=project.sodar_uuid, - flow_name='role_update', - flow_data=flow_data, - request=request, - sodar_url=sodar_url, - ) - except taskflow.FlowSubmitException as ex: - if tl_event: - tl_event.set_status('FAILED', str(ex)) - raise ex - - # Get object - role_as = RoleAssignment.objects.get(project=project, user=user) - - # Local save without Taskflow - elif action == 'create': + if action == PROJECT_ACTION_CREATE: role_as = RoleAssignment(project=project, user=user, role=role) - + old_role = None else: role_as = RoleAssignment.objects.get(project=project, user=user) + old_role = role_as.role role_as.role = role - role_as.save() + + # Call for additional actions for role creation/update in plugins + if getattr(settings, 'PROJECTROLES_ENABLE_MODIFY_API', False): + args = [role_as, action, old_role, request] + self.call_project_modify_api( + 'perform_role_modify', 'revert_role_modify', args + ) + if tl_event: tl_event.set_status('OK') if request.user != user: if app_alerts: - if action == 'create': + if action == PROJECT_ACTION_CREATE: alert_msg = ALERT_MSG_ROLE_CREATE else: # Update alert_msg = ALERT_MSG_ROLE_UPDATE app_alerts.add_alert( app_name=APP_NAME, - alert_name='role_' + action, + alert_name='role_' + action.lower(), user=user, message=alert_msg.format( project=project.title, role=role.name @@ -1537,7 +1446,7 @@ def modify_assignment( ) if SEND_EMAIL: email.send_role_change_mail( - action, project, user, role, request + action.lower(), project, user, role, request ) return role_as @@ -1603,18 +1512,16 @@ def form_valid(self, form): ) -class RoleAssignmentDeleteMixin: +class RoleAssignmentDeleteMixin(ProjectModifyPluginViewMixin): """Mixin for RoleAssignment deletion/destroying in UI and API views""" def delete_assignment(self, request, instance): - timeline = get_backend_api('timeline_backend') - taskflow = get_backend_api('taskflow') app_alerts = get_backend_api('appalerts_backend') + timeline = get_backend_api('timeline_backend') tl_event = None project = instance.project user = instance.user role = instance.role - use_taskflow = taskflow.use_taskflow(project) if taskflow else False # Init Timeline event if timeline: @@ -1629,31 +1536,13 @@ def delete_assignment(self, request, instance): ) tl_event.add_object(user, 'user', user.username) - # Submit with taskflow - if use_taskflow: - if tl_event: - tl_event.set_status('SUBMIT') - flow_data = { - 'username': user.username, - 'user_uuid': str(user.sodar_uuid), - 'role_pk': role.pk, - } - try: - taskflow.submit( - project_uuid=project.sodar_uuid, - flow_name='role_delete', - flow_data=flow_data, - request=request, - ) - instance = None - except taskflow.FlowSubmitException as ex: - if tl_event: - tl_event.set_status('FAILED', str(ex)) - raise ex - # Local save without Taskflow - else: - instance.delete() - + # Call the project plugin modify API for additional actions + if getattr(settings, 'PROJECTROLES_ENABLE_MODIFY_API', False): + self.call_project_modify_api( + 'perform_role_delete', 'revert_role_delete', [instance, request] + ) + # Delete object itself + instance.delete() # Remove project star from user if it exists remove_tag(project=project, user=user) @@ -1770,7 +1659,7 @@ def post(self, *args, **kwargs): ) -class RoleAssignmentOwnerTransferMixin: +class RoleAssignmentOwnerTransferMixin(ProjectModifyPluginViewMixin): """Mixin for owner RoleAssignment transfer in UI and API views""" def _create_timeline_event(self, old_owner, new_owner, project): @@ -1797,32 +1686,13 @@ def _create_timeline_event(self, old_owner, new_owner, project): tl_event.add_object(new_owner, 'new_owner', new_owner.username) return tl_event + @transaction.atomic def _handle_transfer( self, project, old_owner_as, new_owner, old_owner_role ): - taskflow = get_backend_api('taskflow') - - # Handle inherited owner roles for categories if taskflow is enabled - if taskflow and project.type == PROJECT_TYPE_CATEGORY: - flow_data = { - 'roles_add': taskflow.get_inherited_roles(project, new_owner), - 'roles_delete': taskflow.get_inherited_roles( - project, old_owner_as.user - ), - } - # Submit taskflow (Requires SODAR Taskflow v0.4.0+) - # NOTE: Can raise exception - taskflow.submit( - project_uuid=None, # Batch flow for multiple projects - flow_name='role_update_irods_batch', - flow_data=flow_data, - request=self.request, - ) - - # If taskflow submission was successful / skipped, update database + # Update roles old_owner_as.role = old_owner_role old_owner_as.save() - role_as = RoleAssignment.objects.get_assignment(new_owner, project) role_owner = Role.objects.get(name=PROJECT_ROLE_OWNER) @@ -1841,6 +1711,18 @@ def _handle_transfer( 'New owner must have direct or inherited role in project' ) + # Call for additional actions for role creation/update in plugins + if getattr(settings, 'PROJECTROLES_ENABLE_MODIFY_API', False): + args = [ + project, + new_owner, + old_owner_as.user, + old_owner_role, + self.request, + ] + self.call_project_modify_api( + 'perform_owner_transfer', 'revert_owner_transfer', args + ) return True def transfer_owner(self, project, new_owner, old_owner_as, old_owner_role): @@ -2137,7 +2019,7 @@ def form_valid(self, form): ) -class ProjectInviteProcessMixin: +class ProjectInviteProcessMixin(ProjectModifyPluginViewMixin): """Mixin for accepting and processing project invites""" @classmethod @@ -2156,7 +2038,7 @@ def get_invite_type(cls, invite): return 'error' @classmethod - def revoke_failed_invite( + def revoke_invite( cls, invite, user=None, failed=True, fail_desc='', timeline=None ): """Set invite.active to False and save the invite""" @@ -2199,7 +2081,7 @@ def user_role_exists(self, invite, user, timeline=None): ) ), ) - self.revoke_failed_invite( + self.revoke_invite( invite, user, failed=True, @@ -2226,15 +2108,16 @@ def is_invite_expired(self, invite, user=None): self.request, user_name=user.get_full_name() if user else invite.email, ) - self.revoke_failed_invite( + self.revoke_invite( invite, user, failed=True, fail_desc='Invite expired' ) return True return False + # TODO: Combine with RoleAssignmentModifyMixin.modify_assignment? + @transaction.atomic def create_assignment(self, invite, user, timeline=None): """Create role assignment for invited user""" - taskflow = get_backend_api('taskflow') app_alerts = get_backend_api('appalerts_backend') tl_event = None if timeline: @@ -2248,43 +2131,21 @@ def create_assignment(self, invite, user, timeline=None): ), ) - # Submit with taskflow (only for projects) - if taskflow and invite.project.type == PROJECT_TYPE_PROJECT: - if tl_event: - tl_event.set_status('SUBMIT') - - flow_data = { - 'username': user.username, - 'user_uuid': str(user.sodar_uuid), - 'role_pk': invite.role.pk, - } - try: - taskflow.submit( - project_uuid=str(invite.project.sodar_uuid), - flow_name='role_update', - flow_data=flow_data, - request=self.request, - ) - except taskflow.FlowSubmitException as ex: - if tl_event: - tl_event.set_status('FAILED', str(ex)) - messages.error(self.request, str(ex)) - return False - - # Get object - RoleAssignment.objects.get(project=invite.project, user=user) - tl_event.set_status('OK') - - # Local save without Taskflow - else: - role_as = RoleAssignment( - user=user, project=invite.project, role=invite.role + # Create the assignment + role_as = RoleAssignment( + user=user, project=invite.project, role=invite.role + ) + # Call for additional actions for role creation/update in plugins + if getattr(settings, 'PROJECTROLES_ENABLE_MODIFY_API', False): + args = [role_as, PROJECT_ACTION_CREATE, None, self.request] + self.call_project_modify_api( + 'perform_role_modify', 'revert_role_modify', args ) - role_as.save() - if tl_event: - tl_event.set_status('OK') + role_as.save() + if tl_event: + tl_event.set_status('OK') - # Notify the issuer by alert and email.. + # Notify the issuer by alert and email if app_alerts: app_alerts.add_alert( app_name=APP_NAME, @@ -2303,8 +2164,8 @@ def create_assignment(self, invite, user, timeline=None): if SEND_EMAIL: email.send_accept_note(invite, self.request, user) - # Deactivate the invite.. - self.revoke_failed_invite(invite, user, failed=False, timeline=timeline) + # Deactivate the invite + self.revoke_invite(invite, user, failed=False, timeline=timeline) # Finally, redirect user to the project front page messages.success( diff --git a/projectroles/views_api.py b/projectroles/views_api.py index ab214b6b..8a8e9b03 100644 --- a/projectroles/views_api.py +++ b/projectroles/views_api.py @@ -557,8 +557,7 @@ class RoleAssignmentDestroyAPIView( def perform_destroy(self, instance): """ - Override perform_destroy() to handle RoleAssignment deletion with or - without SODAR Taskflow. + Override perform_destroy() to handle RoleAssignment deletion. """ project = self.get_project() diff --git a/projectroles/views_taskflow.py b/projectroles/views_taskflow.py deleted file mode 100644 index ff1da11b..00000000 --- a/projectroles/views_taskflow.py +++ /dev/null @@ -1,225 +0,0 @@ -"""Taskflow API views for the projectroles app""" - -import json - -from django.conf import settings - -from rest_framework.authentication import BaseAuthentication -from rest_framework.exceptions import PermissionDenied -from rest_framework.permissions import BasePermission -from rest_framework.response import Response -from rest_framework.views import APIView - -from projectroles.models import Project, RoleAssignment, Role -from projectroles.views import SUBMIT_STATUS_OK, User, app_settings - - -class TaskflowAPIAuthentication(BaseAuthentication): - """Taskflow API authentication handling""" - - def authenticate(self, request): - taskflow_secret = None - - if request.method == 'POST' and 'sodar_secret' in request.POST: - taskflow_secret = request.POST['sodar_secret'] - - elif request.method == 'GET': - taskflow_secret = request.GET.get('sodar_secret', None) - - if ( - not hasattr(settings, 'TASKFLOW_SODAR_SECRET') - or taskflow_secret != settings.TASKFLOW_SODAR_SECRET - ): - raise PermissionDenied('Not authorized') - - -class TaskflowAPIPermission(BasePermission): - """Taskflow API permission handling""" - - def has_permission(self, request, view): - # Only allow accessing Taskflow API views if Taskflow is used - return True if 'taskflow' in settings.ENABLED_BACKEND_PLUGINS else False - - -class BaseTaskflowAPIView(APIView): - """Base Taskflow API view""" - - authentication_classes = [TaskflowAPIAuthentication] - permission_classes = [TaskflowAPIPermission] - - -class TaskflowProjectGetAPIView(BaseTaskflowAPIView): - """Taskflow API view for getting a project""" - - def post(self, request): - try: - project = Project.objects.get( - sodar_uuid=request.data['project_uuid'], - submit_status=SUBMIT_STATUS_OK, - ) - - except Project.DoesNotExist as ex: - return Response(str(ex), status=404) - - ret_data = { - 'project_uuid': str(project.sodar_uuid), - 'title': project.title, - 'description': project.description, - } - - return Response(ret_data, status=200) - - -class TaskflowProjectUpdateAPIView(BaseTaskflowAPIView): - """Taskflow API view for updating a project""" - - def post(self, request): - try: - project = Project.objects.get( - sodar_uuid=request.data['project_uuid'] - ) - parent = ( - Project.objects.get(sodar_uuid=request.data['parent_uuid']) - if request.data.get('parent_uuid') - else None - ) - project.parent = parent - project.title = request.data['title'] - project.description = request.data.get('description') or '' - project.readme.raw = request.data['readme'] - project.public_guest_access = ( - request.data.get('public_guest_access') or False - ) - project.save() - - except Project.DoesNotExist as ex: - return Response(str(ex), status=404) - - return Response('ok', status=200) - - -class TaskflowRoleAssignmentGetAPIView(BaseTaskflowAPIView): - """Taskflow API view for getting a role assignment for user and project""" - - def post(self, request): - try: - project = Project.objects.get( - sodar_uuid=request.data['project_uuid'] - ) - user = User.objects.get(sodar_uuid=request.data['user_uuid']) - - except (Project.DoesNotExist, User.DoesNotExist) as ex: - return Response(str(ex), status=404) - - try: - role_as = RoleAssignment.objects.get(project=project, user=user) - ret_data = { - 'assignment_uuid': str(role_as.sodar_uuid), - 'project_uuid': str(role_as.project.sodar_uuid), - 'user_uuid': str(role_as.user.sodar_uuid), - 'role_pk': role_as.role.pk, - 'role_name': role_as.role.name, - } - return Response(ret_data, status=200) - - except RoleAssignment.DoesNotExist as ex: - return Response(str(ex), status=404) - - -class TaskflowRoleAssignmentSetAPIView(BaseTaskflowAPIView): - """Taskflow API view for creating or updating a role assignment""" - - def post(self, request): - try: - project = Project.objects.get( - sodar_uuid=request.data['project_uuid'] - ) - user = User.objects.get(sodar_uuid=request.data['user_uuid']) - role = Role.objects.get(pk=request.data['role_pk']) - - except ( - Project.DoesNotExist, - User.DoesNotExist, - Role.DoesNotExist, - ) as ex: - return Response(str(ex), status=404) - - try: - role_as = RoleAssignment.objects.get(project=project, user=user) - role_as.role = role - role_as.save() - - except RoleAssignment.DoesNotExist: - role_as = RoleAssignment(project=project, user=user, role=role) - role_as.save() - - return Response('ok', status=200) - - -class TaskflowRoleAssignmentDeleteAPIView(BaseTaskflowAPIView): - """Taskflow API view for deleting a role assignment""" - - def post(self, request): - try: - project = Project.objects.get( - sodar_uuid=request.data['project_uuid'] - ) - user = User.objects.get(sodar_uuid=request.data['user_uuid']) - - except (Project.DoesNotExist, User.DoesNotExist) as ex: - return Response(str(ex), status=404) - - try: - role_as = RoleAssignment.objects.get(project=project, user=user) - role_as.delete() - - except RoleAssignment.DoesNotExist as ex: - return Response(str(ex), status=404) - - return Response('ok', status=200) - - -class TaskflowProjectSettingsGetAPIView(BaseTaskflowAPIView): - """Taskflow API view for getting project settings""" - - def post(self, request): - try: - project = Project.objects.get( - sodar_uuid=request.data['project_uuid'] - ) - - except Project.DoesNotExist as ex: - return Response(str(ex), status=404) - - project_settings = app_settings.get_all_settings(project) - - for k, v in project_settings.items(): - if isinstance(v, dict): - project_settings[k] = json.dumps(v) - - ret_data = { - 'project_uuid': project.sodar_uuid, - 'settings': project_settings, - } - - return Response(ret_data, status=200) - - -class TaskflowProjectSettingsSetAPIView(BaseTaskflowAPIView): - """Taskflow API view for updating project settings""" - - def post(self, request): - try: - project = Project.objects.get( - sodar_uuid=request.data['project_uuid'] - ) - - except Project.DoesNotExist as ex: - return Response(str(ex), status=404) - - for k, v in json.loads(request.data['settings']).items(): - app_settings.set_app_setting( - k.split('.')[1], k.split('.')[2], v, project - ) - - return Response('ok', status=200) diff --git a/setup.py b/setup.py index 7cf085ff..6ca0c7d5 100755 --- a/setup.py +++ b/setup.py @@ -67,7 +67,6 @@ def is_requirement(line): 'filesfolders', 'siteinfo', 'sodarcache', - 'taskflowbackend', 'timeline', 'tokens', 'userprofile', diff --git a/taskflowbackend/__init__.py b/taskflowbackend/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/taskflowbackend/api.py b/taskflowbackend/api.py deleted file mode 100644 index 44c2d157..00000000 --- a/taskflowbackend/api.py +++ /dev/null @@ -1,223 +0,0 @@ -"""SODAR Taskflow API for Django apps""" - -import logging -import requests -from uuid import UUID - -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured - -# Projectroles dependency -from projectroles.models import RoleAssignment, SODAR_CONSTANTS - - -logger = logging.getLogger(__name__) - -# SODAR constants -PROJECT_TYPE_PROJECT = SODAR_CONSTANTS['PROJECT_TYPE_PROJECT'] - -# Local constants -HEADERS = {'Content-Type': 'application/json'} - - -class TaskflowAPI: - """SODAR Taskflow API to be used by Django apps""" - - class FlowSubmitException(Exception): - """SODAR Taskflow submission exception""" - - pass - - class CleanupException(Exception): - """SODAR Taskflow cleanup exception""" - - pass - - def __init__(self): - self.taskflow_url = '{}:{}'.format( - getattr(settings, 'TASKFLOW_BACKEND_HOST', ''), - getattr(settings, 'TASKFLOW_BACKEND_PORT', ''), - ) - - def submit( - self, - project_uuid, - flow_name, - flow_data, - request=None, - targets=None, - request_mode='sync', - timeline_uuid=None, - force_fail=False, - sodar_url=None, - ): - """ - Submit taskflow for SODAR project data modification. - - :param project_uuid: UUID of the project (UUID object or string) - :param flow_name: Name of flow to be executed (string) - :param flow_data: Input data for flow execution (dict) - :param request: Request object (optional) - :param targets: Names of backends to sync with (list) - :param request_mode: "sync" or "async" - :param timeline_uuid: UUID of corresponding timeline event (optional) - :param force_fail: Make flow fail on purpose (boolean, default False) - :param sodar_url: URL of SODAR server (optional, for testing) - :return: Boolean - :raise: FlowSubmitException if submission fails - """ - if not targets: - targets = settings.TASKFLOW_TARGETS - url = self.taskflow_url + '/submit' - - # Format UUIDs in flow_data - for k, v in flow_data.items(): - if isinstance(v, UUID): - flow_data[k] = str(v) - - data = { - 'project_uuid': str(project_uuid), - 'flow_name': flow_name, - 'flow_data': flow_data, - 'request_mode': request_mode, - 'targets': targets, - 'force_fail': force_fail, - 'timeline_uuid': str(timeline_uuid), - 'sodar_secret': settings.TASKFLOW_SODAR_SECRET, - } - - # Add the "test_mode" parameter - data['test_mode'] = settings.TASKFLOW_TEST_MODE - - # HACK: Add overriding URL for test server - if sodar_url: - data['sodar_url'] = sodar_url - elif request: - if request.POST.get('sodar_url'): - data['sodar_url'] = request.POST['sodar_url'] - elif request.GET.get('sodar_url'): - data['sodar_url'] = request.GET['sodar_url'] - elif hasattr(request, 'data') and request.data.get('sodar_url'): - data['sodar_url'] = request.data['sodar_url'] - - logger.debug('Submit data: {}'.format(data)) - response = requests.post(url, json=data, headers=HEADERS) - - if response.status_code == 200 and bool(response.text) is True: - logger.debug('Submit OK') - return True - - else: - logger.error('Submit failed: {}'.format(response.text)) - raise self.FlowSubmitException( - self.get_error_msg(flow_name, response.text) - ) - - def use_taskflow(self, project): - """ - Check whether taskflow use is allowed with a project. - - :param project: Project object - :return: Boolean - """ - return True if project.type == PROJECT_TYPE_PROJECT else False - - def cleanup(self): - """ - Send a cleanup command to SODAR Taskflow. Only allowed in test mode. - - :return: Boolean - :raise: ImproperlyConfigured if TASKFLOW_TEST_MODE is not set True - :raise: CleanupException if SODAR Taskflow raises an error - """ - if not settings.TASKFLOW_TEST_MODE: - raise ImproperlyConfigured( - 'TASKFLOW_TEST_MODE not True, cleanup command not allowed' - ) - - url = self.taskflow_url + '/cleanup' - data = {'test_mode': settings.TASKFLOW_TEST_MODE} - - response = requests.post(url, json=data, headers=HEADERS) - - if response.status_code == 200: - logger.debug('Cleanup OK') - return True - - else: - logger.debug('Cleanup failed: {}'.format(response.text)) - raise self.CleanupException(response.text) - - def get_error_msg(self, flow_name, submit_info): - """ - Return a printable version of a SODAR Taskflow error message. - - :param flow_name: Name of submitted flow - :param submit_info: Returned information from SODAR Taskflow - :return: String - """ - return 'Taskflow "{}" failed! Reason: "{}"'.format( - flow_name, submit_info[:256] - ) - - @classmethod - def get_inherited_roles(cls, project, user, roles=None): - """ - Return list of inherited owner roles to be used in taskflow sync. - - :param project: Project object - :param user: User object - :pram roles: Previously collected roles (optional, list or None) - :return: List of dicts - """ - if roles is None: - roles = [] - - # TODO: Remove support for legacy roles in v0.9 (see #506) - if ( - project.type == PROJECT_TYPE_PROJECT - and not RoleAssignment.objects.filter(project=project, user=user) - ): - r = { - 'project_uuid': str(project.sodar_uuid), - 'username': user.username, - } - - if r not in roles: # Avoid unnecessary dupes - roles.append(r) - - for child in project.get_children(): - roles = cls.get_inherited_roles(child, user, roles) - - return roles - - @classmethod - def get_inherited_users(cls, project, roles=None): - """ - Return list of all inherited users within a project and its children, to - be used in taskflow sync. - - :param project: Project object - :pram roles: Previously collected roles (optional, list or None) - :return: List of dicts - """ - - if roles is None: - roles = [] - - if project.type == PROJECT_TYPE_PROJECT: - i_owners = [a.user for a in project.get_owners(inherited_only=True)] - all_users = [a.user for a in project.get_all_roles(inherited=False)] - - for u in [u for u in i_owners if u not in all_users]: - roles.append( - { - 'project_uuid': str(project.sodar_uuid), - 'username': u.username, - } - ) - - for child in project.get_children(): - roles = cls.get_inherited_users(child, roles) - - return roles diff --git a/taskflowbackend/apps.py b/taskflowbackend/apps.py deleted file mode 100644 index 1164ea4a..00000000 --- a/taskflowbackend/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class TaskflowbackendConfig(AppConfig): - name = 'taskflowbackend' diff --git a/taskflowbackend/management/commands/__init__.py b/taskflowbackend/management/commands/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/taskflowbackend/management/commands/synctaskflow.py b/taskflowbackend/management/commands/synctaskflow.py deleted file mode 100644 index 55ac59b8..00000000 --- a/taskflowbackend/management/commands/synctaskflow.py +++ /dev/null @@ -1,229 +0,0 @@ -import sys - -from django.conf import settings -from django.contrib.auth import get_user_model -from django.core.management.base import BaseCommand, CommandError - -# Projectroles dependency -from projectroles.management.logging import ManagementCommandLogger -from projectroles.models import Project, Role, SODAR_CONSTANTS -from projectroles.plugins import get_active_plugins, get_backend_api - - -# SODAR constants -PROJECT_TYPE_PROJECT = SODAR_CONSTANTS['PROJECT_TYPE_PROJECT'] -PROJECT_TYPE_CATEGORY = SODAR_CONSTANTS['PROJECT_TYPE_CATEGORY'] - - -TARGETS = settings.TASKFLOW_TARGETS -TARGETS.remove('sodar') # Exclude SODAR from sync as data is already here - - -logger = ManagementCommandLogger(__name__) -User = get_user_model() - - -class Command(BaseCommand): - help = 'Submits missing project data to external storage' - - def add_arguments(self, parser): - pass - - def _submit_sync(self, app_name, sync_data, raise_exception=False): - """Submit flows found in an app's sync_data structure""" - - for item in sync_data: - project = Project.objects.get(sodar_uuid=item['project_uuid']) - logger.debug( - 'Syncing flow "{}" by {} for "{}" ({})'.format( - item['flow_name'], - app_name, - project.title, - project.sodar_uuid, - ) - ) - try: - self.taskflow.submit( - project_uuid=item['project_uuid'], - flow_name=item['flow_name'], - flow_data=item['flow_data'], - targets=TARGETS, - ) - except self.taskflow.FlowSubmitException as ex: - logger.error('Exception raised by flow: {}'.format(ex)) - # If we don't want to continue on failure - if raise_exception: - raise ex - - def _sync_projects(self): - """Synchronize projects and roles (must be called first!)""" - logger.info('Synchronizing project data with taskflow...') - logger.info('Target(s) = ' + ', '.join([t for t in TARGETS])) - - # Only sync PROJECT type projects as we (currently) don't have any - # use for CATEGORY projects in taskflow - projects = Project.objects.filter(type=PROJECT_TYPE_PROJECT).order_by( - 'pk' - ) - project_sync_data = [] - role_sync_data = [] - - for project in projects: - owner_as = project.get_owner() - if not owner_as: # This should not happen unless the db is corrupt - logger.error( - 'No owner assignment for project "{}" ({})'.format( - project.title, project.sodar_uuid - ) - ) - continue - - # Create project - project_sync_data.append( - { - 'project_uuid': str(project.sodar_uuid), - 'project_title': project.title, - 'flow_name': 'project_create', - 'flow_data': { - 'project_title': project.title, - 'project_description': project.description, - 'parent_uuid': str(project.parent.sodar_uuid) - if project.parent - else 0, - 'owner_username': owner_as.user.username, - 'owner_uuid': str(owner_as.user.sodar_uuid), - 'owner_role_pk': owner_as.role.pk, - }, - } - ) - - # Set up roles - role_sync_data.append( - { - 'project_uuid': str(project.sodar_uuid), - 'project_title': project.title, - 'flow_name': 'role_sync_delete_all', - 'flow_data': {'owner_username': owner_as.user.username}, - } - ) - for role_as in project.roles.exclude( - role=Role.objects.get( - name=SODAR_CONSTANTS['PROJECT_ROLE_OWNER'] - ) - ): - role_sync_data.append( - { - 'project_uuid': str(project.sodar_uuid), - 'project_title': project.title, - 'flow_name': 'role_update', - 'flow_data': { - 'username': role_as.user.username, - 'user_uuid': str(role_as.user.sodar_uuid), - 'role_pk': str(role_as.role.pk), - }, - } - ) - - self._submit_sync( - 'projectroles', project_sync_data, raise_exception=True - ) - self._submit_sync('projectroles', role_sync_data, raise_exception=False) - - def _sync_inherited_owners(self): - """Synchronize inherited owner permissions in iRODS""" - timeline = get_backend_api('timeline_backend') - roles_add = [] - roles_delete = [] - - if not timeline: - logger.warning( - 'Timeline backend not enabled, unable to sync ' - 'inherited owner roles in SODAR Taskflow' - ) - else: - logger.info('Retrieving added/removed inherited owners..') - ProjectEvent, ProjectEventObjectRef = timeline.get_models() - - for category in Project.objects.filter(type=PROJECT_TYPE_CATEGORY): - # Get roles to add - roles_add = self.taskflow.get_inherited_roles( - category, category.get_owner().user, roles_add - ) - - # Get previous owners to remove - tl_users = ( - ProjectEventObjectRef.objects.filter( - label='prev_owner', - event__in=ProjectEvent.objects.filter( - project=category, event_name='role_owner_transfer' - ), - ) - .exclude(object_uuid=category.get_owner().user.sodar_uuid) - .values_list('name', flat=True) - .distinct() - ) - - for u_name in tl_users: - user = User.objects.filter(username=u_name).first() - if not user: - continue - roles_delete = self.taskflow.get_inherited_roles( - category, user, roles_delete - ) - - if roles_add or roles_delete: - logger.info('Changes in inherited owners found, synchronizing..') - try: - self.taskflow.submit( - project_uuid=None, - flow_name='role_update_irods_batch', - flow_data={ - 'roles_add': roles_add, - 'roles_delete': roles_delete, - }, - ) - except Exception as ex: - logger.error( - 'Error synchronizing inherited owners: {}'.format(ex) - ) - logger.info('Inherited owner permissions synchronized.') - - def _sync_apps(self): - """Run taskflow synchronization methods in project app plugins""" - plugins = get_active_plugins(plugin_type='project_app') - for plugin in plugins: - sync_data = plugin.get_taskflow_sync_data() - logger.info('Synchronizing app "{}"...'.format(plugin.name)) - if sync_data: - self._submit_sync(plugin.name, sync_data, raise_exception=False) - else: - logger.info('Nothing to synchronize.') - - def handle(self, *args, **options): - """Run management command""" - if 'taskflow' not in settings.ENABLED_BACKEND_PLUGINS: - logger.error('Taskflow not enabled in settings, cancelled!') - raise CommandError - - self.taskflow = get_backend_api('taskflow') - if not self.taskflow: - logger.error('Taskflow backend plugin not available, cancelled!') - raise CommandError - - # Projectroles sync - # NOTE: For projectroles, this is done here as projects must be created - # or we can not continue with sync.. Also, removed projects are - # NOT deleted automatically (they shouldn't be deleted anyway). - # We first set up the projects and exit if syncing them fails. - try: - self._sync_projects() - except Exception as ex: - logger.error('Exception in project sync: {}'.format(ex)) - logger.error('Project sync failed! Unable to continue, exiting..') - sys.exit(1) - - # Inherited Owner Sync - self._sync_inherited_owners() - # App sync - self._sync_apps() - logger.info('Project data synchronized.') diff --git a/taskflowbackend/plugins.py b/taskflowbackend/plugins.py deleted file mode 100644 index f9e0a88b..00000000 --- a/taskflowbackend/plugins.py +++ /dev/null @@ -1,24 +0,0 @@ -# Projectroles dependency -from projectroles.plugins import BackendPluginPoint - -from .api import TaskflowAPI - - -class BackendPlugin(BackendPluginPoint): - """Plugin for registering backend app with Projectroles""" - - #: Name (slug-safe, used in URLs) - name = 'taskflow' - - #: Title (used in templates) - title = 'Taskflow' - - #: Iconify icon - icon = 'mdi:database' - - #: Description string - description = 'SODAR Taskflow backend for data transactions' - - def get_api(self): - """Return API entry point object.""" - return TaskflowAPI() diff --git a/timeline/models.py b/timeline/models.py index fd9de59b..797ef9bc 100644 --- a/timeline/models.py +++ b/timeline/models.py @@ -16,7 +16,7 @@ DEFAULT_MESSAGES = { 'OK': 'All OK', 'INIT': 'Event initialized', - 'SUBMIT': 'Job submitted to Taskflow', + 'SUBMIT': 'Job submitted asynchronously', 'FAILED': 'Failed (unknown problem)', 'INFO': 'Info level action', 'CANCEL': 'Action cancelled', diff --git a/timeline/templatetags/timeline_tags.py b/timeline/templatetags/timeline_tags.py index ac5fcd27..aeb2ce2e 100644 --- a/timeline/templatetags/timeline_tags.py +++ b/timeline/templatetags/timeline_tags.py @@ -1,4 +1,5 @@ import html +import logging from django import template from django.urls import reverse @@ -10,6 +11,7 @@ from timeline.models import ProjectEvent +logger = logging.getLogger(__name__) timeline = TimelineAPI() register = template.Library() @@ -53,7 +55,15 @@ def get_details_events(project, view_classified=False): @register.simple_tag def get_plugin_lookup(): """Return lookup dict of app plugins with app name as key""" - return {p.name: p.get_plugin() for p in Plugin.objects.all()} + ret = {} + for p in Plugin.objects.all(): + try: + ret[p.name] = p.get_plugin() + except Exception as ex: + logger.error( + 'Unable to retrieve plugin "{}": {}'.format(p.name, ex) + ) + return ret @register.simple_tag diff --git a/timeline/tests/test_views_taskflow.py b/timeline/tests/test_views_taskflow.py deleted file mode 100644 index 7c3cceaf..00000000 --- a/timeline/tests/test_views_taskflow.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Taskflow view tests for the timeline app""" - -import uuid - -from django.conf import settings -from django.test import override_settings -from django.urls import reverse - -from timeline.tests.test_views import TestViewsBase - - -class TestTaskflowSetStatusAPIView(TestViewsBase): - """Tests for the taskflow status setting API view""" - - def setUp(self): - super().setUp() - - # Init default event - self.event_init = self.timeline.add_event( - project=self.project, - app_name='projectroles', - user=self.user, - event_name='test_event', - description='description', - extra_data={'test_key': 'test_val'}, - status_type='INIT', - ) - - @override_settings(ENABLED_BACKEND_PLUGINS=['taskflow']) - def test_set_status(self): - """Test setting the status of the event""" - values = { - 'event_uuid': self.event_init.sodar_uuid, - 'status_type': 'OK', - 'status_desc': '', - 'sodar_secret': settings.TASKFLOW_SODAR_SECRET, - } - - response = self.client.post( - reverse('timeline:taskflow_status_set'), values - ) - - self.assertEqual(response.status_code, 200) - - @override_settings(ENABLED_BACKEND_PLUGINS=['taskflow']) - def test_set_invalid_event(self): - """Test setting the status of the event with an invalid event pk""" - values = { - 'event_uuid': uuid.uuid4(), - 'status_type': 'OK', - 'status_desc': '', - 'sodar_secret': settings.TASKFLOW_SODAR_SECRET, - } - - response = self.client.post( - reverse('timeline:taskflow_status_set'), values - ) - - self.assertEqual(response.status_code, 404) - - @override_settings(ENABLED_BACKEND_PLUGINS=['taskflow']) - def test_set_invalid_status(self): - """Test setting the status of the event with an invalid status type""" - values = { - 'event_uuid': self.event_init.sodar_uuid, - 'status_type': 'ahL4VeerAeth4ohh', - 'status_desc': '', - 'sodar_secret': settings.TASKFLOW_SODAR_SECRET, - } - - response = self.client.post( - reverse('timeline:taskflow_status_set'), values - ) - - self.assertEqual(response.status_code, 400) diff --git a/timeline/urls.py b/timeline/urls.py index 6cf7bf7b..b739d60f 100644 --- a/timeline/urls.py +++ b/timeline/urls.py @@ -2,7 +2,7 @@ from django.conf.urls import url -from timeline import views, views_ajax, views_taskflow +from timeline import views, views_ajax app_name = 'timeline' @@ -46,13 +46,4 @@ ), ] -# Taskflow API views -urls_taskflow = [ - url( - regex=r'^taskflow/status/set$', - view=views_taskflow.TaskflowEventStatusSetAPIView.as_view(), - name='taskflow_status_set', - ) -] - -urlpatterns = urls_ui + urls_ajax + urls_taskflow +urlpatterns = urls_ui + urls_ajax diff --git a/timeline/views_taskflow.py b/timeline/views_taskflow.py deleted file mode 100644 index 7ff3d5b8..00000000 --- a/timeline/views_taskflow.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Taskflow API views for the timeline app""" - -from rest_framework.response import Response - -# Projectroles dependency -from projectroles.views_taskflow import BaseTaskflowAPIView - -from timeline.models import ProjectEvent - - -class TaskflowEventStatusSetAPIView(BaseTaskflowAPIView): - def post(self, request): - try: - tl_event = ProjectEvent.objects.get( - sodar_uuid=request.data['event_uuid'] - ) - - except ProjectEvent.DoesNotExist: - return Response('Timeline event not found', status=404) - - try: - tl_event.set_status( - status_type=request.data['status_type'], - status_desc=request.data['status_desc'], - extra_data=request.data['extra_data'] - if 'extra_data' in request.data - else None, - ) - - except TypeError: - return Response('Invalid status type', status=400) - - return Response('ok', status=200) From bd89988b9a84d9283c864218d8d19b8b97146269 Mon Sep 17 00:00:00 2001 From: Mikko Nieminen Date: Thu, 25 Aug 2022 12:57:23 +0200 Subject: [PATCH 02/17] remove project submit_status field (#971) --- CHANGELOG.rst | 1 + docs/source/major_changes.rst | 9 +++++++++ projectroles/constants.py | 4 ---- projectroles/forms.py | 2 -- .../0021_remove_project_submit_status.py | 17 +++++++++++++++++ projectroles/models.py | 11 +---------- projectroles/remote_projects.py | 2 -- projectroles/serializers.py | 2 -- projectroles/tests/test_app_settings_api.py | 3 --- projectroles/tests/test_models.py | 6 ------ projectroles/tests/test_remote_projects_api.py | 10 ---------- projectroles/tests/test_views.py | 9 --------- projectroles/tests/test_views_api.py | 16 ---------------- projectroles/views.py | 5 ----- projectroles/views_ajax.py | 5 +---- projectroles/views_api.py | 3 +-- 16 files changed, 30 insertions(+), 75 deletions(-) create mode 100644 projectroles/migrations/0021_remove_project_submit_status.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9fb02e08..aa1ab432 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -38,6 +38,7 @@ Removed - **Projectroles** - Taskflow specific views, tests and API calls (#387) - ``get_taskflow_sync_data()`` method from ``ProjectAppPluginPoint`` (#387) + - ``Project.submit_status`` field and usages in code (#971) - **Taskflowbackend** - Remove app and implement in SODAR (#387) - **Timeline** diff --git a/docs/source/major_changes.rst b/docs/source/major_changes.rst index cae2836f..b93f2933 100644 --- a/docs/source/major_changes.rst +++ b/docs/source/major_changes.rst @@ -44,6 +44,15 @@ Next, run the Django shell and enter the following: After this the server should run without issues. +Project.submit_status Removed +----------------------------- + +The ``submit_status`` field has been removed from the ``Project`` model, along +with related helper method arguments and constants. This field was primarily +used by SODAR Taskflow, but its removal may raise some issues in e.g. unit +tests. If you encounter errors, refactor your code to remove references to the +field. + v0.10.13 (2022-07-15) ********************* diff --git a/projectroles/constants.py b/projectroles/constants.py index 504b8869..3b41a101 100644 --- a/projectroles/constants.py +++ b/projectroles/constants.py @@ -11,10 +11,6 @@ # Project types 'PROJECT_TYPE_CATEGORY': 'CATEGORY', 'PROJECT_TYPE_PROJECT': 'PROJECT', - # Submission status - 'SUBMIT_STATUS_OK': 'OK', - 'SUBMIT_STATUS_PENDING': 'PENDING', - 'SUBMIT_STATUS_PENDING_TASKFLOW': 'PENDING-TASKFLOW', # App Settings 'APP_SETTING_SCOPE_PROJECT': 'PROJECT', 'APP_SETTING_SCOPE_USER': 'USER', diff --git a/projectroles/forms.py b/projectroles/forms.py index b6364b55..e33fad48 100644 --- a/projectroles/forms.py +++ b/projectroles/forms.py @@ -43,8 +43,6 @@ PROJECT_ROLE_GUEST = SODAR_CONSTANTS['PROJECT_ROLE_GUEST'] PROJECT_TYPE_CATEGORY = SODAR_CONSTANTS['PROJECT_TYPE_CATEGORY'] PROJECT_TYPE_PROJECT = SODAR_CONSTANTS['PROJECT_TYPE_PROJECT'] -SUBMIT_STATUS_OK = SODAR_CONSTANTS['SUBMIT_STATUS_OK'] -SUBMIT_STATUS_PENDING = SODAR_CONSTANTS['SUBMIT_STATUS_PENDING'] SITE_MODE_SOURCE = SODAR_CONSTANTS['SITE_MODE_SOURCE'] SITE_MODE_TARGET = SODAR_CONSTANTS['SITE_MODE_TARGET'] APP_SETTING_SCOPE_PROJECT = SODAR_CONSTANTS['APP_SETTING_SCOPE_PROJECT'] diff --git a/projectroles/migrations/0021_remove_project_submit_status.py b/projectroles/migrations/0021_remove_project_submit_status.py new file mode 100644 index 00000000..b448be6c --- /dev/null +++ b/projectroles/migrations/0021_remove_project_submit_status.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.14 on 2022-08-25 10:41 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projectroles', '0020_project_has_public_children'), + ] + + operations = [ + migrations.RemoveField( + model_name='project', + name='submit_status', + ), + ] diff --git a/projectroles/models.py b/projectroles/models.py index aea0af8d..69451178 100644 --- a/projectroles/models.py +++ b/projectroles/models.py @@ -126,13 +126,6 @@ class Project(models.Model): 'unauthenticated users if allowed on the site', ) - #: Status of project creation - submit_status = models.CharField( - max_length=64, - default=SODAR_CONSTANTS['SUBMIT_STATUS_OK'], - help_text='Status of project creation', - ) - #: Full project title with parent path (auto-generated) full_title = models.CharField( max_length=4096, @@ -274,9 +267,7 @@ def _get(obj, ret=None): if flat: return _get(self) - return self.children.filter( - submit_status=SODAR_CONSTANTS['SUBMIT_STATUS_OK'] - ).order_by('title') + return self.children.all().order_by('title') def get_depth(self): """Return depth of project in the project tree structure (root=0)""" diff --git a/projectroles/remote_projects.py b/projectroles/remote_projects.py index 6061bdcd..4e0df811 100644 --- a/projectroles/remote_projects.py +++ b/projectroles/remote_projects.py @@ -38,8 +38,6 @@ PROJECT_TYPE_CATEGORY = SODAR_CONSTANTS['PROJECT_TYPE_CATEGORY'] PROJECT_ROLE_OWNER = SODAR_CONSTANTS['PROJECT_ROLE_OWNER'] PROJECT_ROLE_DELEGATE = SODAR_CONSTANTS['PROJECT_ROLE_DELEGATE'] -SUBMIT_STATUS_OK = SODAR_CONSTANTS['SUBMIT_STATUS_OK'] -SUBMIT_STATUS_PENDING = SODAR_CONSTANTS['SUBMIT_STATUS_PENDING'] SITE_MODE_TARGET = SODAR_CONSTANTS['SITE_MODE_TARGET'] SITE_MODE_SOURCE = SODAR_CONSTANTS['SITE_MODE_SOURCE'] SITE_MODE_PEER = SODAR_CONSTANTS['SITE_MODE_PEER'] diff --git a/projectroles/serializers.py b/projectroles/serializers.py index 2144f389..bef31bbc 100644 --- a/projectroles/serializers.py +++ b/projectroles/serializers.py @@ -352,12 +352,10 @@ class Meta: 'description', 'readme', 'public_guest_access', - 'submit_status', 'owner', 'roles', 'sodar_uuid', ] - read_only_fields = ['submit_status'] def validate(self, attrs): site_mode = getattr( diff --git a/projectroles/tests/test_app_settings_api.py b/projectroles/tests/test_app_settings_api.py index 4bc2cea8..e6213793 100644 --- a/projectroles/tests/test_app_settings_api.py +++ b/projectroles/tests/test_app_settings_api.py @@ -14,9 +14,6 @@ PROJECT_ROLE_GUEST = SODAR_CONSTANTS['PROJECT_ROLE_GUEST'] PROJECT_TYPE_CATEGORY = SODAR_CONSTANTS['PROJECT_TYPE_CATEGORY'] PROJECT_TYPE_PROJECT = SODAR_CONSTANTS['PROJECT_TYPE_PROJECT'] -SUBMIT_STATUS_OK = SODAR_CONSTANTS['SUBMIT_STATUS_OK'] -SUBMIT_STATUS_PENDING = SODAR_CONSTANTS['SUBMIT_STATUS_PENDING'] -SUBMIT_STATUS_PENDING_TASKFLOW = SODAR_CONSTANTS['SUBMIT_STATUS_PENDING'] APP_SETTING_SCOPE_PROJECT = SODAR_CONSTANTS['APP_SETTING_SCOPE_PROJECT'] APP_SETTING_SCOPE_USER = SODAR_CONSTANTS['APP_SETTING_SCOPE_USER'] APP_SETTING_SCOPE_PROJECT_USER = SODAR_CONSTANTS[ diff --git a/projectroles/tests/test_models.py b/projectroles/tests/test_models.py index 556a4ff9..d855edbb 100644 --- a/projectroles/tests/test_models.py +++ b/projectroles/tests/test_models.py @@ -35,9 +35,6 @@ PROJECT_ROLE_GUEST = SODAR_CONSTANTS['PROJECT_ROLE_GUEST'] PROJECT_TYPE_CATEGORY = SODAR_CONSTANTS['PROJECT_TYPE_CATEGORY'] PROJECT_TYPE_PROJECT = SODAR_CONSTANTS['PROJECT_TYPE_PROJECT'] -SUBMIT_STATUS_OK = SODAR_CONSTANTS['SUBMIT_STATUS_OK'] -SUBMIT_STATUS_PENDING = SODAR_CONSTANTS['SUBMIT_STATUS_PENDING'] -SUBMIT_STATUS_PENDING_TASKFLOW = SODAR_CONSTANTS['SUBMIT_STATUS_PENDING'] SITE_MODE_TARGET = SODAR_CONSTANTS['SITE_MODE_TARGET'] SITE_MODE_SOURCE = SODAR_CONSTANTS['SITE_MODE_SOURCE'] @@ -65,7 +62,6 @@ def _make_project( type, parent, description='', - submit_status=SUBMIT_STATUS_OK, readme=None, public_guest_access=False, sodar_uuid=None, @@ -75,7 +71,6 @@ def _make_project( 'title': title, 'type': type, 'parent': parent, - 'submit_status': submit_status, 'description': description, 'readme': readme, 'public_guest_access': public_guest_access, @@ -302,7 +297,6 @@ def test_initialization(self): 'title': 'TestProjectSub', 'type': PROJECT_TYPE_PROJECT, 'parent': self.category_top.pk, - 'submit_status': SUBMIT_STATUS_OK, 'full_title': 'TestCategoryTop / TestProjectSub', 'sodar_uuid': self.project_sub.sodar_uuid, 'description': '', diff --git a/projectroles/tests/test_remote_projects_api.py b/projectroles/tests/test_remote_projects_api.py index 6d884af7..2d7c9ebe 100644 --- a/projectroles/tests/test_remote_projects_api.py +++ b/projectroles/tests/test_remote_projects_api.py @@ -42,9 +42,6 @@ PROJECT_ROLE_GUEST = SODAR_CONSTANTS['PROJECT_ROLE_GUEST'] PROJECT_TYPE_CATEGORY = SODAR_CONSTANTS['PROJECT_TYPE_CATEGORY'] PROJECT_TYPE_PROJECT = SODAR_CONSTANTS['PROJECT_TYPE_PROJECT'] -SUBMIT_STATUS_OK = SODAR_CONSTANTS['SUBMIT_STATUS_OK'] -SUBMIT_STATUS_PENDING = SODAR_CONSTANTS['SUBMIT_STATUS_PENDING'] -SUBMIT_STATUS_PENDING_TASKFLOW = SODAR_CONSTANTS['SUBMIT_STATUS_PENDING'] SITE_MODE_TARGET = SODAR_CONSTANTS['SITE_MODE_TARGET'] SITE_MODE_SOURCE = SODAR_CONSTANTS['SITE_MODE_SOURCE'] SITE_MODE_PEER = SODAR_CONSTANTS['SITE_MODE_PEER'] @@ -673,7 +670,6 @@ def test_create(self): 'description': SOURCE_PROJECT_DESCRIPTION, 'parent': None, 'public_guest_access': False, - 'submit_status': SUBMIT_STATUS_OK, 'full_title': SOURCE_CATEGORY_TITLE, 'has_public_children': False, 'sodar_uuid': uuid.UUID(SOURCE_CATEGORY_UUID), @@ -736,7 +732,6 @@ def test_create(self): 'description': SOURCE_PROJECT_DESCRIPTION, 'parent': category_obj.pk, 'public_guest_access': False, - 'submit_status': SUBMIT_STATUS_OK, 'full_title': SOURCE_PROJECT_FULL_TITLE, 'has_public_children': False, 'sodar_uuid': uuid.UUID(SOURCE_PROJECT_UUID), @@ -994,7 +989,6 @@ def test_create_multiple(self): 'description': SOURCE_PROJECT_DESCRIPTION, 'parent': category_obj.pk, 'public_guest_access': False, - 'submit_status': SUBMIT_STATUS_OK, 'full_title': SOURCE_CATEGORY_TITLE + ' / ' + new_project_title, 'has_public_children': False, 'sodar_uuid': uuid.UUID(new_project_uuid), @@ -1428,7 +1422,6 @@ def test_update(self): 'description': SOURCE_PROJECT_DESCRIPTION, 'parent': None, 'public_guest_access': False, - 'submit_status': SUBMIT_STATUS_OK, 'full_title': SOURCE_CATEGORY_TITLE, 'has_public_children': False, 'sodar_uuid': uuid.UUID(SOURCE_CATEGORY_UUID), @@ -1455,7 +1448,6 @@ def test_update(self): 'description': SOURCE_PROJECT_DESCRIPTION, 'parent': self.category_obj.pk, 'public_guest_access': False, - 'submit_status': SUBMIT_STATUS_OK, 'full_title': SOURCE_PROJECT_FULL_TITLE, 'has_public_children': False, 'sodar_uuid': uuid.UUID(SOURCE_PROJECT_UUID), @@ -1805,7 +1797,6 @@ def test_update_no_changes(self): 'description': SOURCE_PROJECT_DESCRIPTION, 'parent': None, 'public_guest_access': False, - 'submit_status': SUBMIT_STATUS_OK, 'full_title': SOURCE_CATEGORY_TITLE, 'has_public_children': False, 'sodar_uuid': uuid.UUID(SOURCE_CATEGORY_UUID), @@ -1832,7 +1823,6 @@ def test_update_no_changes(self): 'description': SOURCE_PROJECT_DESCRIPTION, 'parent': self.category_obj.pk, 'public_guest_access': False, - 'submit_status': SUBMIT_STATUS_OK, 'full_title': SOURCE_PROJECT_FULL_TITLE, 'has_public_children': False, 'sodar_uuid': uuid.UUID(SOURCE_PROJECT_UUID), diff --git a/projectroles/tests/test_views.py b/projectroles/tests/test_views.py index e5373edd..abed71b3 100644 --- a/projectroles/tests/test_views.py +++ b/projectroles/tests/test_views.py @@ -65,9 +65,6 @@ PROJECT_ROLE_GUEST = SODAR_CONSTANTS['PROJECT_ROLE_GUEST'] PROJECT_TYPE_CATEGORY = SODAR_CONSTANTS['PROJECT_TYPE_CATEGORY'] PROJECT_TYPE_PROJECT = SODAR_CONSTANTS['PROJECT_TYPE_PROJECT'] -SUBMIT_STATUS_OK = SODAR_CONSTANTS['SUBMIT_STATUS_OK'] -SUBMIT_STATUS_PENDING = SODAR_CONSTANTS['SUBMIT_STATUS_PENDING'] -SUBMIT_STATUS_PENDING_TASKFLOW = SODAR_CONSTANTS['SUBMIT_STATUS_PENDING'] SITE_MODE_TARGET = SODAR_CONSTANTS['SITE_MODE_TARGET'] SITE_MODE_SOURCE = SODAR_CONSTANTS['SITE_MODE_SOURCE'] APP_SETTING_SCOPE_PROJECT = SODAR_CONSTANTS['APP_SETTING_SCOPE_PROJECT'] @@ -530,7 +527,6 @@ def test_create_top_level_category(self): 'type': PROJECT_TYPE_CATEGORY, 'parent': '', 'owner': self.user.sodar_uuid, - 'submit_status': SUBMIT_STATUS_OK, 'description': 'description', 'public_guest_access': False, } @@ -556,7 +552,6 @@ def test_create_top_level_category(self): 'title': 'TestCategory', 'type': PROJECT_TYPE_CATEGORY, 'parent': None, - 'submit_status': SUBMIT_STATUS_OK, 'description': 'description', 'public_guest_access': False, 'full_title': 'TestCategory', @@ -600,7 +595,6 @@ def test_create_project(self): 'type': PROJECT_TYPE_CATEGORY, 'parent': '', 'owner': self.user.sodar_uuid, - 'submit_status': SUBMIT_STATUS_OK, 'description': 'description', 'public_guest_access': False, } @@ -653,7 +647,6 @@ def test_create_project(self): 'title': 'TestProject', 'type': PROJECT_TYPE_PROJECT, 'parent': category.pk, - 'submit_status': SUBMIT_STATUS_OK, 'description': 'description', 'public_guest_access': False, 'full_title': 'TestCategory / TestProject', @@ -827,7 +820,6 @@ def test_update_project(self): 'title': 'updated title', 'type': PROJECT_TYPE_PROJECT, 'parent': new_category.pk, - 'submit_status': SUBMIT_STATUS_OK, 'description': 'updated description', 'public_guest_access': False, 'full_title': new_category.title + ' / ' + 'updated title', @@ -932,7 +924,6 @@ def test_update_category(self): 'title': 'updated title', 'type': PROJECT_TYPE_CATEGORY, 'parent': None, - 'submit_status': SUBMIT_STATUS_OK, 'description': 'updated description', 'public_guest_access': False, 'full_title': 'updated title', diff --git a/projectroles/tests/test_views_api.py b/projectroles/tests/test_views_api.py index 7c70eefb..6d833c9b 100644 --- a/projectroles/tests/test_views_api.py +++ b/projectroles/tests/test_views_api.py @@ -277,7 +277,6 @@ def test_get(self): 'description': self.category.description, 'readme': '', 'public_guest_access': False, - 'submit_status': self.category.submit_status, 'roles': { str(self.cat_owner_as.sodar_uuid): { 'user': { @@ -299,7 +298,6 @@ def test_get(self): 'description': self.project.description, 'readme': '', 'public_guest_access': False, - 'submit_status': self.project.submit_status, 'roles': { str(self.owner_as.sodar_uuid): { 'user': { @@ -361,7 +359,6 @@ def test_get_category(self): 'description': self.category.description, 'readme': '', 'public_guest_access': False, - 'submit_status': self.category.submit_status, 'roles': { str(self.cat_owner_as.sodar_uuid): { 'user': { @@ -395,7 +392,6 @@ def test_get_project(self): 'description': self.project.description, 'readme': '', 'public_guest_access': False, - 'submit_status': self.project.submit_status, 'roles': { str(self.owner_as.sodar_uuid): { 'user': { @@ -458,7 +454,6 @@ def test_create_category(self): 'description': new_category.description, 'readme': new_category.readme.raw, 'public_guest_access': False, - 'submit_status': SODAR_CONSTANTS['SUBMIT_STATUS_OK'], 'full_title': new_category.title, 'has_public_children': False, 'sodar_uuid': new_category.sodar_uuid, @@ -515,7 +510,6 @@ def test_create_category_nested(self): 'description': new_category.description, 'readme': new_category.readme.raw, 'public_guest_access': False, - 'submit_status': SODAR_CONSTANTS['SUBMIT_STATUS_OK'], 'full_title': self.category.title + ' / ' + new_category.title, 'has_public_children': False, 'sodar_uuid': new_category.sodar_uuid, @@ -569,7 +563,6 @@ def test_create_project(self): 'description': new_project.description, 'readme': new_project.readme.raw, 'public_guest_access': False, - 'submit_status': SODAR_CONSTANTS['SUBMIT_STATUS_OK'], 'full_title': self.category.title + ' / ' + new_project.title, 'has_public_children': False, 'sodar_uuid': new_project.sodar_uuid, @@ -825,7 +818,6 @@ def test_put_category(self): 'description': UPDATED_DESC, 'readme': UPDATED_README, 'public_guest_access': True, - 'submit_status': SODAR_CONSTANTS['SUBMIT_STATUS_OK'], 'full_title': UPDATED_TITLE, 'has_public_children': False, 'sodar_uuid': self.category.sodar_uuid, @@ -836,7 +828,6 @@ def test_put_category(self): 'title': UPDATED_TITLE, 'type': PROJECT_TYPE_CATEGORY, 'parent': None, - 'submit_status': SODAR_CONSTANTS['SUBMIT_STATUS_OK'], 'description': UPDATED_DESC, 'readme': UPDATED_README, 'public_guest_access': True, @@ -884,7 +875,6 @@ def test_put_project(self): 'description': UPDATED_DESC, 'readme': UPDATED_README, 'public_guest_access': True, - 'submit_status': SODAR_CONSTANTS['SUBMIT_STATUS_OK'], 'full_title': self.category.title + ' / ' + UPDATED_TITLE, 'has_public_children': False, 'sodar_uuid': self.project.sodar_uuid, @@ -895,7 +885,6 @@ def test_put_project(self): 'title': UPDATED_TITLE, 'type': PROJECT_TYPE_PROJECT, 'parent': str(self.category.sodar_uuid), - 'submit_status': SODAR_CONSTANTS['SUBMIT_STATUS_OK'], 'description': UPDATED_DESC, 'readme': UPDATED_README, 'public_guest_access': True, @@ -939,7 +928,6 @@ def test_patch_category(self): 'description': UPDATED_DESC, 'readme': UPDATED_README, 'public_guest_access': False, - 'submit_status': SODAR_CONSTANTS['SUBMIT_STATUS_OK'], 'full_title': UPDATED_TITLE, 'has_public_children': False, 'sodar_uuid': self.category.sodar_uuid, @@ -951,7 +939,6 @@ def test_patch_category(self): 'title': UPDATED_TITLE, 'type': PROJECT_TYPE_CATEGORY, 'parent': None, - 'submit_status': SODAR_CONSTANTS['SUBMIT_STATUS_OK'], 'description': UPDATED_DESC, 'readme': UPDATED_README, 'public_guest_access': False, @@ -996,7 +983,6 @@ def test_patch_project(self): 'description': UPDATED_DESC, 'readme': UPDATED_README, 'public_guest_access': True, - 'submit_status': SODAR_CONSTANTS['SUBMIT_STATUS_OK'], 'full_title': self.category.title + ' / ' + UPDATED_TITLE, 'has_public_children': False, 'sodar_uuid': self.project.sodar_uuid, @@ -1008,7 +994,6 @@ def test_patch_project(self): 'title': UPDATED_TITLE, 'type': PROJECT_TYPE_PROJECT, 'parent': str(self.category.sodar_uuid), - 'submit_status': SODAR_CONSTANTS['SUBMIT_STATUS_OK'], 'description': UPDATED_DESC, 'readme': UPDATED_README, 'public_guest_access': True, @@ -2616,7 +2601,6 @@ def _get_project_ip_allowing( 'description': self.project.description, 'readme': '', 'public_guest_access': False, - 'submit_status': self.project.submit_status, 'roles': { str(user_as.sodar_uuid): { 'user': { diff --git a/projectroles/views.py b/projectroles/views.py index 6eafd644..6e5b6560 100644 --- a/projectroles/views.py +++ b/projectroles/views.py @@ -74,8 +74,6 @@ PROJECT_ROLE_OWNER = SODAR_CONSTANTS['PROJECT_ROLE_OWNER'] PROJECT_ROLE_DELEGATE = SODAR_CONSTANTS['PROJECT_ROLE_DELEGATE'] PROJECT_ROLE_GUEST = SODAR_CONSTANTS['PROJECT_ROLE_GUEST'] -SUBMIT_STATUS_OK = SODAR_CONSTANTS['SUBMIT_STATUS_OK'] -SUBMIT_STATUS_PENDING = SODAR_CONSTANTS['SUBMIT_STATUS_PENDING'] SITE_MODE_TARGET = SODAR_CONSTANTS['SITE_MODE_TARGET'] SITE_MODE_SOURCE = SODAR_CONSTANTS['SITE_MODE_SOURCE'] SITE_MODE_PEER = SODAR_CONSTANTS['SITE_MODE_PEER'] @@ -1140,9 +1138,6 @@ def modify_project(self, data, request, instance=None): # Update owner and settings self._update_owner(project, owner) self._update_settings(project, project_settings) - - # Post submit/save - project.submit_status = SUBMIT_STATUS_OK project.save() # Call for additional actions for project creation/update in plugins diff --git a/projectroles/views_ajax.py b/projectroles/views_ajax.py index b08ea268..0128c939 100644 --- a/projectroles/views_ajax.py +++ b/projectroles/views_ajax.py @@ -44,7 +44,6 @@ PROJECT_TYPE_PROJECT = SODAR_CONSTANTS['PROJECT_TYPE_PROJECT'] PROJECT_TYPE_CATEGORY = SODAR_CONSTANTS['PROJECT_TYPE_CATEGORY'] PROJECT_ROLE_OWNER = SODAR_CONSTANTS['PROJECT_ROLE_OWNER'] -SUBMIT_STATUS_OK = SODAR_CONSTANTS['SUBMIT_STATUS_OK'] SYSTEM_USER_GROUP = SODAR_CONSTANTS['SYSTEM_USER_GROUP'] # Local constants @@ -115,9 +114,7 @@ def _get_project_list(cls, user, parent=None): :param user: User for which the projects are visible :param parent: Project object of type CATEGORY or None """ - project_list = Project.objects.filter( - submit_status=SUBMIT_STATUS_OK, - ) + project_list = Project.objects.all() if user.is_anonymous: project_list = project_list.filter( Q(public_guest_access=True) | Q(has_public_children=True) diff --git a/projectroles/views_api.py b/projectroles/views_api.py index 8a8e9b03..7cb28cea 100644 --- a/projectroles/views_api.py +++ b/projectroles/views_api.py @@ -398,7 +398,7 @@ def get_queryset(self): Override get_queryset() to return projects of type PROJECT for which the requesting user has access. """ - qs = Project.objects.filter(submit_status='OK').order_by('pk') + qs = Project.objects.all().order_by('pk') if self.request.user.is_superuser: return qs @@ -426,7 +426,6 @@ class ProjectRetrieveAPIView( - ``public_guest_access``: Guest access for all users (boolean) - ``roles``: Project role assignments (dict, assignment UUID as key) - ``sodar_uuid``: Project UUID (string) - - ``submit_status``: Project creation status (string) - ``title``: Project title (string) - ``type``: Project type (string, options: ``PROJECT`` or ``CATEGORY``) """ From 48fef852801c395ca038751a801f1c4492625332 Mon Sep 17 00:00:00 2001 From: Mikko Nieminen Date: Thu, 25 Aug 2022 13:28:33 +0200 Subject: [PATCH 03/17] remove required owner from ProjectUpdateAPIView (#1007) --- CHANGELOG.rst | 2 ++ docs/source/major_changes.rst | 14 ++++++++++++++ projectroles/serializers.py | 6 +++++- projectroles/tests/test_views_api.py | 2 -- projectroles/views_api.py | 1 - 5 files changed, 21 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index aa1ab432..7184882d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -23,6 +23,8 @@ Changed - **Projectroles** - Replace Taskflow specific code with project modifying API calls (#387) - Rename ``revoke_failed_invite()`` to ``revoke_invite()`` + - Do not return ``submit_status`` from project API views (#971) + - Remove required ``owner`` argument for ``ProjectUpdateAPIView`` (#1007) Fixed ----- diff --git a/docs/source/major_changes.rst b/docs/source/major_changes.rst index b93f2933..af1c1f71 100644 --- a/docs/source/major_changes.rst +++ b/docs/source/major_changes.rst @@ -53,6 +53,20 @@ used by SODAR Taskflow, but its removal may raise some issues in e.g. unit tests. If you encounter errors, refactor your code to remove references to the field. +REST API Backwards Compatibility +-------------------------------- + +Due to some required changes to the REST API, it is no longer considered +backwards compatible with older versions. Version ``0.11.0`` or higher must now +be used. Note that target sites using a SODAR Core v0.11 source site also have +to be updated for remote project sync to work. + +Changes: + +- Remove ``owner`` argument requirement from ``ProjectUpdateAPIView``. +- Do not provide ``submit_status`` in ``ProjectListAPIView`` and + ``ProjectRetrieveAPIView``. + v0.10.13 (2022-07-15) ********************* diff --git a/projectroles/serializers.py b/projectroles/serializers.py index bef31bbc..510e438a 100644 --- a/projectroles/serializers.py +++ b/projectroles/serializers.py @@ -333,7 +333,7 @@ def save(self, **kwargs): class ProjectSerializer(ProjectModifyMixin, SODARModelSerializer): """Serializer for the Project model""" - owner = serializers.CharField(write_only=True) + owner = serializers.CharField(write_only=True, required=False) parent = serializers.SlugRelatedField( slug_field='sodar_uuid', many=False, @@ -480,6 +480,10 @@ def validate(self, attrs): if not owner: raise serializers.ValidationError('Owner not found') attrs['owner'] = owner + elif not self.instance: + raise serializers.ValidationError( + 'The "owner" parameter must be supplied for project creation' + ) # Set readme if 'readme' in attrs and 'raw' in attrs['readme']: diff --git a/projectroles/tests/test_views_api.py b/projectroles/tests/test_views_api.py index 6d833c9b..9d315403 100644 --- a/projectroles/tests/test_views_api.py +++ b/projectroles/tests/test_views_api.py @@ -800,7 +800,6 @@ def test_put_category(self): 'description': UPDATED_DESC, 'readme': UPDATED_README, 'public_guest_access': True, - 'owner': str(self.user.sodar_uuid), } response = self.request_knox(url, method='PUT', data=put_data) @@ -857,7 +856,6 @@ def test_put_project(self): 'description': UPDATED_DESC, 'readme': UPDATED_README, 'public_guest_access': True, - 'owner': str(self.user.sodar_uuid), } response = self.request_knox(url, method='PUT', data=put_data) diff --git a/projectroles/views_api.py b/projectroles/views_api.py index 7cb28cea..8285daff 100644 --- a/projectroles/views_api.py +++ b/projectroles/views_api.py @@ -483,7 +483,6 @@ class ProjectUpdateAPIView( - ``description``: Projcet description (string, optional) - ``readme``: Project readme (string, optional, supports markdown) - ``public_guest_access``: Guest access for all users (boolean) - - ``owner``: User UUID of the project owner (string) """ permission_required = 'projectroles.update_project' From f91bc6881851355933a6cfac8c70c155d6c82512 Mon Sep 17 00:00:00 2001 From: Mikko Nieminen Date: Thu, 25 Aug 2022 13:32:03 +0200 Subject: [PATCH 04/17] fix remote site form button icon (#1001) --- CHANGELOG.rst | 1 + projectroles/templates/projectroles/remotesite_form.html | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7184882d..88721ea9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -31,6 +31,7 @@ Fixed - **Projectroles** - Crash at exception handling in ``clean_new_owner()`` (#981) + - Incorrect button icon in remote site form (#1001) - **Timeline** - Uncaught exceptions in ``get_plugin_lookup()`` (#979) diff --git a/projectroles/templates/projectroles/remotesite_form.html b/projectroles/templates/projectroles/remotesite_form.html index 0457c25c..82636da6 100644 --- a/projectroles/templates/projectroles/remotesite_form.html +++ b/projectroles/templates/projectroles/remotesite_form.html @@ -26,7 +26,7 @@

{% if object.pk %}Update{% else %}Add{% endif %} {% if site_mode == 'TARGET' Cancel From f1ec2e93203578b38c5299d89a9411214e46f0f2 Mon Sep 17 00:00:00 2001 From: Mikko Nieminen Date: Thu, 25 Aug 2022 14:02:00 +0200 Subject: [PATCH 05/17] remove unused ProjectModifyMixin owner operations (#1008) --- CHANGELOG.rst | 1 + projectroles/views.py | 38 ++++++++++---------------------------- 2 files changed, 11 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 88721ea9..2b1de3c5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -25,6 +25,7 @@ Changed - Rename ``revoke_failed_invite()`` to ``revoke_invite()`` - Do not return ``submit_status`` from project API views (#971) - Remove required ``owner`` argument for ``ProjectUpdateAPIView`` (#1007) + - Remove unused owner operations from ``ProjectModifyMixin`` (#1008) Fixed ----- diff --git a/projectroles/views.py b/projectroles/views.py index 6e5b6560..4cb7588c 100644 --- a/projectroles/views.py +++ b/projectroles/views.py @@ -868,7 +868,7 @@ def _get_app_settings(data, instance): return project_settings @staticmethod - def _get_project_update_data(old_data, project, owner, project_settings): + def _get_project_update_data(old_data, project, project_settings): extra_data = {} upd_fields = [] if old_data['title'] != project.title: @@ -877,9 +877,6 @@ def _get_project_update_data(old_data, project, owner, project_settings): if old_data['parent'] != project.parent: extra_data['parent'] = project.parent upd_fields.append('parent') - if old_data['owner'] != owner: - extra_data['owner'] = owner.username - upd_fields.append('owner') if old_data['description'] != project.description: extra_data['description'] = project.description upd_fields.append('description') @@ -933,7 +930,7 @@ def _create_timeline_event( else: # Update tl_desc = 'update ' + type_str.lower() extra_data, upd_fields = cls._get_project_update_data( - old_data, project, owner, project_settings + old_data, project, project_settings ) if extra_data.get('parent'): # Convert parent object into UUID extra_data['parent'] = str(extra_data['parent'].sodar_uuid) @@ -952,24 +949,6 @@ def _create_timeline_event( tl_event.add_object(owner, 'owner', owner.username) return tl_event - @classmethod - def _update_owner(cls, project, owner): - """Create or update project owner""" - try: - role_as = RoleAssignment.objects.get( - project=project, role__name=PROJECT_ROLE_OWNER - ) - role_as.user = owner - role_as.save() - except RoleAssignment.DoesNotExist: - role_as = RoleAssignment( - project=project, - user=owner, - role=Role.objects.get(name=PROJECT_ROLE_OWNER), - ) - role_as.save() - return role_as - @classmethod def _update_settings(cls, project, project_settings): """Update project settings""" @@ -994,9 +973,7 @@ def _notify_users( # Create alerts and send emails owner_as = RoleAssignment.objects.get_assignment(owner, project) # Owner change notification - if request.user != owner and ( - action == PROJECT_ACTION_CREATE or old_data['owner'] != owner - ): + if request.user != owner and action == PROJECT_ACTION_CREATE: if app_alerts: app_alerts.add_alert( app_name=APP_NAME, @@ -1136,9 +1113,14 @@ def modify_project(self, data, request, instance=None): ) # Update owner and settings - self._update_owner(project, owner) + if action == PROJECT_ACTION_CREATE: + RoleAssignment.objects.create( + project=project, + user=owner, + role=Role.objects.get(name=PROJECT_ROLE_OWNER), + ) self._update_settings(project, project_settings) - project.save() + project.save() # TODO: Is this required anymore? # Call for additional actions for project creation/update in plugins if getattr(settings, 'PROJECTROLES_ENABLE_MODIFY_API', False): From feeeeb8cb03cf1b634d0e2b43845b3061f61feed Mon Sep 17 00:00:00 2001 From: Mikko Nieminen Date: Thu, 25 Aug 2022 15:38:10 +0200 Subject: [PATCH 06/17] deprecate ProjectEvent.get_current_status() (#322) --- CHANGELOG.rst | 2 ++ timeline/models.py | 18 ++++++++++++++---- timeline/tests/test_api.py | 6 +++--- timeline/tests/test_models.py | 8 +++----- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2b1de3c5..6b9ef4ca 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -26,6 +26,8 @@ Changed - Do not return ``submit_status`` from project API views (#971) - Remove required ``owner`` argument for ``ProjectUpdateAPIView`` (#1007) - Remove unused owner operations from ``ProjectModifyMixin`` (#1008) +- **Timeline** + - Deprecate ``ProjectEvent.get_current_status()``, use ``get_status()`` (#322) Fixed ----- diff --git a/timeline/models.py b/timeline/models.py index 797ef9bc..5901669b 100644 --- a/timeline/models.py +++ b/timeline/models.py @@ -1,3 +1,6 @@ +"""Models for the timeline app""" + +import logging import uuid from django.conf import settings @@ -6,13 +9,12 @@ # Projectroles dependency from projectroles.models import Project +logger = logging.getLogger(__name__) + # Access Django user model AUTH_USER_MODEL = getattr(settings, 'AUTH_USER_MODEL', 'auth.User') - - # Event status types EVENT_STATUS_TYPES = ['OK', 'INIT', 'SUBMIT', 'FAILED', 'INFO', 'CANCEL'] - DEFAULT_MESSAGES = { 'OK': 'All OK', 'INIT': 'Event initialized', @@ -130,10 +132,18 @@ def get_repr_values(self): self.user.username if self.user else 'N/A', ] - def get_current_status(self): + def get_status(self): """Return the current event status""" return self.status_changes.order_by('-timestamp').first() + def get_current_status(self): + """Return the current event status""" + logger.warning( + 'ProjectEvent.get_current_status() is deprecated and will be ' + 'removed in SODAR Core v0.12: use get_status()' + ) + return self.get_status() + def get_timestamp(self): """Return the timestamp of current status""" return self.status_changes.order_by('-timestamp').first().timestamp diff --git a/timeline/tests/test_api.py b/timeline/tests/test_api.py index f446df8f..8903ca47 100644 --- a/timeline/tests/test_api.py +++ b/timeline/tests/test_api.py @@ -99,7 +99,7 @@ def test_add_event(self): } self.assertEqual(model_to_dict(event), expected) - status = event.get_current_status() + status = event.get_status() expected_status = { 'id': status.pk, 'event': event.pk, @@ -126,7 +126,7 @@ def test_add_event_with_status(self): status_extra_data={}, ) - status = event.get_current_status() + status = event.get_status() self.assertEqual(ProjectEvent.objects.all().count(), 1) self.assertEqual(ProjectEventStatus.objects.all().count(), 2) @@ -173,7 +173,7 @@ def test_add_event_custom_init(self): self.assertEqual(ProjectEvent.objects.all().count(), 1) self.assertEqual(ProjectEventStatus.objects.all().count(), 1) # Init - status = event.get_current_status() + status = event.get_status() expected_status = { 'id': status.pk, 'event': event.pk, diff --git a/timeline/tests/test_models.py b/timeline/tests/test_models.py index 2ea1aba3..eb01a235 100644 --- a/timeline/tests/test_models.py +++ b/timeline/tests/test_models.py @@ -448,10 +448,9 @@ def test__repr__no_user(self): ) self.assertEqual(repr(self.event_status_ok), expected) - def test_get_current_status(self): - """Test the get_current_status() function of ProjectEvent""" - status = self.event.get_current_status() - + def test_get_status(self): + """Test the get_status() function of ProjectEvent""" + status = self.event.get_status() expected = { 'id': self.event_status_ok.pk, 'event': self.event.pk, @@ -459,7 +458,6 @@ def test_get_current_status(self): 'description': 'OK', 'extra_data': {'test_key': 'test_val'}, } - self.assertEqual(model_to_dict(status), expected) def test_get_timestamp(self): From 88ee762016bb8a32dcb28aa95f440f540bc18804 Mon Sep 17 00:00:00 2001 From: Mikko Nieminen Date: Thu, 25 Aug 2022 15:59:17 +0200 Subject: [PATCH 07/17] fix case-sensitive project list ordering (#1006) --- CHANGELOG.rst | 1 + projectroles/views_ajax.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6b9ef4ca..9d2b3798 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -35,6 +35,7 @@ Fixed - **Projectroles** - Crash at exception handling in ``clean_new_owner()`` (#981) - Incorrect button icon in remote site form (#1001) + - Case-sensitive project list sorting (#1006) - **Timeline** - Uncaught exceptions in ``get_plugin_lookup()`` (#979) diff --git a/projectroles/views_ajax.py b/projectroles/views_ajax.py index 0128c939..99962549 100644 --- a/projectroles/views_ajax.py +++ b/projectroles/views_ajax.py @@ -157,7 +157,7 @@ def _get_project_list(cls, user, parent=None): ret.append(p_parent) p_parent = p_parent.parent # Sort by full title - return sorted(ret, key=lambda x: x.full_title) + return sorted(ret, key=lambda x: x.full_title.lower()) def get(self, request, *args, **kwargs): parent_uuid = request.GET.get('parent', None) From b641563e67eee964c6f139254dbec552f8046912 Mon Sep 17 00:00:00 2001 From: Mikko Nieminen Date: Thu, 25 Aug 2022 16:20:20 +0200 Subject: [PATCH 08/17] change filesfolders public_url form label (#1016) --- CHANGELOG.rst | 2 ++ filesfolders/forms.py | 1 + 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9d2b3798..bb0571e0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -20,6 +20,8 @@ Added Changed ------- +- **Filesfolders** + - Change ``public_url`` form label (#1016) - **Projectroles** - Replace Taskflow specific code with project modifying API calls (#387) - Rename ``revoke_failed_invite()`` to ``revoke_invite()`` diff --git a/filesfolders/forms.py b/filesfolders/forms.py index 9122d98d..40d3c538 100644 --- a/filesfolders/forms.py +++ b/filesfolders/forms.py @@ -211,6 +211,7 @@ def __init__( ) if self.instance.pk: self.project = self.instance.project + self.fields['public_url'].label = 'Public link' # Disable public URL creation if setting is false if not app_settings.get_app_setting( From 4f01bf5327972111f2dbc0e37e76a604a8d97acd Mon Sep 17 00:00:00 2001 From: Mikko Nieminen Date: Thu, 25 Aug 2022 16:43:31 +0200 Subject: [PATCH 09/17] add SODARBaseAjaxMixin (#994) --- CHANGELOG.rst | 1 + docs/source/app_projectroles_api_django.rst | 4 ++++ projectroles/views_ajax.py | 17 ++++++++++++----- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index bb0571e0..6da1041c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,7 @@ Added - ``PROJECTROLES_ENABLE_MODIFY_API`` Django setting (#387) - ``PROJECTROLES_MODIFY_API_APPS`` Django setting (#387) - ``syncmodifyapi`` management command (#387) + - ``SODARBaseAjaxMixin`` with ``SODARBaseAjaxView`` functionality (#994) Changed ------- diff --git a/docs/source/app_projectroles_api_django.rst b/docs/source/app_projectroles_api_django.rst index 97046efd..0637c1c8 100644 --- a/docs/source/app_projectroles_api_django.rst +++ b/docs/source/app_projectroles_api_django.rst @@ -119,6 +119,10 @@ Base view classes and mixins for building Ajax API views can be found in .. currentmodule:: projectroles.views_ajax +.. autoclass:: SODARBaseAjaxMixin + :members: + :show-inheritance: + .. autoclass:: SODARBaseAjaxView :members: :show-inheritance: diff --git a/projectroles/views_ajax.py b/projectroles/views_ajax.py index 99962549..e7f46f79 100644 --- a/projectroles/views_ajax.py +++ b/projectroles/views_ajax.py @@ -53,12 +53,10 @@ # Base Classes and Mixins ------------------------------------------------------ -class SODARBaseAjaxView(APIView): +class SODARBaseAjaxMixin: """ - Base Ajax view with Django session authentication. - - No permission classes or mixins used, you will have to supply your own if - using this class directly. + Base Ajax mixin with permission class retrieval. To be used if another base + class instead of SODARBaseAjaxView is needed. The allow_anonymous property can be used to control whether anonymous users should access an Ajax view when PROJECTROLES_ALLOW_ANONYMOUS==True. @@ -79,6 +77,15 @@ def permission_classes(self): return [IsAuthenticated] +class SODARBaseAjaxView(SODARBaseAjaxMixin, APIView): + """ + Base Ajax view with Django session authentication. + + No permission classes or mixins used, you will have to supply your own if + using this class directly. + """ + + class SODARBasePermissionAjaxView(PermissionRequiredMixin, SODARBaseAjaxView): """ Base Ajax view with permission checks, to be used e.g. in site apps with no From 451e0764ff37c10a7751d31a7a65299fd5a301d6 Mon Sep 17 00:00:00 2001 From: Mikko Nieminen Date: Mon, 29 Aug 2022 11:04:42 +0200 Subject: [PATCH 10/17] upgrade dependencies (#303, #1003, #1019) --- .github/workflows/build.yml | 2 +- .gitlab-ci.yml | 2 +- CHANGELOG.rst | 5 + docs/source/conf.py | 2 +- docs/source/dev_core_install.rst | 2 +- docs/source/for_the_impatient.rst | 2 +- docs/source/getting_started.rst | 2 +- docs/source/major_changes.rst | 23 +++ projectroles/__init__.py | 9 +- projectroles/_version.py | 27 ++- .../projectroles/roleassignment_form.html | 13 +- .../roleassignment_owner_transfer.html | 17 +- requirements/base.txt | 28 +-- requirements/ldap.txt | 4 +- requirements/local.txt | 6 +- requirements/test.txt | 14 +- utility/install_postgres.sh | 4 +- versioneer.py | 191 +++++++++++------- 18 files changed, 204 insertions(+), 149 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d34b11d4..80a7a1c6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ jobs: - '3.10' services: postgres: - image: postgres:9.6 + image: postgres:11 env: POSTGRES_DB: sodar_core POSTGRES_USER: sodar_core diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e185d07a..a6631803 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,7 +1,7 @@ image: python:3.8 services: - - postgres:9.6 + - postgres:11 variables: POSTGRES_DB: sodar_core diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6da1041c..71ea0f5f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -21,6 +21,11 @@ Added Changed ------- +- **General** + - Upgrade minimum PostgreSQL version to v11 (#303) + - Upgrade minimum Django version to v3.2.15 (#1003) + - Upgrade to black v22.6.0 (#1003) + - Upgrade general Python dependencies (#1003, #1019) - **Filesfolders** - Change ``public_url`` form label (#1016) - **Projectroles** diff --git a/docs/source/conf.py b/docs/source/conf.py index edc7f8ff..286392b9 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -60,7 +60,7 @@ # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = 'en' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. diff --git a/docs/source/dev_core_install.rst b/docs/source/dev_core_install.rst index 8ef86ff6..c95f2b09 100644 --- a/docs/source/dev_core_install.rst +++ b/docs/source/dev_core_install.rst @@ -20,7 +20,7 @@ system of choice. System Dependencies =================== -First you need to install OS dependencies, PostgreSQL >=9.6 and Python >=3.8. +First you need to install OS dependencies, PostgreSQL >=11 and Python >=3.8. .. code-block:: console diff --git a/docs/source/for_the_impatient.rst b/docs/source/for_the_impatient.rst index 6043bbfb..4fa879bd 100644 --- a/docs/source/for_the_impatient.rst +++ b/docs/source/for_the_impatient.rst @@ -55,7 +55,7 @@ Linux / Mac your system when needed. PostgreSQL - Please install version 9.6 or above. + Please install version 11 or above. We assume that you have access to the ``postgres`` user or some other administrative user. diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst index b5f93675..ef60a721 100644 --- a/docs/source/getting_started.rst +++ b/docs/source/getting_started.rst @@ -58,7 +58,7 @@ your Django site are listed below. For a complete requirement list, see the Django project) - Python >=3.8 (**NOTE:** Python 3.7 no longer supported in SODAR Core v0.10.8+) - Django 3.2 -- PostgreSQL >=9.6 and psycopg2-binary +- PostgreSQL >=11 and psycopg2-binary - Bootstrap 4.x - JQuery 3.3.x - Shepherd and Tether diff --git a/docs/source/major_changes.rst b/docs/source/major_changes.rst index af1c1f71..a5d1549e 100644 --- a/docs/source/major_changes.rst +++ b/docs/source/major_changes.rst @@ -18,6 +18,7 @@ Release Highlights - Remove taskflowbackend app - Add project modifying API to replace built-in taskflowbackend +- Upgrade general dependencies Breaking Changes ================ @@ -67,6 +68,28 @@ Changes: - Do not provide ``submit_status`` in ``ProjectListAPIView`` and ``ProjectRetrieveAPIView``. +System Prerequisites +-------------------- + +Changes in system requirements: + +- PostgreSQL v11 is now the minimum recommended version of the database. +- The minimum Django version has been bumped to v3.2.15. +- General Python dependencies have been upgraded, see ``requirements/*.txt`` + +User Autocomplete Fields Updated +-------------------------------- + +The ``django-autocomplete-light`` dependency has been upgraded to v3.9, which +comes with potential incompatibilities. If you include widgets using DAL in your +site's views, you should upgrade them as follows: + +- Remove DAL related JS and CSS includes from your template (not including any + possible custom event listeners) +- Add ``{{ form.media }}`` to your template if not present. + +For an example, see the ``roleassignment_form.html`` template. + v0.10.13 (2022-07-15) ********************* diff --git a/projectroles/__init__.py b/projectroles/__init__.py index 2943e678..d2b7c4ed 100644 --- a/projectroles/__init__.py +++ b/projectroles/__init__.py @@ -2,15 +2,10 @@ SODAR project and role management """ -from ._version import get_versions +from . import _version # noqa -__version__ = get_versions()['version'] -del get_versions +__version__ = _version.get_versions()['version'] default_app_config = ( 'projectroles.apps.ProjectrolesConfig' # pylint: disable=invalid-name ) - -from . import _version # noqa - -__version__ = _version.get_versions()['version'] diff --git a/projectroles/_version.py b/projectroles/_version.py index cb34852e..3d6fa749 100644 --- a/projectroles/_version.py +++ b/projectroles/_version.py @@ -5,7 +5,7 @@ # that just contains the computed version number. # This file is released into the public domain. Generated by -# versioneer-0.21 (https://github.com/python-versioneer/python-versioneer) +# versioneer-0.23 (https://github.com/python-versioneer/python-versioneer) """Git implementation of _version.py.""" @@ -15,6 +15,7 @@ import subprocess import sys from typing import Callable, Dict +import functools def get_keywords(): @@ -75,6 +76,14 @@ def run_command( """Call the given command(s).""" assert isinstance(commands, list) process = None + + popen_kwargs = {} + if sys.platform == "win32": + # This hides the console window if pythonw.exe is used + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + popen_kwargs["startupinfo"] = startupinfo + for command in commands: try: dispcmd = str([command] + args) @@ -85,6 +94,7 @@ def run_command( env=env, stdout=subprocess.PIPE, stderr=(subprocess.PIPE if hide_stderr else None), + **popen_kwargs, ) break except OSError: @@ -246,10 +256,15 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): version string, meaning we're inside a checked out source tree. """ GITS = ["git"] - TAG_PREFIX_REGEX = "*" if sys.platform == "win32": GITS = ["git.cmd", "git.exe"] - TAG_PREFIX_REGEX = r"\*" + + # GIT_DIR can interfere with correct operation of Versioneer. + # It may be intended to be passed to the Versioneer-versioned project, + # but that should not change where we get our version from. + env = os.environ.copy() + env.pop("GIT_DIR", None) + runner = functools.partial(runner, env=env) _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=True) if rc != 0: @@ -268,7 +283,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): "--always", "--long", "--match", - "%s%s" % (tag_prefix, TAG_PREFIX_REGEX), + f"{tag_prefix}[[:digit:]]*", ], cwd=root, ) @@ -364,8 +379,8 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): else: # HEX: no tags pieces["closest-tag"] = None - count_out, rc = runner(GITS, ["rev-list", "HEAD", "--count"], cwd=root) - pieces["distance"] = int(count_out) # total number of commits + out, rc = runner(GITS, ["rev-list", "HEAD", "--left-right"], cwd=root) + pieces["distance"] = len(out.split()) # total number of commits # commit date: see ISO-8601 comment in git_versions_from_keywords() date = runner(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[ diff --git a/projectroles/templates/projectroles/roleassignment_form.html b/projectroles/templates/projectroles/roleassignment_form.html index 44e382d5..4e38836b 100644 --- a/projectroles/templates/projectroles/roleassignment_form.html +++ b/projectroles/templates/projectroles/roleassignment_form.html @@ -25,6 +25,8 @@

Add Member

{% csrf_token %} + {# Add form.media for DAL #} + {{ form.media }} {{ form | crispy }}
@@ -73,17 +75,8 @@

Add Member

$('.modal-body').html(htmlData); }); }); - - - - + {% endblock javascript %} - -{% block css %} - {{ block.super }} - - -{% endblock css %} diff --git a/projectroles/templates/projectroles/roleassignment_owner_transfer.html b/projectroles/templates/projectroles/roleassignment_owner_transfer.html index 2cd6732e..00244377 100644 --- a/projectroles/templates/projectroles/roleassignment_owner_transfer.html +++ b/projectroles/templates/projectroles/roleassignment_owner_transfer.html @@ -17,8 +17,9 @@

Transfer Project Ownership from User {{ current_owner.username }}

{% csrf_token %} + {# Add form.media for DAL #} + {{ form.media }} {{ form | crispy }} -

{% endblock projectroles_extend %} - -{% block javascript %} - {{ block.super }} - - - - {{ form.media }} -{% endblock javascript %} - -{% block css %} - {{ block.super }} - - -{% endblock css %} diff --git a/requirements/base.txt b/requirements/base.txt index ce2cde3d..9a3a7b39 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -2,16 +2,16 @@ wheel==0.37.1 # Setuptools -setuptools==59.6.0 +setuptools==65.3.0 # Django -django>=3.2.14, <3.3 +django>=3.2.15, <3.3 # Configuration -django-environ>=0.8.1, <0.9 +django-environ>=0.9.0, <0.10 # Forms -django-crispy-forms>=1.13.0, <1.14 +django-crispy-forms>=1.14.0, <1.15 # Models django-model-utils>=4.2.0, <4.3 @@ -26,19 +26,19 @@ psycopg2-binary>=2.9.3, <2.10 awesome-slugify>=1.6.5, <1.7 # Time zones support -pytz>=2021.3 +pytz>=2022.2.1 # SVG icon support -django-iconify==0.1.1 +django-iconify==0.1.1 # NOTE: v0.3 crashes, see issue # Online documentation via django-docs docutils==0.17.1 -Sphinx==4.3.2 +Sphinx==5.1.1 django-docs==0.3.1 sphinx-rtd-theme==1.0.0 # Versioning -versioneer==0.21 +versioneer==0.23 ###################### # Project app imports @@ -48,7 +48,7 @@ versioneer==0.21 -e git+https://github.com/mikkonie/django-plugins.git@42e86e7904e5c09f1da32173862b26843eda5dd8#egg=django-plugins # Rules for permissions -rules>=3.0, <3.1 +rules>=3.3, <3.4 # REST framework djangorestframework>=3.13.1, <3.14 @@ -57,22 +57,22 @@ djangorestframework>=3.13.1, <3.14 -e git+https://github.com/mikkonie/drf-keyed-list.git@b03607b866c5706b0e1ea46a7eeaab6527030734#egg=drf-keyed-list # Token authentication -django-rest-knox>=4.1.0, <4.2 +django-rest-knox>=4.2.0, <4.3 # Markdown field support -markdown==3.3.4 # NOTE: Markdown 3.3.6+ requires Python>=3.10 +markdown==3.4.1 django-markupfield>=2.0.1, <2.1 django-pagedown>=2.2.1, <2.3 -mistune>=2.0.1, <2.1 +mistune>=2.0.4, <2.1 # Database file storage for filesfolders django-db-file-storage==0.5.5 # Backround Jobs requirements -celery>=5.2.3, <5.3 +celery>=5.2.7, <5.3 # Django autocomplete light (DAL) -django-autocomplete-light>=3.8.2, <3.9 +django-autocomplete-light>=3.9.4, <3.10 # SAML2 support for SSO django-saml2-auth-ai>=2.1.6, <2.2 diff --git a/requirements/ldap.txt b/requirements/ldap.txt index e02f596b..12f62643 100644 --- a/requirements/ldap.txt +++ b/requirements/ldap.txt @@ -1,4 +1,4 @@ # Optional LDAP dependencies go here -django-auth-ldap==4.0.0 -python-ldap==3.4.0 +django-auth-ldap==4.1.0 +python-ldap==3.4.2 diff --git a/requirements/local.txt b/requirements/local.txt index 002d0ebe..be8735dc 100644 --- a/requirements/local.txt +++ b/requirements/local.txt @@ -1,10 +1,10 @@ # Local development dependencies go here -r base.txt -django-extensions==3.1.5 -Werkzeug==2.0.2 +django-extensions==3.2.0 +Werkzeug==2.2.2 -django-debug-toolbar>=3.2.4, <3.3 +django-debug-toolbar>=3.6.0, <3.7 # improved REPL ipdb>=0.13.9, <0.14 diff --git a/requirements/test.txt b/requirements/test.txt index 9966d8ba..ce644867 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,27 +1,27 @@ # Test dependencies go here. -r base.txt -flake8==4.0.1 +flake8==5.0.4 django-test-plus==2.2.0 factory-boy==3.2.1 -coverage==6.2 -django-coverage-plugin==2.0.2 +coverage==6.4.4 +django-coverage-plugin==2.0.3 # pytest pytest-django==4.5.2 -pytest-sugar==0.9.4 +pytest-sugar==0.9.5 # Selenium for UI testing -selenium==4.1.0 +selenium==4.4.3 # Tblib for tracebacks tblib==1.7.0 # BeautifulSoup for HTML testing -beautifulsoup4==4.10.0 +beautifulsoup4==4.11.1 # Coverage through Codacy codacy-coverage==1.3.11 # Black for formatting -black==22.3.0 +black==22.6.0 diff --git a/utility/install_postgres.sh b/utility/install_postgres.sh index a4afb8a4..b052f00e 100755 --- a/utility/install_postgres.sh +++ b/utility/install_postgres.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash echo "***********************************************" -echo "Installing PostgreSQL 9.6" +echo "Installing PostgreSQL v11" echo "***********************************************" add-apt-repository -y "deb http://apt.postgresql.org/pub/repos/apt/ focal-pgdg main" wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - apt-get -y update -apt-get -y install postgresql-9.6 +apt-get -y install postgresql-11 diff --git a/versioneer.py b/versioneer.py index b4cd1d6c..2156245a 100644 --- a/versioneer.py +++ b/versioneer.py @@ -1,5 +1,5 @@ -# Version: 0.21 +# Version: 0.23 """The Versioneer - like a rocketeer, but for versions. @@ -9,12 +9,12 @@ * like a rocketeer, but for versions! * https://github.com/python-versioneer/python-versioneer * Brian Warner -* License: Public Domain -* Compatible with: Python 3.6, 3.7, 3.8, 3.9 and pypy3 +* License: Public Domain (CC0-1.0) +* Compatible with: Python 3.7, 3.8, 3.9, 3.10 and pypy3 * [![Latest Version][pypi-image]][pypi-url] * [![Build Status][travis-image]][travis-url] -This is a tool for managing a recorded version number in distutils-based +This is a tool for managing a recorded version number in distutils/setuptools-based python projects. The goal is to remove the tedious and error-prone "update the embedded version string" step from your release process. Making a new release should be as easy as recording a new tag in your version-control @@ -288,6 +288,7 @@ import subprocess import sys from typing import Callable, Dict +import functools class VersioneerConfig: @@ -354,7 +355,7 @@ def get_config_from_root(root): cfg.versionfile_source = section.get("versionfile_source") cfg.versionfile_build = section.get("versionfile_build") cfg.tag_prefix = section.get("tag_prefix") - if cfg.tag_prefix in ("''", '""'): + if cfg.tag_prefix in ("''", '""', None): cfg.tag_prefix = "" cfg.parentdir_prefix = section.get("parentdir_prefix") cfg.verbose = section.get("verbose") @@ -384,6 +385,14 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, """Call the given command(s).""" assert isinstance(commands, list) process = None + + popen_kwargs = {} + if sys.platform == "win32": + # This hides the console window if pythonw.exe is used + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + popen_kwargs["startupinfo"] = startupinfo + for command in commands: try: dispcmd = str([command] + args) @@ -391,7 +400,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, process = subprocess.Popen([command] + args, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=(subprocess.PIPE if hide_stderr - else None)) + else None), **popen_kwargs) break except OSError: e = sys.exc_info()[1] @@ -422,7 +431,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, # that just contains the computed version number. # This file is released into the public domain. Generated by -# versioneer-0.21 (https://github.com/python-versioneer/python-versioneer) +# versioneer-0.23 (https://github.com/python-versioneer/python-versioneer) """Git implementation of _version.py.""" @@ -432,6 +441,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, import subprocess import sys from typing import Callable, Dict +import functools def get_keywords(): @@ -489,6 +499,14 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, """Call the given command(s).""" assert isinstance(commands, list) process = None + + popen_kwargs = {} + if sys.platform == "win32": + # This hides the console window if pythonw.exe is used + startupinfo = subprocess.STARTUPINFO() + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + popen_kwargs["startupinfo"] = startupinfo + for command in commands: try: dispcmd = str([command] + args) @@ -496,7 +514,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, process = subprocess.Popen([command] + args, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=(subprocess.PIPE if hide_stderr - else None)) + else None), **popen_kwargs) break except OSError: e = sys.exc_info()[1] @@ -644,10 +662,15 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): version string, meaning we're inside a checked out source tree. """ GITS = ["git"] - TAG_PREFIX_REGEX = "*" if sys.platform == "win32": GITS = ["git.cmd", "git.exe"] - TAG_PREFIX_REGEX = r"\*" + + # GIT_DIR can interfere with correct operation of Versioneer. + # It may be intended to be passed to the Versioneer-versioned project, + # but that should not change where we get our version from. + env = os.environ.copy() + env.pop("GIT_DIR", None) + runner = functools.partial(runner, env=env) _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=True) @@ -658,11 +681,10 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] # if there isn't one, this yields HEX[-dirty] (no NUM) - describe_out, rc = runner(GITS, ["describe", "--tags", "--dirty", - "--always", "--long", - "--match", - "%%s%%s" %% (tag_prefix, TAG_PREFIX_REGEX)], - cwd=root) + describe_out, rc = runner(GITS, [ + "describe", "--tags", "--dirty", "--always", "--long", + "--match", f"{tag_prefix}[[:digit:]]*" + ], cwd=root) # --long was added in git-1.5.5 if describe_out is None: raise NotThisMethod("'git describe' failed") @@ -751,8 +773,8 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): else: # HEX: no tags pieces["closest-tag"] = None - count_out, rc = runner(GITS, ["rev-list", "HEAD", "--count"], cwd=root) - pieces["distance"] = int(count_out) # total number of commits + out, rc = runner(GITS, ["rev-list", "HEAD", "--left-right"], cwd=root) + pieces["distance"] = len(out.split()) # total number of commits # commit date: see ISO-8601 comment in git_versions_from_keywords() date = runner(GITS, ["show", "-s", "--format=%%ci", "HEAD"], cwd=root)[0].strip() @@ -848,7 +870,7 @@ def render_pep440_pre(pieces): tag_version, post_version = pep440_split_post(pieces["closest-tag"]) rendered = tag_version if post_version is not None: - rendered += ".post%%d.dev%%d" %% (post_version+1, pieces["distance"]) + rendered += ".post%%d.dev%%d" %% (post_version + 1, pieces["distance"]) else: rendered += ".post0.dev%%d" %% (pieces["distance"]) else: @@ -1162,10 +1184,15 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): version string, meaning we're inside a checked out source tree. """ GITS = ["git"] - TAG_PREFIX_REGEX = "*" if sys.platform == "win32": GITS = ["git.cmd", "git.exe"] - TAG_PREFIX_REGEX = r"\*" + + # GIT_DIR can interfere with correct operation of Versioneer. + # It may be intended to be passed to the Versioneer-versioned project, + # but that should not change where we get our version from. + env = os.environ.copy() + env.pop("GIT_DIR", None) + runner = functools.partial(runner, env=env) _, rc = runner(GITS, ["rev-parse", "--git-dir"], cwd=root, hide_stderr=True) @@ -1176,11 +1203,10 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] # if there isn't one, this yields HEX[-dirty] (no NUM) - describe_out, rc = runner(GITS, ["describe", "--tags", "--dirty", - "--always", "--long", - "--match", - "%s%s" % (tag_prefix, TAG_PREFIX_REGEX)], - cwd=root) + describe_out, rc = runner(GITS, [ + "describe", "--tags", "--dirty", "--always", "--long", + "--match", f"{tag_prefix}[[:digit:]]*" + ], cwd=root) # --long was added in git-1.5.5 if describe_out is None: raise NotThisMethod("'git describe' failed") @@ -1269,8 +1295,8 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): else: # HEX: no tags pieces["closest-tag"] = None - count_out, rc = runner(GITS, ["rev-list", "HEAD", "--count"], cwd=root) - pieces["distance"] = int(count_out) # total number of commits + out, rc = runner(GITS, ["rev-list", "HEAD", "--left-right"], cwd=root) + pieces["distance"] = len(out.split()) # total number of commits # commit date: see ISO-8601 comment in git_versions_from_keywords() date = runner(GITS, ["show", "-s", "--format=%ci", "HEAD"], cwd=root)[0].strip() @@ -1282,7 +1308,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): return pieces -def do_vcs_install(manifest_in, versionfile_source, ipy): +def do_vcs_install(versionfile_source, ipy): """Git-specific installation logic for Versioneer. For Git, this means creating/changing .gitattributes to mark _version.py @@ -1291,7 +1317,7 @@ def do_vcs_install(manifest_in, versionfile_source, ipy): GITS = ["git"] if sys.platform == "win32": GITS = ["git.cmd", "git.exe"] - files = [manifest_in, versionfile_source] + files = [versionfile_source] if ipy: files.append(ipy) try: @@ -1344,7 +1370,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): SHORT_VERSION_PY = """ -# This file was generated by 'versioneer.py' (0.21) from +# This file was generated by 'versioneer.py' (0.23) from # revision-control system data, or from the parent directory name of an # unpacked source archive. Distribution tarballs contain a pre-generated copy # of this file. @@ -1473,7 +1499,7 @@ def render_pep440_pre(pieces): tag_version, post_version = pep440_split_post(pieces["closest-tag"]) rendered = tag_version if post_version is not None: - rendered += ".post%d.dev%d" % (post_version+1, pieces["distance"]) + rendered += ".post%d.dev%d" % (post_version + 1, pieces["distance"]) else: rendered += ".post0.dev%d" % (pieces["distance"]) else: @@ -1725,7 +1751,7 @@ def get_version(): def get_cmdclass(cmdclass=None): - """Get the custom setuptools/distutils subclasses used by Versioneer. + """Get the custom setuptools subclasses used by Versioneer. If the package uses a different cmdclass (e.g. one from numpy), it should be provide as an argument. @@ -1747,8 +1773,8 @@ def get_cmdclass(cmdclass=None): cmds = {} if cmdclass is None else cmdclass.copy() - # we add "version" to both distutils and setuptools - from distutils.core import Command + # we add "version" to setuptools + from setuptools import Command class cmd_version(Command): description = "report generated version string" @@ -1771,7 +1797,7 @@ def run(self): print(" error: %s" % vers["error"]) cmds["version"] = cmd_version - # we override "build_py" in both distutils and setuptools + # we override "build_py" in setuptools # # most invocation pathways end up running build_py: # distutils/build -> build_py @@ -1786,13 +1812,14 @@ def run(self): # then does setup.py bdist_wheel, or sometimes setup.py install # setup.py egg_info -> ? + # pip install -e . and setuptool/editable_wheel will invoke build_py + # but the build_py command is not expected to copy any files. + # we override different "build_py" commands for both environments if 'build_py' in cmds: _build_py = cmds['build_py'] - elif "setuptools" in sys.modules: - from setuptools.command.build_py import build_py as _build_py else: - from distutils.command.build_py import build_py as _build_py + from setuptools.command.build_py import build_py as _build_py class cmd_build_py(_build_py): def run(self): @@ -1800,6 +1827,10 @@ def run(self): cfg = get_config_from_root(root) versions = get_versions() _build_py.run(self) + if getattr(self, "editable_mode", False): + # During editable installs `.py` and data files are + # not copied to build_lib + return # now locate _version.py in the new build/ directory and replace # it with an updated value if cfg.versionfile_build: @@ -1811,10 +1842,8 @@ def run(self): if 'build_ext' in cmds: _build_ext = cmds['build_ext'] - elif "setuptools" in sys.modules: - from setuptools.command.build_ext import build_ext as _build_ext else: - from distutils.command.build_ext import build_ext as _build_ext + from setuptools.command.build_ext import build_ext as _build_ext class cmd_build_ext(_build_ext): def run(self): @@ -1832,6 +1861,11 @@ def run(self): # it with an updated value target_versionfile = os.path.join(self.build_lib, cfg.versionfile_build) + if not os.path.exists(target_versionfile): + print(f"Warning: {target_versionfile} does not exist, skipping " + "version update. This can happen if you are running build_ext " + "without first running build_py.") + return print("UPDATING %s" % target_versionfile) write_to_version_file(target_versionfile, versions) cmds["build_ext"] = cmd_build_ext @@ -1893,13 +1927,48 @@ def run(self): }) cmds["py2exe"] = cmd_py2exe + # sdist farms its file list building out to egg_info + if 'egg_info' in cmds: + _sdist = cmds['egg_info'] + else: + from setuptools.command.egg_info import egg_info as _egg_info + + class cmd_egg_info(_egg_info): + def find_sources(self): + # egg_info.find_sources builds the manifest list and writes it + # in one shot + super().find_sources() + + # Modify the filelist and normalize it + root = get_root() + cfg = get_config_from_root(root) + self.filelist.append('versioneer.py') + if cfg.versionfile_source: + # There are rare cases where versionfile_source might not be + # included by default, so we must be explicit + self.filelist.append(cfg.versionfile_source) + self.filelist.sort() + self.filelist.remove_duplicates() + + # The write method is hidden in the manifest_maker instance that + # generated the filelist and was thrown away + # We will instead replicate their final normalization (to unicode, + # and POSIX-style paths) + from setuptools import unicode_utils + normalized = [unicode_utils.filesys_decode(f).replace(os.sep, '/') + for f in self.filelist.files] + + manifest_filename = os.path.join(self.egg_info, 'SOURCES.txt') + with open(manifest_filename, 'w') as fobj: + fobj.write('\n'.join(normalized)) + + cmds['egg_info'] = cmd_egg_info + # we override different "sdist" commands for both environments if 'sdist' in cmds: _sdist = cmds['sdist'] - elif "setuptools" in sys.modules: - from setuptools.command.sdist import sdist as _sdist else: - from distutils.command.sdist import sdist as _sdist + from setuptools.command.sdist import sdist as _sdist class cmd_sdist(_sdist): def run(self): @@ -2024,42 +2093,10 @@ def do_setup(): print(" %s doesn't exist, ok" % ipy) ipy = None - # Make sure both the top-level "versioneer.py" and versionfile_source - # (PKG/_version.py, used by runtime code) are in MANIFEST.in, so - # they'll be copied into source distributions. Pip won't be able to - # install the package without this. - manifest_in = os.path.join(root, "MANIFEST.in") - simple_includes = set() - try: - with open(manifest_in, "r") as f: - for line in f: - if line.startswith("include "): - for include in line.split()[1:]: - simple_includes.add(include) - except OSError: - pass - # That doesn't cover everything MANIFEST.in can do - # (http://docs.python.org/2/distutils/sourcedist.html#commands), so - # it might give some false negatives. Appending redundant 'include' - # lines is safe, though. - if "versioneer.py" not in simple_includes: - print(" appending 'versioneer.py' to MANIFEST.in") - with open(manifest_in, "a") as f: - f.write("include versioneer.py\n") - else: - print(" 'versioneer.py' already in MANIFEST.in") - if cfg.versionfile_source not in simple_includes: - print(" appending versionfile_source ('%s') to MANIFEST.in" % - cfg.versionfile_source) - with open(manifest_in, "a") as f: - f.write("include %s\n" % cfg.versionfile_source) - else: - print(" versionfile_source already in MANIFEST.in") - # Make VCS-specific changes. For git, this means creating/changing # .gitattributes to mark _version.py for export-subst keyword # substitution. - do_vcs_install(manifest_in, cfg.versionfile_source, ipy) + do_vcs_install(cfg.versionfile_source, ipy) return 0 From c596529d3edf210d914baeadb7892977c10ac8c8 Mon Sep 17 00:00:00 2001 From: Mikko Nieminen Date: Mon, 29 Aug 2022 12:03:17 +0200 Subject: [PATCH 11/17] fix issues with get_status() deprecation (#322) --- timeline/models.py | 2 +- timeline/templates/timeline/_list_item.html | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/timeline/models.py b/timeline/models.py index 5901669b..15fcc4f8 100644 --- a/timeline/models.py +++ b/timeline/models.py @@ -142,7 +142,7 @@ def get_current_status(self): 'ProjectEvent.get_current_status() is deprecated and will be ' 'removed in SODAR Core v0.12: use get_status()' ) - return self.get_status() + return self.status_changes.order_by('-timestamp').first() def get_timestamp(self): """Return the timestamp of current status""" diff --git a/timeline/templates/timeline/_list_item.html b/timeline/templates/timeline/_list_item.html index 77456bd0..8d4beb2c 100644 --- a/timeline/templates/timeline/_list_item.html +++ b/timeline/templates/timeline/_list_item.html @@ -50,8 +50,8 @@ {% endif %} - - {{ event.get_current_status.status_type }} + + {{ event.get_status.status_type }} From 924295dd8c0a47a44fc180429b8103f82a7e9db2 Mon Sep 17 00:00:00 2001 From: Mikko Nieminen Date: Mon, 29 Aug 2022 13:00:56 +0200 Subject: [PATCH 12/17] add custom login page include (#982) --- CHANGELOG.rst | 1 + docs/source/major_changes.rst | 9 +++++++++ example_site/templates/include/_login_extend.html | 8 ++++++++ projectroles/templates/projectroles/login.html | 9 ++++++++- 4 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 example_site/templates/include/_login_extend.html diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 71ea0f5f..3e4bd4be 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -17,6 +17,7 @@ Added - ``PROJECTROLES_MODIFY_API_APPS`` Django setting (#387) - ``syncmodifyapi`` management command (#387) - ``SODARBaseAjaxMixin`` with ``SODARBaseAjaxView`` functionality (#994) + - Custom login view content via ``include/_login_extend.html`` (#982) Changed ------- diff --git a/docs/source/major_changes.rst b/docs/source/major_changes.rst index a5d1549e..0a37e72c 100644 --- a/docs/source/major_changes.rst +++ b/docs/source/major_changes.rst @@ -18,6 +18,7 @@ Release Highlights - Remove taskflowbackend app - Add project modifying API to replace built-in taskflowbackend +- Enable including custom content in the login view - Upgrade general dependencies Breaking Changes @@ -90,6 +91,14 @@ site's views, you should upgrade them as follows: For an example, see the ``roleassignment_form.html`` template. +Login Template Updated +---------------------- + +The default login template ``login.html`` has been updated for including +extended content via ``include/_login_extend.html``. If you have overridden the +login template with your own, ensure to update it accordingly to enable this new +functionality. + v0.10.13 (2022-07-15) ********************* diff --git a/example_site/templates/include/_login_extend.html b/example_site/templates/include/_login_extend.html new file mode 100644 index 00000000..67aa687b --- /dev/null +++ b/example_site/templates/include/_login_extend.html @@ -0,0 +1,8 @@ +{# Place extended login view content into this template #} +
+
+ Extended login view content goes here. Add the template + include/_login_extend.html to define extended content on your + SODAR Core based site. +
+
diff --git a/projectroles/templates/projectroles/login.html b/projectroles/templates/projectroles/login.html index a0b8e7d1..7ecde430 100644 --- a/projectroles/templates/projectroles/login.html +++ b/projectroles/templates/projectroles/login.html @@ -27,7 +27,7 @@ {% endif %} -
+ + + {# Optional template for additional login page HTML #} + {% template_exists 'include/_login_extend.html' as login_extend %} + {% if login_extend %} + {% include 'include/_login_extend.html' %} + {% endif %} +
{% endblock content %} From 510ab53c5fafe5209d5cd15bb0d96e6bc603e0cf Mon Sep 17 00:00:00 2001 From: Mikko Nieminen Date: Wed, 31 Aug 2022 14:24:16 +0200 Subject: [PATCH 13/17] fix project list filter trimming (#1021) --- CHANGELOG.rst | 1 + projectroles/static/projectroles/js/project_list.js | 2 +- projectroles/tests/test_ui.py | 12 +++++++++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3e4bd4be..35131bc7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -45,6 +45,7 @@ Fixed - Crash at exception handling in ``clean_new_owner()`` (#981) - Incorrect button icon in remote site form (#1001) - Case-sensitive project list sorting (#1006) + - Project list filtering not trimmed (#1021) - **Timeline** - Uncaught exceptions in ``get_plugin_lookup()`` (#979) diff --git a/projectroles/static/projectroles/js/project_list.js b/projectroles/static/projectroles/js/project_list.js index 2bfae4cc..5b3900f0 100644 --- a/projectroles/static/projectroles/js/project_list.js +++ b/projectroles/static/projectroles/js/project_list.js @@ -278,7 +278,7 @@ $(document).ready(function () { $(document).ready(function () { // Filter input $('#sodar-pr-project-list-filter').keyup(function () { - var v = $(this).val().toLowerCase(); + var v = $(this).val().toLowerCase().trim(); var valFound = false; var starBtn = $('#sodar-pr-project-list-link-star'); if (starBtn.attr('data-star-enabled') === '1') { diff --git a/projectroles/tests/test_ui.py b/projectroles/tests/test_ui.py index 50f7b52e..e7864243 100644 --- a/projectroles/tests/test_ui.py +++ b/projectroles/tests/test_ui.py @@ -548,13 +548,23 @@ def test_project_list_filter(self): url = reverse('home') self.login_and_redirect(self.owner_as.user, url, **self.wait_kwargs) self.assertEqual(self._get_item_vis_count(), 2) - f_input = self.selenium.find_element( By.ID, 'sodar-pr-project-list-filter' ) f_input.send_keys('sub') self.assertEqual(self._get_item_vis_count(), 1) + def test_project_list_filter_trim(self): + """Test filtering project list items with trimming for spaces""" + url = reverse('home') + self.login_and_redirect(self.owner_as.user, url, **self.wait_kwargs) + self.assertEqual(self._get_item_vis_count(), 2) + f_input = self.selenium.find_element( + By.ID, 'sodar-pr-project-list-filter' + ) + f_input.send_keys(' sub ') + self.assertEqual(self._get_item_vis_count(), 1) + def test_project_list_star(self): """Test project list star filter""" self._make_tag( From 72d5fe47bab238b095c78759bb50e438a5d5b1d2 Mon Sep 17 00:00:00 2001 From: Mikko Nieminen Date: Thu, 22 Sep 2022 17:35:11 +0200 Subject: [PATCH 14/17] refactor and cleanup AppSettingAPI (#1023) --- CHANGELOG.rst | 1 + projectroles/app_settings.py | 144 +++++++++++++++-------------------- 2 files changed, 62 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 35131bc7..95733e7a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -35,6 +35,7 @@ Changed - Do not return ``submit_status`` from project API views (#971) - Remove required ``owner`` argument for ``ProjectUpdateAPIView`` (#1007) - Remove unused owner operations from ``ProjectModifyMixin`` (#1008) + - Refactor and cleanup ``AppSettingAPI`` (#1024) - **Timeline** - Deprecate ``ProjectEvent.get_current_status()``, use ``get_status()`` (#322) diff --git a/projectroles/app_settings.py b/projectroles/app_settings.py index 11b3c048..e8fa7d83 100644 --- a/projectroles/app_settings.py +++ b/projectroles/app_settings.py @@ -1,4 +1,4 @@ -"""Project and user settings API""" +"""Projectroles app settings API""" import json import logging @@ -20,6 +20,7 @@ ] # Local constants +APP_SETTING_LOCAL_DEFAULT = True VALID_SCOPES = [ APP_SETTING_SCOPE_PROJECT, APP_SETTING_SCOPE_USER, @@ -75,9 +76,6 @@ }, } -# Default value for the "local" flag in app settings -APP_SETTING_LOCAL_DEFAULT = True - class AppSettingAPI: @classmethod @@ -141,16 +139,10 @@ def _check_type_options(cls, setting_type, setting_options): :param setting_options: List of options (Strings or Integers) :raise: ValueError if type is not recognized """ - if ( - setting_type - not in ( - 'INTEGER', - 'STRING', - ) - and setting_options - ): + if setting_type not in ['INTEGER', 'STRING'] and setting_options: raise ValueError( - 'Options are only allowed for settings of type INTEGER and STRING' + 'Options are only allowed for settings of type INTEGER and ' + 'STRING' ) @classmethod @@ -169,6 +161,28 @@ def _check_value_in_options(cls, setting_value, setting_options): ) ) + @classmethod + def _get_defs(cls, plugin=None, app_name=None): + """ + Ensure valid argument values for a settings def query. + + :param plugin: Plugin object extending ProjectAppPluginPoint or None + :param app_name: Name of the app plugin (string or None) + :return: Dict + :raise: ValueError if args are not valid or plugin is not found + """ + if not plugin and not app_name: + raise ValueError('Plugin and app name both unset') + if app_name == 'projectroles': + return cls.get_projectroles_defs() + if not plugin: + plugin = get_app_plugin(app_name) + if not plugin: + raise ValueError( + 'Plugin not found with app name "{}"'.format(app_name) + ) + return plugin.app_settings + @classmethod def _get_json_value(cls, value): """ @@ -293,7 +307,6 @@ def get_all_settings(cls, project=None, user=None, post_safe=False): ret = {} app_plugins = get_active_plugins() - for plugin in app_plugins: p_settings = cls.get_setting_defs( APP_SETTING_SCOPE_PROJECT, plugin=plugin @@ -389,15 +402,14 @@ def _log_debug(action, app_name, setting_name, value, project, user): raise ValueError('Project and user are both unset') try: - query_parameters = { + q_kwargs = { 'name': setting_name, 'project': project, 'user': user, } if not app_name == 'projectroles': - query_parameters['app_plugin__name'] = app_name - - setting = AppSetting.objects.get(**query_parameters) + q_kwargs['app_plugin__name'] = app_name + setting = AppSetting.objects.get(**q_kwargs) if cls._compare_value(setting, value): return False @@ -413,7 +425,6 @@ def _log_debug(action, app_name, setting_name, value, project, user): setting.value_json = cls._get_json_value(value) else: setting.value = value - setting.save() _log_debug('Set', app_name, setting_name, value, project, user) return True @@ -426,14 +437,12 @@ def _log_debug(action, app_name, setting_name, value, project, user): app_plugin = get_app_plugin(app_name) app_settings = app_plugin.app_settings app_plugin_model = app_plugin.get_model() - if setting_name not in app_settings: raise KeyError( 'Setting "{}" not found in app plugin "{}"'.format( setting_name, app_name ) ) - s_def = app_settings[setting_name] s_type = s_def['type'] s_mod = ( @@ -444,7 +453,6 @@ def _log_debug(action, app_name, setting_name, value, project, user): cls._check_scope(s_def['scope']) cls._check_project_and_user(s_def['scope'], project, user) - if validate: v = cls._get_json_value(value) if s_type == 'JSON' else value setting_def = cls.get_setting_def( @@ -460,7 +468,6 @@ def _log_debug(action, app_name, setting_name, value, project, user): 'type': s_type, 'user_modifiable': s_mod, } - if s_type == 'JSON': s_vals['value_json'] = cls._get_json_value(value) else: @@ -472,40 +479,40 @@ def _log_debug(action, app_name, setting_name, value, project, user): @classmethod def delete_setting(cls, app_name, setting_name, project=None, user=None): - """Delete app setting. + """ + Delete app setting. :param app_name: App name (string, must equal "name" in app plugin) :param setting_name: Setting name (string) :param project: Project object to delete setting from (optional) :param user: User object to delete setting from (optional) """ - setting_def = cls.get_setting_def(setting_name, app_name=app_name) scope = setting_def.get('scope') - query_parameters = {'name': setting_name} - if scope == 'USER' and project: raise ValueError('App setting scope is USER but project is set.') elif scope == 'PROJECT' and user: raise ValueError('App setting scope is PROJECT but user is set.') + q_kwargs = {'name': setting_name} if user: - query_parameters['user'] = user + q_kwargs['user'] = user if project: - query_parameters['project'] = project + q_kwargs['project'] = project logger.debug( - 'Request to delete app setting: {}.{} with query parameters {}'.format( - app_name, - setting_name, - query_parameters, + 'Delete app setting: {}.{} ({})'.format( + app_name, setting_name, '; '.join(q_kwargs) ) ) - - app_settings = AppSetting.objects.filter(**query_parameters) - logger.debug('Deleting {} app setting(s)'.format(app_settings.count())) - # TODO: once sodar_core issue #119 is implemented, add timeline logging. + app_settings = AppSetting.objects.filter(**q_kwargs) + s_count = app_settings.count() app_settings.delete() + logger.debug( + 'Deleted {} app setting{}'.format( + s_count, 's' if s_count != 1 else '' + ) + ) @classmethod def validate_setting(cls, setting_type, setting_value, setting_options): @@ -528,7 +535,6 @@ def validate_setting(cls, setting_type, setting_value, setting_options): setting_value ) ) - elif setting_type == 'INTEGER': if ( not isinstance(setting_value, int) @@ -539,7 +545,6 @@ def validate_setting(cls, setting_type, setting_value, setting_options): setting_value ) ) - elif setting_type == 'JSON': try: json.dumps(setting_value) @@ -547,7 +552,6 @@ def validate_setting(cls, setting_type, setting_value, setting_options): raise ValueError( 'Please enter valid JSON ({})'.format(setting_value) ) - return True @classmethod @@ -557,74 +561,49 @@ def get_setting_def(cls, name, plugin=None, app_name=None): or the plugin object. :param name: Setting name - :param plugin: Plugin object extending ProjectAppPluginPoint - :param app_name: Name of the app plugin (string) + :param plugin: Plugin object extending ProjectAppPluginPoint or None + :param app_name: Name of the app plugin (string or None) :return: Dict :raise: ValueError if neither app_name or plugin are set or if setting is not found in plugin """ - if not plugin and not app_name: - raise ValueError('Plugin and app name both unset') - if app_name == 'projectroles': - app_settings = cls.get_projectroles_defs() - else: - if not plugin: - plugin = get_app_plugin(app_name) - if not plugin: - raise ValueError( - 'Plugin not found with app name "{}"'.format(app_name) - ) - app_settings = plugin.app_settings - - if name not in app_settings: + defs = cls._get_defs(plugin, app_name) + if name not in defs: raise ValueError( 'App setting not found in app "{}" with name "{}"'.format( app_name or plugin.name, name ) ) - - setting_def = app_settings[name] - cls._check_type(setting_def['type']) - cls._check_type_options(setting_def['type'], setting_def.get('options')) - return setting_def + ret = defs[name] + cls._check_type(ret['type']) + cls._check_type_options(ret['type'], ret.get('options')) + return ret @classmethod def get_setting_defs( cls, scope, - plugin=False, - app_name=False, + plugin=None, + app_name=None, user_modifiable=False, ): """ Return app setting definitions of a specific scope from a plugin. :param scope: PROJECT, USER or PROJECT_USER - :param plugin: project app plugin object extending ProjectAppPluginPoint - :param app_name: Name of the app plugin (string) + :param plugin: Plugin object extending ProjectAppPluginPoint or None + :param app_name: Name of the app plugin (string or None) :param user_modifiable: Only return modifiable settings if True (boolean) :return: Dict :raise: ValueError if scope is invalid or if if neither app_name or plugin are set """ - if not plugin and not app_name: - raise ValueError('Plugin and app name both unset') - if app_name == 'projectroles': - app_settings = cls.get_projectroles_defs() - else: - if not plugin: - plugin = get_app_plugin(app_name) - if not plugin: - raise ValueError( - 'Plugin not found with app name "{}"'.format(app_name) - ) - app_settings = plugin.app_settings - cls._check_scope(scope) - setting_defs = { + defs = cls._get_defs(plugin, app_name) + ret = { k: v - for k, v in app_settings.items() + for k, v in defs.items() if ( 'scope' in v and v['scope'] == scope @@ -637,12 +616,11 @@ def get_setting_defs( ) ) } - # Ensure type validity - for k, v in setting_defs.items(): + for k, v in ret.items(): cls._check_type(v['type']) cls._check_type_options(v['type'], v.get('options')) - return setting_defs + return ret @classmethod def get_projectroles_defs(cls): From f0be5834ede7b66f0b6abb524fc0d8fe8e013f71 Mon Sep 17 00:00:00 2001 From: Mikko Nieminen Date: Fri, 23 Sep 2022 12:05:01 +0200 Subject: [PATCH 15/17] drop codacy support (#1022), add coveralls support (#1026) --- .github/workflows/build.yml | 10 +++++----- CHANGELOG.rst | 4 ++++ README.rst | 7 ++----- requirements/test.txt | 6 +++--- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 80a7a1c6..a9c0f29a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -52,15 +52,15 @@ jobs: - name: Run tests run: | coverage run --rcfile=.coveragerc manage.py test -v 2 --settings=config.settings.test - coverage xml + coverage lcov coverage report - name: Check linting run: flake8 . - name: Check formatting run: make black arg=--check - - name: Run Codacy coverage reporter - uses: codacy/codacy-coverage-reporter-action@master + - name: Report coverage with Coveralls + uses: coverallsapp/github-action@master with: - project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} - coverage-reports: coverage.xml + github-token: ${{ secrets.GITHUB_TOKEN }} + path-to-lcov: './coverage.lcov' if: ${{ matrix.python-version == '3.8' }} diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 95733e7a..15c8a814 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,6 +11,8 @@ Unreleased Added ----- +- **General** + - Coverage reporting with Coveralls (#1026) - **Projectroles** - Project modifying API in ``ProjectModifyPluginMixin`` (#387) - ``PROJECTROLES_ENABLE_MODIFY_API`` Django setting (#387) @@ -53,6 +55,8 @@ Fixed Removed ------- +- **General** + - Codacy support (#1022) - **Projectroles** - Taskflow specific views, tests and API calls (#387) - ``get_taskflow_sync_data()`` method from ``ProjectAppPluginPoint`` (#387) diff --git a/README.rst b/README.rst index d24cd6db..3df3bcbb 100644 --- a/README.rst +++ b/README.rst @@ -7,11 +7,8 @@ SODAR Core .. image:: https://github.com/bihealth/sodar-core/actions/workflows/build.yml/badge.svg :target: https://github.com/bihealth/sodar-core/actions?query=workflow%3ABuild -.. image:: https://app.codacy.com/project/badge/Grade/6ba6b44ee37642918c7ff7a44d413982 - :target: https://www.codacy.com/gh/bihealth/sodar-core/dashboard - -.. image:: https://app.codacy.com/project/badge/Coverage/6ba6b44ee37642918c7ff7a44d413982 - :target: https://www.codacy.com/gh/bihealth/sodar-core/dashboard +.. image:: https://coveralls.io/repos/github/bihealth/sodar-core/badge.svg?branch=main + :target: https://coveralls.io/github/bihealth/sodar-core?branch=update/codacy .. image:: https://img.shields.io/badge/License-MIT-green.svg :target: https://opensource.org/licenses/MIT diff --git a/requirements/test.txt b/requirements/test.txt index ce644867..c627b273 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -20,8 +20,8 @@ tblib==1.7.0 # BeautifulSoup for HTML testing beautifulsoup4==4.11.1 -# Coverage through Codacy -codacy-coverage==1.3.11 - # Black for formatting black==22.6.0 + +# Coveralls for coverage reporting +coveralls==3.3.1 From 888d594d342984ab430e5e2d624c0cfda68d2eaa Mon Sep 17 00:00:00 2001 From: Mikko Nieminen Date: Fri, 23 Sep 2022 15:33:34 +0200 Subject: [PATCH 16/17] cleanup for v0.11.0 release (#978, #1017) --- .github/ISSUE_TEMPLATE/feature_request.md | 1 + .github/workflows/build.yml | 2 + docs/source/app_appalerts.rst | 11 +- docs/source/app_projectroles_api_django.rst | 7 +- docs/source/app_projectroles_custom.rst | 9 ++ docs/source/app_sodarcache.rst | 2 +- docs/source/app_sodarcache_api_django.rst | 15 +- docs/source/app_sodarcache_usage.rst | 34 ++--- docs/source/app_timeline_api_django.rst | 11 +- docs/source/contributing.rst | 6 +- docs/source/dev_core_install.rst | 17 ++- docs/source/dev_resource.rst | 35 ++--- docs/source/for_the_impatient.rst | 159 +++----------------- docs/source/index.rst | 20 +-- timeline/api.py | 1 + 15 files changed, 125 insertions(+), 205 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 98f43a3e..e688c2fa 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -19,6 +19,7 @@ A clear and concise description of what you want to happen and how this feature should work. ### Alternative Solutions + A clear and concise description of any alternative solutions or features you've considered. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a9c0f29a..2c6c771e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -56,8 +56,10 @@ jobs: coverage report - name: Check linting run: flake8 . + if: ${{ matrix.python-version == '3.8' }} - name: Check formatting run: make black arg=--check + if: ${{ matrix.python-version == '3.8' }} - name: Report coverage with Coveralls uses: coverallsapp/github-action@master with: diff --git a/docs/source/app_appalerts.rst b/docs/source/app_appalerts.rst index 17c9b26b..6081300e 100644 --- a/docs/source/app_appalerts.rst +++ b/docs/source/app_appalerts.rst @@ -145,8 +145,15 @@ accompanying API documentation for details. Backend Django API Documentation ================================ -This is the backend API, retrievable with -``get_backend_api('appalerts_backend')``. +The backend API can be retrieved as follows. + +.. code-block:: python + + from projectroles.plugins import get_backend_api + app_alerts = get_backend_api('appalerts_backend') + +Make sure to also enable ``appalerts_backend`` in the +``ENABLED_BACKEND_PLUGINS`` Django setting. .. currentmodule:: appalerts.api diff --git a/docs/source/app_projectroles_api_django.rst b/docs/source/app_projectroles_api_django.rst index 0637c1c8..9088794a 100644 --- a/docs/source/app_projectroles_api_django.rst +++ b/docs/source/app_projectroles_api_django.rst @@ -35,7 +35,12 @@ App Settings ============ Projectroles provides an API for getting or setting project and user specific -settings. +settings. The API can be invoked as follows: + +.. code-block:: python + + from projectroles.app_settings import AppSettingAPI + app_settings = AppSettingAPI() .. autoclass:: projectroles.app_settings.AppSettingAPI :members: diff --git a/docs/source/app_projectroles_custom.rst b/docs/source/app_projectroles_custom.rst index c466328a..010e9f32 100644 --- a/docs/source/app_projectroles_custom.rst +++ b/docs/source/app_projectroles_custom.rst @@ -92,6 +92,15 @@ documentation links or linking to external sites. Example: +Extra Login View Content +======================== + +If you want to provide extra content in your site's login view, you can add +custom HTML into the template file +``{SITE_NAME}/templates/include/_login_extend.html``. The content will appear +below the login form and its format is not restricted. + + Site Logo ========= diff --git a/docs/source/app_sodarcache.rst b/docs/source/app_sodarcache.rst index 1bd42837..54f6383f 100644 --- a/docs/source/app_sodarcache.rst +++ b/docs/source/app_sodarcache.rst @@ -15,4 +15,4 @@ queries to databases other than the local Django PostgreSQL. Installation Usage - Django API + Django API Documentation diff --git a/docs/source/app_sodarcache_api_django.rst b/docs/source/app_sodarcache_api_django.rst index 39afb51b..dd1b98b6 100644 --- a/docs/source/app_sodarcache_api_django.rst +++ b/docs/source/app_sodarcache_api_django.rst @@ -1,8 +1,8 @@ .. _app_sodarcache_api_django: -Sodarcache Backend API Documentation -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Sodarcache Django API Documentation +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ This document contains Django API documentation for the backend plugin in the ``sodarcache`` app. Included are functionalities and classes intended to be used @@ -12,8 +12,15 @@ by other applications. Backend API =========== -The ``SodarCacheAPI`` class contains the Sodar Cache backend API. It should be -initialized with ``Projectroles.plugins.get_backend_api('sodar_cache')``. +The backend API can be retrieved as follows. + +.. code-block:: python + + from projectroles.plugins import get_backend_api + app_alerts = get_backend_api('sodar_cache') + +Make sure to also enable ``sodar_cache`` in the ``ENABLED_BACKEND_PLUGINS`` +Django setting. .. autoclass:: sodarcache.api.SodarCacheAPI :members: diff --git a/docs/source/app_sodarcache_usage.rst b/docs/source/app_sodarcache_usage.rst index f4829083..3ad27394 100644 --- a/docs/source/app_sodarcache_usage.rst +++ b/docs/source/app_sodarcache_usage.rst @@ -10,8 +10,8 @@ Usage instructions for the ``sodarcache`` app are detailed in this document. Backend API for Data Caching ============================ -The Django backend API for caching data is located in ``sodarcache.api``. For -the full documentation, see `here `_. +The Django backend API for caching data is located in ``sodarcache.api``. +Details of the API can be found in :ref:`app_sodarcache_api_django`. Invoking the API ---------------- @@ -25,12 +25,9 @@ Initialize the API using ``projectroles.plugins.get_backend_api()`` as follows: .. code-block:: python from projectroles.plugins import get_backend_api - projectcache = get_backend_api('sodar_cache') + cache_backend = get_backend_api('sodar_cache') - if projectcache: # Only proceed if the backend was successfully initialized - pass - -Setting and getting Cache Items +Setting and Getting Cache Items ------------------------------- Once you can access the sodarcache backend, you should set up the @@ -57,7 +54,7 @@ example is as follows: .. code-block:: python - cache_item = projectcache.set_cache_item( + cache_item = cache_backend.set_cache_item( project=project, # Project object app_name=APP_NAME, # Name of the current app user=request.user, # The user triggering the cache update @@ -71,37 +68,39 @@ example is as follows: The item ID in the ``name`` argument is not unique, but it is expected to be unique together with the ``project`` and ``app_name`` arguments. -Retrieve items with ``sodarcache.get_cache_item()`` or just check the -time the item was last updated with ``sodarcache.get_update_time()`` like +Retrieve items with ``get_cache_item()`` or just check the +time the item was last updated with ``get_update_time()`` like this: .. code-block:: python - projectcache.get_cache_item( + cache_backend.get_cache_item( app_name='yourapp', name='some_item', project=project, data_type='json' ) # Returns a JsonCacheItem - projectcache.get_update_time( + cache_backend.get_update_time( app_name='yourapp', name='some_item', project=project ) It is also possible to retrieve a Queryset with all cached items for a specific -project with ``sodarcache.get_project_cache()`` +project with ``get_project_cache()``. .. code-block:: python - projectcache.get_project_cache( + cache_backend.get_project_cache( project=project, # Project object data_type='json' # must be 'json' for JsonCacheItem ) -Using the Management commands ------------------------------ + +Management Commands +=================== + To create or update the data cache for all apps and projects, you can use a management command. @@ -121,6 +120,3 @@ Similarly, there is a command to delete all cached data: .. code-block:: console $ ./manage.py deletecache - - - diff --git a/docs/source/app_timeline_api_django.rst b/docs/source/app_timeline_api_django.rst index 77bd29b0..37b501af 100644 --- a/docs/source/app_timeline_api_django.rst +++ b/docs/source/app_timeline_api_django.rst @@ -12,8 +12,15 @@ applications. Backend API =========== -The ``TimelineAPI`` class contains the Timeline backend API. It should be -initialized using the ``Projectroles.plugins.get_backend_api()`` function. +The backend API can be retrieved as follows. + +.. code-block:: python + + from projectroles.plugins import get_backend_api + app_alerts = get_backend_api('timeline_backend') + +Make sure to also enable ``timeline_backend`` in the ``ENABLED_BACKEND_PLUGINS`` +Django setting. .. autoclass:: timeline.api.TimelineAPI :members: diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst index 14eacf38..9dd02814 100644 --- a/docs/source/contributing.rst +++ b/docs/source/contributing.rst @@ -59,6 +59,8 @@ If you are proposing a feature: - Follow the provided template. - Explain in detail how it would work. - Keep the scope as narrow as possible, to make it easier to implement. +- Separate multiple suggestions into separate issues, avoiding "umbrella + tickets" and ensuring simple assignment and follow-up. - Remember that this is a volunteer-driven project, and that contributions are welcome :) @@ -102,7 +104,7 @@ Ready to contribute code to SODAR Core? Here are the steps to get started. 7. Submit a pull request through the GitHub website. -For specific requirements and recommendations regarding work branches, commits, -pull requirements, see :ref:`dev_core_guide`. +For specific requirements and recommendations on work branches, commits and pull +requirements, see :ref:`dev_core_guide`. For guidelines regarding the code itself, see :ref:`dev_core_guide_code`. diff --git a/docs/source/dev_core_install.rst b/docs/source/dev_core_install.rst index c95f2b09..bd5132d5 100644 --- a/docs/source/dev_core_install.rst +++ b/docs/source/dev_core_install.rst @@ -16,11 +16,17 @@ If you need to set up the accompanying example site in Docker, please see online for up-to-date Docker setup tutorials for Django related to your operating system of choice. +.. note:: + + These instructions are also valid for the + `sodar-django-site `_ + repository. + System Dependencies =================== -First you need to install OS dependencies, PostgreSQL >=11 and Python >=3.8. +To get started, install the OS dependencies, PostgreSQL >=11 and Python >=3.8. .. code-block:: console @@ -51,19 +57,18 @@ Example of the database URL variable as set within an ``.env`` file: .. code-block:: console - DATABASE_URL=postgres://sodar_core:sodar_core@127.0.0.1/sodar_core + DATABASE_URL=postgres://your-db:your-db@127.0.0.1/your-db Repository and Environment Setup ================================ -Clone the repository, setup and activate the virtual environment. Once in -the environment, install Python requirements for the project: +Clone the repository, setup and activate the virtual environment. Once within +the repository and an active virtual environment, install Python requirements +for the project. Example: .. code-block:: console - $ git clone https://github.com/bihealth/sodar-core.git - $ cd sodar-core $ python3.x -m venv .venv $ source .venv/bin/activate $ utility/install_python_dependencies.sh diff --git a/docs/source/dev_resource.rst b/docs/source/dev_resource.rst index de0e9a85..6cce4498 100644 --- a/docs/source/dev_resource.rst +++ b/docs/source/dev_resource.rst @@ -118,31 +118,22 @@ For more examples of usage of this field and its widget, see retrieve the related widget to your own field with ``projectroles.forms.get_user_widget()``. -The following ``django-autocomplete-light`` and ``select2`` CSS and Javascript -links have to be added to the HTML template that includes the form with your -user selection field: +To provide required Javascript and CSS includes for DAL in your form, make sure +to include ``form.media`` in your template. Example: .. code-block:: django - {% block javascript %} - {{ block.super }} - - - - - - {% endblock javascript %} - - {% block css %} - {{ block.super }} - - - {% endblock css %} - -If using a customized widget with its own Javascript, include the corresponding -JS file instead of ``autocomplete_light/select2.js``. See the -``django-autocomplete-light`` documentation for more information on how to -customize your autocomplete-widget. +
+ + {{ form.media }} + {{ form | crispy }} + {% ... %} + +
+ +If using customized Javascript for your widget, the corresponding JS file can be +provided in the ``javascript`` block. See the ``django-autocomplete-light`` +documentation for more information on how to customize your widget. Markdown -------- diff --git a/docs/source/for_the_impatient.rst b/docs/source/for_the_impatient.rst index 4fa879bd..ea9018bb 100644 --- a/docs/source/for_the_impatient.rst +++ b/docs/source/for_the_impatient.rst @@ -47,65 +47,6 @@ in complex web applications. instance yet. -Prerequisites -============= - -Linux / Mac - We use bash syntax on a Unix system and assume that you can adjust this to - your system when needed. - -PostgreSQL - Please install version 11 or above. - We assume that you have access to the ``postgres`` user or some other - administrative user. - -Development Essentials - We assume that you have ``git``, Python 3.8 or above, and other essential - tools installed. - If you are using a mainstream Unix-like distribution (Mac qualifies) then - you should be good to go. - -.. info: - - In the case that you get an error as follows when installing the - dependencies, make sure that you have the development libraries of postgres - installed. E.g., on Debian-based systems install ``postgresql-dev``, for Red - Hat and CentOS install ``postgresql-devel``. - - :: - - Error: pg_config executable not found. - - -Isolate Python Environment -========================== - -If you use `virtualenv `_, -please create a new virtual environment for the project and activate it. -Otherwise, follow the previous link and do this now or you can follow along us -using `conda `_. - -The following creates a new Miniconda installation on 64 bit Linux or Mac. -The `Miniconda `_ website has -URLs to more. - -.. code-block:: bash - - # Linux - $ wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh - $ bash Miniconda3-latest-Linux-x86_64.sh -b -p ~/miniconda3 - $ source ~/miniconda3/bin/activate - $ conda install -y python=3.8 - - # Mac - $ wget https://repo.anaconda.com/miniconda/Miniconda3-latest-MacOSX-x86_64.sh - $ bash Miniconda3-latest-MacOSX-x86_64.sh -b -p ~/miniconda3 - $ source ~/miniconda3/bin/activate - $ conda install -y python=3.8 - -For activating the conda installation, use ``source ~/miniconda3/bin/activate``. - - Download Example Site ===================== @@ -114,88 +55,31 @@ example Django site, which installs SODAR Core and also sets up a default web site around it. We maintain a Git repository with a django project using the latest SODAR Core -version here on GitHub: `sodar-django-site `_. +version here on GitHub: +`sodar-django-site `_. See :ref:`app_projectroles_integration` on other ways to get started with SODAR Core. -.. code-block:: bash - - $ git clone https://github.com/bihealth/sodar-django-site.git sodar-django-site - $ cd sodar-django-site - -From here on, we assume that you are located (a) within the -``sodar-django-site`` directory and (b) have done -``source ~/miniconda3/bin/activate`` such that ``which python`` shows -``~/miniconda3/bin/python``. - -To complete this step install the development requirements. +To clone the example site, do the following: .. code-block:: bash - # you must have your miniconda3 install sourced, skip if done already - $ source ~/miniconda3/bin/activate - $ pip install -r requirements/local.txt - - -Configure Environment -===================== - -The next step is to perform some configuration. -SODAR Core is built on the `12 factor app `_ principles. -Configuration is done using environment variables. -For development, they are read from the ``.env`` file in your -``sodar-django-site`` checkout. -We are shipping an example setting file that you should copy and then edit. - -.. code-block:: bash - - $ cp env.example .env - # now edit .env - -To start out, it will be sufficient to make sure you can connect to the database. -The default value for this is shown below. - -.. code-block:: bash - - DATABASE_URL="postgres://sodar-django-site:sodar-django-site@127.0.0.1/sodar-django-site" - -You can use the following commands to create the correct database, user, and set -the password. Alternatively, you can run the ``utility/setup_database.sh`` -script and fill out the values as prompted. - -.. code-block:: bash - - $ sudo -u postgres createuser -ds sodar-django-site -W - [sudo] password for USER: - Password: - $ sudo -u postgres createdb --owner=sodar-django-site sodar-django-site - -Now, we have to make sure that the environment file is read: - -.. code-block:: bash - - $ sed -ie "s/^READ_DOT_ENV_FILE.*/READ_DOT_ENV_FILE = env.bool('DJANGO_READ_DOT_ENV_FILE', default=True)/" config/settings/base.py - - -Database Initialization -======================= + $ git clone https://github.com/bihealth/sodar-django-site.git + $ cd sodar-django-site -For the final steps, you will need to initialize the database: -.. code-block:: bash - - $ python manage.py migrate +Example Site Setup +================== -Finally, create an admin user: +The process of installing required dependencies and setting up your example site +is the same as for SODAR Core itself. For a step-by-step guide, see +:ref:`dev_core_install`. -.. code-block:: bash +.. note:: - $ python manage.py createsuperuser - Username: admin - Email address: admin@example.com - Password: - Password (again): - Superuser created successfully. + This guide and associated helper scripts are targeting Ubuntu 20.04. For + other Linux distributions or operating systems, you may have to adjust them + as required. This is out of the scope of this documentation. The First Login @@ -208,12 +92,7 @@ browser to navigate to the following URL: http://127.0.0.1:8000 $ make serve python manage.py runserver --settings=config.settings.local - Watching for file changes with StatReloader - Performing system checks... - - System check identified no issues (0 silenced). - July 01, 2021 - 13:06:06 - Django version 3.2.5, using settings 'config.settings.local' + (...) Starting development server at http://127.0.0.1:8000/ Quit the server with CONTROL-C. @@ -223,6 +102,8 @@ You should see the following: :align: center :scale: 50% + Login view + Login with the superuser account you created. Afterwards you are redirected to your home view: @@ -230,6 +111,8 @@ your home view: :align: center :scale: 50% + Project list + By clicking the user icon on the top right corner you can access the Django admin (where you can create more users, for example) but also the preconfigured :term:`site apps ` :ref:`Adminalerts `, @@ -257,6 +140,8 @@ follows. :align: center :scale: 50% + Project details view + At this point you can test the search functionality. Typing "example" into the text field on the top bar and clicking :guilabel:`Search` will return your example project. The project overview shows the *overview card* for installed @@ -278,6 +163,8 @@ uploading a few files, you will see a trace of actions in the Timeline app: :align: center :scale: 50% + Timeline app + .. note:: By default, ``sodar-django-site`` will store the files in the PostgreSQL diff --git a/docs/source/index.rst b/docs/source/index.rst index b7becea2..b0bd5a5b 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -117,16 +117,16 @@ HTML / Javascript / CSS / Bootstrap 4 :name: sodar_core_apps :hidden: - app_projectroles - app_adminalerts - app_appalerts - app_bgjobs - app_filesfolders - app_siteinfo - app_sodarcache - app_timeline - app_tokens - app_userprofile + Projectroles + Adminalerts + Appalerts + Bgjobs + Filesfolders + Siteinfo + Sodarcache + Timeline + Tokens + Userprofile .. toctree:: :maxdepth: 2 diff --git a/timeline/api.py b/timeline/api.py index 4ee517a1..7ffc7e5e 100644 --- a/timeline/api.py +++ b/timeline/api.py @@ -1,4 +1,5 @@ """Timeline API for adding and updating events""" + import logging import re From 9bddcef52e65df8f6e4fbdb5ade215d9f9f524d7 Mon Sep 17 00:00:00 2001 From: Mikko Nieminen Date: Fri, 23 Sep 2022 16:47:34 +0200 Subject: [PATCH 17/17] prepare v0.11.0 release (#978) --- CHANGELOG.rst | 4 ++-- README.rst | 2 +- codemeta.json | 6 ++--- docs/source/app_projectroles_integration.rst | 2 +- docs/source/conf.py | 2 +- docs/source/getting_started.rst | 2 +- docs/source/major_changes.rst | 4 ++-- projectroles/views_api.py | 25 +------------------- 8 files changed, 12 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 15c8a814..975c3003 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,8 +5,8 @@ Changelog for the **SODAR Core** Django app package. Loosely follows the `Keep a Changelog `_ guidelines. -Unreleased -========== +v0.11.0 (2022-09-23) +==================== Added ----- diff --git a/README.rst b/README.rst index 3df3bcbb..437a8199 100644 --- a/README.rst +++ b/README.rst @@ -115,7 +115,7 @@ and breaking changes are possible. .. code-block:: console - pip install django-sodar-core==0.10.13 + pip install django-sodar-core==0.11.0 For installing a development version you can point your dependency to a specific commit ID in GitHub. Note that these versions may not be stable. diff --git a/codemeta.json b/codemeta.json index e16ed502..0bde72f4 100644 --- a/codemeta.json +++ b/codemeta.json @@ -40,12 +40,12 @@ ], "identifier": "https://doi.org/10.5281/zenodo.4269346", "codeRepository": "https://github.com/bihealth/sodar-core", - "datePublished": "2022-07-15", - "dateModified": "2022-07-15", + "datePublished": "2022-09-23", + "dateModified": "2022-09-23", "dateCreated": "2019-06-26", "description": "SODAR Core: A Django-based framework for scientific data management and analysis web apps", "keywords": "Python, Django, scientific data managmenent, software library", "license": "MIT", "title": "SODAR Core", - "version": "v0.10.13" + "version": "v0.11.0" } diff --git a/docs/source/app_projectroles_integration.rst b/docs/source/app_projectroles_integration.rst index 760b3c2f..2dbc1481 100644 --- a/docs/source/app_projectroles_integration.rst +++ b/docs/source/app_projectroles_integration.rst @@ -86,7 +86,7 @@ desired release tag or commit ID. .. code-block:: console -e git+https://github.com/mikkonie/django-plugins.git@42e86e7904e5c09f1da32173862b26843eda5dd8#egg=django-plugins - django-sodar-core==0.10.13 + django-sodar-core==0.11.0 Install the requirements for development: diff --git a/docs/source/conf.py b/docs/source/conf.py index 286392b9..5aed2dfa 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -29,7 +29,7 @@ # The short X.Y version version = '0.11' # The full version, including alpha/beta/rc tags -release = '0.11.0-WIP' +release = '0.11.0' # -- General configuration --------------------------------------------------- diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst index ef60a721..6e8b687d 100644 --- a/docs/source/getting_started.rst +++ b/docs/source/getting_started.rst @@ -17,7 +17,7 @@ the package is under active development and breaking changes are expected. .. code-block:: console - pip install django-sodar-core==0.10.13 + pip install django-sodar-core==0.11.0 Please note that the django-sodar-core package only installs :term:`Django apps`, which you need to include in a diff --git a/docs/source/major_changes.rst b/docs/source/major_changes.rst index 0a37e72c..bc27d86b 100644 --- a/docs/source/major_changes.rst +++ b/docs/source/major_changes.rst @@ -10,8 +10,8 @@ older SODAR Core version. For a complete list of changes in current and previous releases, see the :ref:`full changelog`. -v0.11.0 (WIP) -************* +v0.11.0 (2022-09-23) +******************** Release Highlights ================== diff --git a/projectroles/views_api.py b/projectroles/views_api.py index 8285daff..59bc9de2 100644 --- a/projectroles/views_api.py +++ b/projectroles/views_api.py @@ -84,30 +84,7 @@ CORE_API_DEFAULT_VERSION = re.match( r'^([0-9.]+)(?:[+|\-][\S]+)?$', core_version )[1] -CORE_API_ALLOWED_VERSIONS = [ - '0.7.2', - '0.8.0', - '0.8.1', - '0.8.2', - '0.8.3', - '0.8.4', - '0.9.0', - '0.9.1', - '0.10.0', - '0.10.1', - '0.10.2', - '0.10.3', - '0.10.4', - '0.10.5', - '0.10.6', - '0.10.7', - '0.10.8', - '0.10.9', - '0.10.10', - '0.10.11', - '0.10.12', - '0.10.13', -] +CORE_API_ALLOWED_VERSIONS = ['0.11.0'] # Local constants INVALID_PROJECT_TYPE_MSG = (