diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6c65e4d5..eab3cd7b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -37,6 +37,8 @@ Added - ``is_source_site()`` and ``is_target_site()`` rule predicates - ``settings_link`` kwarg in ``send_generic_email()`` (#1418) - ``addremotesite`` and ``syncgroups`` command tests (#352) + - ``RemoteSite.owner_modifiable`` field (#817) + - ``assert_displayed()`` UI test helper - **Timeline** - ``sodar_uuid`` field in ``TimelineEventObjectRef`` model (#1415) - REST API views (#1350) @@ -98,6 +100,7 @@ Fixed - ``ProjectStarringAjaxView`` creating redundant database objects (#1416) - ``addremotesite`` crash in ``TimelineAPI.add_event()`` (#1425) - ``addremotesite`` allows creation of site with mode identical to host (#1426) + - Public guest access field not correctly hidden in project form (#1429) - **Sodarcache** - REST API set view ``app_name`` incorrectly set (#1405) diff --git a/docs/source/_static/app_projectroles/sodar_remote_site_form.png b/docs/source/_static/app_projectroles/sodar_remote_site_form.png new file mode 100644 index 00000000..7af5b044 Binary files /dev/null and b/docs/source/_static/app_projectroles/sodar_remote_site_form.png differ diff --git a/docs/source/app_projectroles_usage.rst b/docs/source/app_projectroles_usage.rst index 29aaf680..9e36b35f 100644 --- a/docs/source/app_projectroles_usage.rst +++ b/docs/source/app_projectroles_usage.rst @@ -197,6 +197,16 @@ specifically granting it. Public guest access can only be set for projects. Categories will be visible for users with access to any category or project under them. +Access on Remote Sites +---------------------- + +In the project create/update view, owners and delegates can modify remote site +access to projects. This is available for sites where these controls have been +enabled by administrators. The sites will appear as checkboxes as +:guilabel:`Enable project on {SITE-NAME}`. + +For more information, see :ref:`app_projectroles_usage_remote`. + App Settings ------------ @@ -334,6 +344,8 @@ out of the LDAP server. Use the ``-h`` flag to see additional options. $ ./manage.py checkusers +.. _app_projectroles_usage_remote: + Remote Projects =============== @@ -372,10 +384,33 @@ specifying the remote site. A secret string is generated automatically. You need to provide this to the administrator of the target site in question for accessing your site. -Here you also have the option to hide the remote project link from your users. -Users viewing the project on the source site then won't see a link to the target -site. Owners and superusers will still see the link (greyed out). This is most -commonly used for internal test sites which only needs to be used by admins. +Fields for target remote site creation: + +Name + Name of the remote site. +URL + URL for the remote site, e.g. ``https://sodar-core-site.example.com``. +Description + Text description for the site. +User display + If set false, this will hide the remote project links from your users. + Users viewing the project on the source site then won't see a link to the + target site. Owners and superusers will still see the link (greyed out). + This is most commonly used for internal test sites which only needs to be + used by admins. +Owner modifiable + If this and :guilabel:`User display` are checked, owners and delegates can + control project visibility on this site in the project create/update view. +Secret + Secret token for the project, which must be set to an identical value + between source and target sites. + +.. figure:: _static/app_projectroles/sodar_remote_site_form.png + :align: center + :scale: 50% + + Remote site create/update view viewed as a source site + Once created, you can access the list of projects on your site in regards to the created target site. For each project, you may select an access level, of which @@ -391,6 +426,15 @@ Revoked Access remain in the target site, but only superusers, the project owner or the project delegate(s) can access it. +Once desired access to specific projects has been granted and confirmed, the +target site will sync the data by sending a request to the source site. + +.. figure:: _static/app_projectroles/sodar_remote_projects.png + :align: center + :scale: 50% + + Remote project list viewed as a source site + .. note:: The *read roles* access level also provides metadata of the categories above @@ -415,15 +459,6 @@ Revoked Access explicitly set by the project owner, delegate or a superuser in the :guilabel:`Update Project` form. -Once desired access to specific projects has been granted and confirmed, the -target site will sync the data by sending a request to the source site. - -.. figure:: _static/app_projectroles/sodar_remote_projects.png - :align: center - :scale: 50% - - Remote project list in source mode - As Target Site -------------- diff --git a/docs/source/major_changes.rst b/docs/source/major_changes.rst index 29fc6f2e..82682489 100644 --- a/docs/source/major_changes.rst +++ b/docs/source/major_changes.rst @@ -25,6 +25,7 @@ Release Highlights - Add admin alert email sending to all users - Add improved additional email address management and verification - Add user opt-out settings for email notifications +- Add remote sync controls for owners and delegates in project form - Add target site user UUID updating in remote sync - Add remote sync of existing target local users - Add remote sync of USER scope app settings diff --git a/projectroles/forms.py b/projectroles/forms.py index a9d0c2f1..89ad8666 100644 --- a/projectroles/forms.py +++ b/projectroles/forms.py @@ -21,6 +21,7 @@ RoleAssignment, ProjectInvite, RemoteSite, + RemoteProject, SODAR_CONSTANTS, ROLE_RANKING, APP_SETTING_VAL_MAXLENGTH, @@ -50,6 +51,7 @@ 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'] +REMOTE_LEVEL_READ_ROLES = SODAR_CONSTANTS['REMOTE_LEVEL_READ_ROLES'] # Local constants APP_NAME = 'projectroles' @@ -359,9 +361,36 @@ def _get_parent_choices(cls, instance, user): ret += [(c.sodar_uuid, c.full_title) for c in categories] return sorted(ret, key=lambda x: x[1]) - def _set_app_setting_widget(self, plugin_name, s_field, s_key, s_val): + def _init_remote_sites(self): """ - Internal helper for setting app setting widget and value. + Initialize remote site fields in the form. + """ + p_display = get_display_name(PROJECT_TYPE_PROJECT) + for site in RemoteSite.objects.filter( + mode=SITE_MODE_TARGET, user_display=True, owner_modifiable=True + ).order_by('name'): + f_name = 'remote_site.{}'.format(site.sodar_uuid) + f_label = 'Enable {} on {}'.format(p_display, site.name) + f_help = 'Make {} available on remote site "{}" ({})'.format( + p_display, site.name, site.url + ) + f_initial = False + if self.instance.pk: + rp = RemoteProject.objects.filter( + site=site, project=self.instance + ).first() + # NOTE: Only "read roles" is supported at the moment + f_initial = rp and rp.level == REMOTE_LEVEL_READ_ROLES + self.fields[f_name] = forms.BooleanField( + label=f_label, + help_text=f_help, + initial=f_initial, + required=False, + ) + + def _set_app_setting_field(self, plugin_name, s_field, s_key, s_val): + """ + Internal helper for setting app setting field, widget and value. :param plugin_name: App plugin name :param s_field: Form field name @@ -500,6 +529,9 @@ def _set_app_setting_notes(self, s_field, s_val, plugin): ) def _init_app_settings(self): + """ + Initialize app settings fields in the form. + """ # Set up setting query kwargs self.p_kwargs = {} # Show unmodifiable settings to superusers @@ -522,8 +554,8 @@ def _init_app_settings(self): ) for s_key, s_val in p_settings.items(): s_field = 'settings.{}.{}'.format(plugin_name, s_key) - # Set widget and value - self._set_app_setting_widget(plugin_name, s_field, s_key, s_val) + # Set field, widget and value + self._set_app_setting_field(plugin_name, s_field, s_key, s_val) # Set label notes self._set_app_setting_notes(s_field, s_val, plugin) @@ -601,12 +633,18 @@ def __init__(self, project=None, current_user=None, *args, **kwargs): # Get current user for checking permissions for form items if current_user: self.current_user = current_user - # Add settings fields - self._init_app_settings() # Access parent project if present parent_project = None if project: parent_project = Project.objects.filter(sodar_uuid=project).first() + # Add remote site fields if on source site + if settings.PROJECTROLES_SITE_MODE == SITE_MODE_SOURCE and ( + parent_project + or (self.instance.pk and self.instance.type == PROJECT_TYPE_PROJECT) + ): + self._init_remote_sites() + # Add settings fields + self._init_app_settings() # Update help texts to match DISPLAY_NAMES self.fields['title'].help_text = 'Title' @@ -777,7 +815,7 @@ def clean(self): 'Public guest access is not allowed for categories', ) - # Verify settings fields + # Verify remote site fields cleaned_data, errors = self._validate_app_settings( self.cleaned_data, self.app_plugins, @@ -1181,7 +1219,14 @@ class RemoteSiteForm(SODARModelForm): class Meta: model = RemoteSite - fields = ['name', 'url', 'description', 'user_display', 'secret'] + fields = [ + 'name', + 'url', + 'description', + 'user_display', + 'owner_modifiable', + 'secret', + ] def __init__(self, current_user=None, *args, **kwargs): """Override for form initialization""" @@ -1199,8 +1244,10 @@ def __init__(self, current_user=None, *args, **kwargs): if settings.PROJECTROLES_SITE_MODE == SITE_MODE_SOURCE: self.fields['secret'].widget.attrs['readonly'] = True self.fields['user_display'].widget = forms.CheckboxInput() + self.fields['owner_modifiable'].widget = forms.CheckboxInput() elif settings.PROJECTROLES_SITE_MODE == SITE_MODE_TARGET: self.fields['user_display'].widget = forms.HiddenInput() + self.fields['owner_modifiable'].widget = forms.HiddenInput() self.fields['user_display'].initial = True # Creation diff --git a/projectroles/management/commands/addremotesite.py b/projectroles/management/commands/addremotesite.py index d5cbad5e..066c276a 100644 --- a/projectroles/management/commands/addremotesite.py +++ b/projectroles/management/commands/addremotesite.py @@ -81,6 +81,16 @@ def add_arguments(self, parser): type=bool, help='User display of the remote site', ) + parser.add_argument( + '-o', + '--owner-modifiable', + dest='owner_modifiable', + default=True, + required=False, + type=bool, + help='Allow owners and delegates to modify project access for this ' + 'site', + ) # Additional Arguments parser.add_argument( '-s', @@ -137,6 +147,7 @@ def handle(self, *args, **options): 'description': options['description'], 'secret': options['secret'], 'user_display': options['user_display'], + 'owner_modifiable': options['owner_modifiable'], } site = RemoteSite.objects.create(**create_kw) diff --git a/projectroles/migrations/0031_remotesite_owner_modifiable.py b/projectroles/migrations/0031_remotesite_owner_modifiable.py new file mode 100644 index 00000000..4519763d --- /dev/null +++ b/projectroles/migrations/0031_remotesite_owner_modifiable.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.11 on 2024-06-11 14:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("projectroles", "0030_populate_sodaruseradditionalemail"), + ] + + operations = [ + migrations.AddField( + model_name="remotesite", + name="owner_modifiable", + field=models.BooleanField( + default=True, + help_text="Allow owners and delegates to modify project access for this site", + ), + ), + migrations.AlterField( + model_name="remotesite", + name="user_display", + field=models.BooleanField(default=True, help_text="Display site to users"), + ), + ] diff --git a/projectroles/models.py b/projectroles/models.py index 82286ac1..c27d8c14 100644 --- a/projectroles/models.py +++ b/projectroles/models.py @@ -1144,6 +1144,19 @@ class RemoteSite(models.Model): help_text='Secret token for connecting to the source site', ) + #: RemoteSite visibilty to users + user_display = models.BooleanField( + default=True, unique=False, help_text='Display site to users' + ) + + #: RemoteSite project access modifiability for owners and delegates + owner_modifiable = models.BooleanField( + default=True, + unique=False, + help_text='Allow owners and delegates to modify project access for ' + 'this site', + ) + #: RemoteSite relation UUID (local) sodar_uuid = models.UUIDField( default=uuid.uuid4, @@ -1151,11 +1164,6 @@ class RemoteSite(models.Model): help_text='RemoteSite relation UUID (local)', ) - #: RemoteSite's link visibilty for users - user_display = models.BooleanField( - default=True, unique=False, help_text='RemoteSite visibility to users' - ) - class Meta: ordering = ['name'] unique_together = ['url', 'mode', 'secret'] diff --git a/projectroles/static/projectroles/js/project_form.js b/projectroles/static/projectroles/js/project_form.js index 4a3acdb8..e4267e5b 100644 --- a/projectroles/static/projectroles/js/project_form.js +++ b/projectroles/static/projectroles/js/project_form.js @@ -1,9 +1,10 @@ $(document).ready(function() { // Hide settings fields by default $('div[id^="div_id_settings"]').hide(); - - // Temporary solution for hiding the public_guest_access field + // Hide public_guest_access field by default $('#div_id_public_guest_access').hide(); + // Hide remote sites by default + $('div[id^="div_id_remote_site"]').hide(); // Check if it's category/project update and show corresponding fields if ($('#sodar-pr-project-form-title').attr('data-project-type') === 'PROJECT') { @@ -21,11 +22,9 @@ $(document).ready(function() { $parentDiv.hide(); } }); - - // Temporary solution for hiding the public_guest_access field $('#div_id_public_guest_access').show(); + $('div[id^="div_id_remote_site"]').show(); } - if ($('#sodar-pr-project-form-title').attr('data-project-type') === 'CATEGORY') { $('div[id^="div_id_settings"]').each(function () { var $parentDiv = $(this); @@ -42,10 +41,9 @@ $(document).ready(function() { } }); } - // Show settings fields if selected type is project/category in update form $('#div_id_type .form-control').change(function() { - if ($('#div_id_type .form-control').val() == 'PROJECT') { + if ($('#div_id_type .form-control').val() === 'PROJECT') { $('div[id^="div_id_settings"]').each(function () { var $parentDiv = $(this); var $projectElements = $parentDiv.find('select[data-project-types="project"]') @@ -59,11 +57,10 @@ $(document).ready(function() { } else { $parentDiv.hide(); } - - // Temporary solution for hiding the public_guest_access field $('#div_id_public_guest_access').show(); + $('div[id^="div_id_remote_site"]').show(); }); - } else if ($('#div_id_type .form-control').val() == 'CATEGORY') { + } else if ($('#div_id_type .form-control').val() === 'CATEGORY') { $('div[id^="div_id_settings"]').each(function () { var $parentDiv = $(this); var $categoryElements = $parentDiv.find('select[data-project-types="category"]') @@ -77,7 +74,19 @@ $(document).ready(function() { } else { $parentDiv.hide(); } + $('#div_id_public_guest_access').hide(); + $('div[id^="div_id_remote_site"]').hide(); }); } }); + + // Warn user of revoking remote site access + $('input[id^="id_remote_site"]').change(function() { + if (!$(this).is(':checked') && $(this).prop('defaultChecked')) { + const confirmMsg = 'This will revoke access to the project on ' + + 'the site. Are you sure you want to proceed?' + if (!confirm(confirmMsg)) $(this).prop('checked', true); + else $(this).prop('checked', false); + } + }); }) diff --git a/projectroles/tests/test_commands.py b/projectroles/tests/test_commands.py index e1cc0e0b..e0417e18 100644 --- a/projectroles/tests/test_commands.py +++ b/projectroles/tests/test_commands.py @@ -103,6 +103,7 @@ def setUp(self): 'description': '', 'secret': REMOTE_SITE_SECRET, 'user_display': True, + 'owner_modifiable': False, 'suppress_error': False, } @@ -124,6 +125,7 @@ def test_add(self): 'secret': REMOTE_SITE_SECRET, 'sodar_uuid': site.sodar_uuid, 'user_display': True, + 'owner_modifiable': False, } self.assertEqual(model_to_dict(site), expected) self.assertEqual(TimelineEvent.objects.filter(**tl_kwargs).count(), 1) diff --git a/projectroles/tests/test_models.py b/projectroles/tests/test_models.py index 4c70d01c..c072a1f2 100644 --- a/projectroles/tests/test_models.py +++ b/projectroles/tests/test_models.py @@ -205,9 +205,11 @@ def make_site( name, url, user_display=REMOTE_SITE_USER_DISPLAY, + owner_modifiable=True, mode=SODAR_CONSTANTS['SITE_MODE_TARGET'], description='', secret=build_secret(), + sodar_uuid=None, ): """Make and save a RemoteSite""" values = { @@ -217,6 +219,8 @@ def make_site( 'description': description, 'secret': secret, 'user_display': user_display, + 'owner_modifiable': owner_modifiable, + 'sodar_uuid': sodar_uuid or uuid.uuid4(), } site = RemoteSite(**values) site.save() @@ -1347,6 +1351,7 @@ def test_initialization(self): 'secret': REMOTE_SITE_SECRET, 'sodar_uuid': self.site.sodar_uuid, 'user_display': REMOTE_SITE_USER_DISPLAY, + 'owner_modifiable': True, } self.assertEqual(model_to_dict(self.site), expected) diff --git a/projectroles/tests/test_remote_project_api.py b/projectroles/tests/test_remote_project_api.py index 708ab3ad..450fc9c0 100644 --- a/projectroles/tests/test_remote_project_api.py +++ b/projectroles/tests/test_remote_project_api.py @@ -1032,6 +1032,7 @@ def test_create(self): 'secret': None, 'sodar_uuid': uuid.UUID(PEER_SITE_UUID), 'user_display': PEER_SITE_USER_DISPLAY, + 'owner_modifiable': True, } peer_site_dict = model_to_dict(peer_site_obj) peer_site_dict.pop('id') @@ -1845,6 +1846,7 @@ def test_update(self): 'description': NEW_PEER_DESC, 'secret': None, 'user_display': NEW_PEER_USER_DISPLAY, + 'owner_modifiable': True, } peer_site_dict = model_to_dict(peer_site_obj) peer_site_dict.pop('id') @@ -2353,6 +2355,7 @@ def test_update_no_changes(self): 'secret': None, 'sodar_uuid': uuid.UUID(PEER_SITE_UUID), 'user_display': PEER_SITE_USER_DISPLAY, + 'owner_modifiable': True, } peer_site_dict = model_to_dict(peer_site_obj) peer_site_dict.pop('id') diff --git a/projectroles/tests/test_ui.py b/projectroles/tests/test_ui.py index 742de73e..8d0fde87 100644 --- a/projectroles/tests/test_ui.py +++ b/projectroles/tests/test_ui.py @@ -2,6 +2,7 @@ import socket import time +import uuid from urllib.parse import urlencode @@ -20,6 +21,7 @@ from selenium.common.exceptions import ( NoSuchElementException, StaleElementReferenceException, + TimeoutException, ) from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as ec @@ -68,6 +70,14 @@ REMOTE_SITE_URL = 'https://sodar.bihealth.org' REMOTE_SITE_DESC = 'New description' REMOTE_SITE_SECRET = build_secret() +PROJECT_SELECT_CSS = 'div[id="div_id_type"] div select[id="id_type"]' +PROJECT_SETTING_ID = 'div_id_settings.example_project_app.project_int_setting' +CATEGORY_SETTING_ID = ( + 'div_id_settings.example_project_app.project_category_bool_setting' +) +PUBLIC_ACCESS_ID = 'id_public_guest_access' +REMOTE_SITE_UUID = uuid.uuid4() +REMOTE_SITE_ID = 'id_remote_site.{}'.format(REMOTE_SITE_UUID) class LiveUserMixin: @@ -204,6 +214,9 @@ def login_and_redirect( :param user: User object :param url: URL to redirect to (string) + :param wait_elem: Wait for existence of an element (string, optional) + :param wait_loc: Locator of optional wait element (string, corresponds + to selenium "By" class members) """ # Legacy login mode if getattr(settings, 'PROJECTROLES_TEST_UI_LEGACY_LOGIN', False): @@ -449,6 +462,17 @@ def assert_element_active( self.assertIsNotNone(element) self.assertNotIn('active', element.get_attribute('class')) + def assert_displayed(self, by, value, expected): + """ + Assert element is or isn't displayed. Assumes user to be logged in. + + :param by: Selenium By selector + :param value: Value for selecting element + :param expected: Boolean + """ + elem = self.selenium.find_element(by, value) + self.assertEqual(elem.is_displayed(), expected) + class TestBaseTemplate(UITestBase): """Tests for the base project template""" @@ -1580,127 +1604,116 @@ def test_archive_visibility_archived(self): ) -class TestProjectCreateView(UITestBase): +class TestProjectCreateView(RemoteSiteMixin, UITestBase): """Tests for ProjectCreateView UI""" + def setUp(self): + super().setUp() + self.remote_site = self.make_site( + name=REMOTE_SITE_NAME, + url=REMOTE_SITE_URL, + mode=SITE_MODE_TARGET, + description='', + secret=REMOTE_SITE_SECRET, + user_display=True, + sodar_uuid=REMOTE_SITE_UUID, + ) + self.url = reverse( + 'projectroles:create', kwargs={'project': self.category.sodar_uuid} + ) + self.url_top = reverse('projectroles:create') + def test_owner_widget_top(self): """Test rendering the owner widget on the top level""" - url = reverse('projectroles:create') - self.assert_element_exists([self.superuser], url, 'div_id_owner', True) + self.assert_element_exists( + [self.superuser], self.url_top, 'div_id_owner', True + ) def test_owner_widget_sub(self): """Test rendering the owner widget under a category""" # Add new user, make them a contributor in category new_user = self.make_user('new_user') self.make_assignment(self.category, new_user, self.role_contributor) - url = reverse( - 'projectroles:create', kwargs={'project': self.category.sodar_uuid} + self.assert_element_exists( + [self.superuser], self.url, 'div_id_owner', True ) - self.assert_element_exists([self.superuser], url, 'div_id_owner', True) self.assert_element_exists( - [self.user_owner, new_user], url, 'div_id_owner', False + [self.user_owner, new_user], self.url, 'div_id_owner', False ) def test_archive_button(self): """Test rendering form without archive button""" - url = reverse('projectroles:create') self.assert_element_exists( - [self.superuser], url, 'sodar-pr-btn-archive', False + [self.superuser], self.url_top, 'sodar-pr-btn-archive', False ) - def test_settings_fields_default(self): - """Test rendering of app settings fields for default view""" - url = reverse('projectroles:create') - self.login_and_redirect( - self.superuser, url, wait_elem=None, wait_loc='ID' - ) - element = self.selenium.find_element( - By.ID, 'div_id_settings.example_project_app.project_int_setting' - ) - self.assertFalse(element.is_displayed()) + def test_fields_top(self): + """Test rendering of dynamic fields for top level creation view""" + self.login_and_redirect(self.superuser, self.url_top) + self.assert_displayed(By.ID, PUBLIC_ACCESS_ID, False) + with self.assertRaises(NoSuchElementException): + self.selenium.find_element(By.ID, REMOTE_SITE_ID) + self.assert_displayed(By.ID, PROJECT_SETTING_ID, False) + self.assert_displayed(By.ID, CATEGORY_SETTING_ID, True) - def test_settings_fields_project(self): - """Test rendering of app settings fields for project creation""" - url = reverse( - 'projectroles:create', kwargs={'project': self.category.sodar_uuid} - ) - self.login_and_redirect( - self.superuser, url, wait_elem=None, wait_loc='ID' - ) - element = self.selenium.find_element( - By.ID, 'div_id_settings.example_project_app.project_int_setting' - ) - self.assertFalse(element.is_displayed()) + def test_fields_project(self): + """Test rendering of dynamic fields for project creation""" + self.login_and_redirect(self.superuser, self.url) + self.assert_displayed(By.ID, PUBLIC_ACCESS_ID, False) + self.assert_displayed(By.ID, REMOTE_SITE_ID, False) + self.assert_displayed(By.ID, PROJECT_SETTING_ID, False) + self.assert_displayed(By.ID, CATEGORY_SETTING_ID, False) select = Select( - self.selenium.find_element( - By.CSS_SELECTOR, - 'div[id="div_id_type"] div select[id="id_type"]', - ) + self.selenium.find_element(By.CSS_SELECTOR, PROJECT_SELECT_CSS) ) - select.select_by_value('PROJECT') + select.select_by_value(PROJECT_TYPE_PROJECT) self.assertEqual(select.first_selected_option.text, 'Project') - WebDriverWait(self.selenium, 10) - element = self.selenium.find_element( - By.ID, 'div_id_settings.example_project_app.project_int_setting' + WebDriverWait(self.selenium, 10).until( + ec.visibility_of_element_located((By.ID, PUBLIC_ACCESS_ID)) ) - self.assertTrue(element.is_displayed()) + self.assert_displayed(By.ID, REMOTE_SITE_ID, True) + self.assert_displayed(By.ID, PROJECT_SETTING_ID, True) + self.assert_displayed(By.ID, CATEGORY_SETTING_ID, False) - def test_settings_fields_category(self): - """Test rendering of app settings fields for category creation""" - url = reverse( - 'projectroles:create', kwargs={'project': self.category.sodar_uuid} - ) - self.login_and_redirect( - self.superuser, url, wait_elem=None, wait_loc='ID' - ) - element = self.selenium.find_element( - By.ID, 'div_id_settings.example_project_app.project_int_setting' - ) - self.assertFalse(element.is_displayed()) + def test_fields_category(self): + """Test rendering of dynamic fields for category creation""" + self.login_and_redirect(self.superuser, self.url) + self.assert_displayed(By.ID, PUBLIC_ACCESS_ID, False) + self.assert_displayed(By.ID, REMOTE_SITE_ID, False) + self.assert_displayed(By.ID, PROJECT_SETTING_ID, False) + self.assert_displayed(By.ID, CATEGORY_SETTING_ID, False) select = Select( - self.selenium.find_element( - By.CSS_SELECTOR, - 'div[id="div_id_type"] div select[id="id_type"]', - ) + self.selenium.find_element(By.CSS_SELECTOR, PROJECT_SELECT_CSS) ) - select.select_by_value('CATEGORY') + select.select_by_value(PROJECT_TYPE_CATEGORY) self.assertEqual(select.first_selected_option.text, 'Category') - WebDriverWait(self.selenium, 10) - element = self.selenium.find_element( - By.ID, 'div_id_settings.example_project_app.project_int_setting' + WebDriverWait(self.selenium, 10).until( + ec.visibility_of_element_located((By.ID, CATEGORY_SETTING_ID)) ) - self.assertFalse(element.is_displayed()) + self.assert_displayed(By.ID, PUBLIC_ACCESS_ID, False) + self.assert_displayed(By.ID, REMOTE_SITE_ID, False) + self.assert_displayed(By.ID, PROJECT_SETTING_ID, False) + self.assert_displayed(By.ID, CATEGORY_SETTING_ID, True) def test_settings_label_icon(self): """Test rendering of app settings icon for project creation""" - url = reverse( - 'projectroles:create', kwargs={'project': self.category.sodar_uuid} + self.login_and_redirect(self.superuser, self.url) + select = Select( + self.selenium.find_element(By.CSS_SELECTOR, PROJECT_SELECT_CSS) ) - self.login_and_redirect( - self.superuser, url, wait_elem=None, wait_loc='ID' + select.select_by_value(PROJECT_TYPE_PROJECT) + WebDriverWait(self.selenium, 10).until( + ec.presence_of_element_located((By.TAG_NAME, 'svg')) ) - select = Select( - self.selenium.find_element( - By.CSS_SELECTOR, - 'div[id="div_id_type"] div select[id="id_type"]', - ) + find_args = (By.CSS_SELECTOR, 'div[id="{}"]'.format(PROJECT_SETTING_ID)) + logo = self.selenium.find_element(*find_args).find_element( + By.TAG_NAME, 'svg' ) - select.select_by_value('PROJECT') - WebDriverWait(self.selenium, 10) - logo = self.selenium.find_element( - By.CSS_SELECTOR, - 'div[id="div_id_settings.example_project_app.project_int_setting"]', - ).find_element(By.TAG_NAME, 'svg') self.assertTrue(logo.is_displayed()) def test_submit_button(self): """Test rendering of submit button""" - url = reverse( - 'projectroles:create', kwargs={'project': self.category.sodar_uuid} - ) - self.login_and_redirect( - self.superuser, url, wait_elem=None, wait_loc='ID' - ) + self.login_and_redirect(self.superuser, self.url) element = self.selenium.find_element( By.CLASS_NAME, 'sodar-btn-submit-once' ) @@ -1715,64 +1728,111 @@ def test_submit_button(self): if element.is_enabled() and i < max_retries - 1: time.sleep(retry_interval) else: - self.fail( - 'Element did not become enabled within the timeout' - ) + self.fail('Element not enabled within timeout') except StaleElementReferenceException: break -class TestProjectUpdateView(UITestBase): +class TestProjectUpdateView(RemoteSiteMixin, RemoteProjectMixin, UITestBase): """Tests for ProjectUpdateView UI""" - def test_archive_button(self): - """Test rendering of archive button""" - url = reverse( + def setUp(self): + super().setUp() + self.remote_site = self.make_site( + name=REMOTE_SITE_NAME, + url=REMOTE_SITE_URL, + mode=SITE_MODE_TARGET, + description='', + secret=REMOTE_SITE_SECRET, + user_display=True, + sodar_uuid=REMOTE_SITE_UUID, + ) + self.url = reverse( 'projectroles:update', kwargs={'project': self.project.sodar_uuid} ) + self.url_cat = reverse( + 'projectroles:update', kwargs={'project': self.category.sodar_uuid} + ) + + def test_archive_button(self): + """Test rendering archive button""" self.assert_element_exists( - [self.superuser], url, 'sodar-pr-btn-archive', True + [self.superuser], self.url, 'sodar-pr-btn-archive', True ) element = self.selenium.find_element(By.ID, 'sodar-pr-btn-archive') self.assertEqual(element.text, 'Archive') def test_archive_button_archived(self): - """Test rendering of archive button with archived project""" + """Test rendering archive button with archived project""" self.project.set_archive() - url = reverse( - 'projectroles:update', kwargs={'project': self.project.sodar_uuid} - ) self.assert_element_exists( - [self.superuser], url, 'sodar-pr-btn-archive', True + [self.superuser], self.url, 'sodar-pr-btn-archive', True ) element = self.selenium.find_element(By.ID, 'sodar-pr-btn-archive') self.assertEqual(element.text, 'Unarchive') - def test_settings_fields_project_update(self): - """Test rendering of app settings fields for project update view""" - url = reverse( - 'projectroles:update', kwargs={'project': self.project.sodar_uuid} - ) - self.login_and_redirect( - self.superuser, url, wait_elem=None, wait_loc='ID' - ) - element = self.selenium.find_element( - By.ID, 'div_id_settings.example_project_app.project_int_setting' - ) - self.assertTrue(element.is_displayed()) + def test_fields_project(self): + """Test field visibility for project update""" + self.login_and_redirect(self.superuser, self.url) + self.assert_displayed(By.ID, PUBLIC_ACCESS_ID, True) + self.assert_displayed(By.ID, REMOTE_SITE_ID, True) + self.assert_displayed(By.ID, PROJECT_SETTING_ID, True) + self.assert_displayed(By.ID, CATEGORY_SETTING_ID, False) + + def test_fields_category(self): + """Test field visibility for category update""" + self.login_and_redirect(self.superuser, self.url_cat) + self.assert_displayed(By.ID, PUBLIC_ACCESS_ID, False) + with self.assertRaises(NoSuchElementException): + self.selenium.find_element(By.ID, REMOTE_SITE_ID) + self.assert_displayed(By.ID, PROJECT_SETTING_ID, False) + self.assert_displayed(By.ID, CATEGORY_SETTING_ID, True) - def test_settings_fields_category_update(self): - """Test rendering of app settings fields for category update view""" - url = reverse( - 'projectroles:update', kwargs={'project': self.category.sodar_uuid} - ) - self.login_and_redirect( - self.superuser, url, wait_elem=None, wait_loc='ID' + def test_remote_field_enable(self): + """Test enabling remote site field""" + self.login_and_redirect(self.superuser, self.url) + elem = self.selenium.find_element(By.ID, REMOTE_SITE_ID) + self.assertEqual(elem.is_selected(), False) + elem.click() + with self.assertRaises(TimeoutException): + WebDriverWait(self.selenium, 3).until(ec.alert_is_present()) + self.assertEqual(elem.is_selected(), True) + elem.click() # Disable again + with self.assertRaises(TimeoutException): + WebDriverWait(self.selenium, 3).until(ec.alert_is_present()) + self.assertEqual(elem.is_selected(), False) + + def test_remote_field_disable(self): + """Test disabling previously enabled remote site field""" + self.remote_project = self.make_remote_project( + project_uuid=self.project.sodar_uuid, + site=self.remote_site, + level=SODAR_CONSTANTS['REMOTE_LEVEL_READ_ROLES'], + project=self.project, ) - element = self.selenium.find_element( - By.ID, 'div_id_settings.example_project_app.project_int_setting' + self.login_and_redirect(self.superuser, self.url) + elem = self.selenium.find_element(By.ID, REMOTE_SITE_ID) + self.assertEqual(elem.is_selected(), True) + elem.click() + WebDriverWait(self.selenium, 3).until(ec.alert_is_present()) + self.selenium.switch_to.alert.accept() + self.assertEqual(elem.is_selected(), False) + + def test_remote_field_disable_cancel(self): + """Test canceling the disabling of previously enabled remote site field""" + self.remote_project = self.make_remote_project( + project_uuid=self.project.sodar_uuid, + site=self.remote_site, + level=SODAR_CONSTANTS['REMOTE_LEVEL_READ_ROLES'], + project=self.project, ) - self.assertFalse(element.is_displayed()) + self.login_and_redirect(self.superuser, self.url) + elem = self.selenium.find_element(By.ID, REMOTE_SITE_ID) + self.assertEqual(elem.is_selected(), True) + elem.click() + WebDriverWait(self.selenium, 3).until(ec.alert_is_present()) + self.selenium.switch_to.alert.dismiss() + self.assertEqual(elem.is_selected(), True) class TestProjectArchiveView(UITestBase): @@ -2319,6 +2379,4 @@ def test_target_user_toggle(self): """Test site create form user display toggle on target site""" url = reverse('projectroles:remote_site_create') self.login_and_redirect(self.superuser, url) - self.assertFalse( - self.selenium.find_element(By.ID, 'id_user_display').is_displayed() - ) + self.assert_displayed(By.ID, 'id_user_display', False) diff --git a/projectroles/tests/test_views.py b/projectroles/tests/test_views.py index 17c01f70..739547b0 100644 --- a/projectroles/tests/test_views.py +++ b/projectroles/tests/test_views.py @@ -1,6 +1,7 @@ """UI view tests for the projectroles app""" import json +import uuid from urllib.parse import urlencode @@ -93,8 +94,13 @@ PROJECT_ROLE_GUEST = SODAR_CONSTANTS['PROJECT_ROLE_GUEST'] PROJECT_TYPE_CATEGORY = SODAR_CONSTANTS['PROJECT_TYPE_CATEGORY'] PROJECT_TYPE_PROJECT = SODAR_CONSTANTS['PROJECT_TYPE_PROJECT'] -SITE_MODE_TARGET = SODAR_CONSTANTS['SITE_MODE_TARGET'] SITE_MODE_SOURCE = SODAR_CONSTANTS['SITE_MODE_SOURCE'] +SITE_MODE_TARGET = SODAR_CONSTANTS['SITE_MODE_TARGET'] +SITE_MODE_PEER = SODAR_CONSTANTS['SITE_MODE_PEER'] +REMOTE_LEVEL_NONE = SODAR_CONSTANTS['REMOTE_LEVEL_NONE'] +REMOTE_LEVEL_REVOKED = SODAR_CONSTANTS['REMOTE_LEVEL_REVOKED'] +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'] APP_SETTING_SCOPE_USER = SODAR_CONSTANTS['APP_SETTING_SCOPE_USER'] APP_SETTING_SCOPE_PROJECT_USER = SODAR_CONSTANTS[ @@ -111,10 +117,13 @@ REMOTE_SITE_DESC = 'description' REMOTE_SITE_SECRET = build_secret() REMOTE_SITE_USER_DISPLAY = True +REMOTE_SITE_OWNER_MODIFY = True REMOTE_SITE_NEW_NAME = 'New name' REMOTE_SITE_NEW_URL = 'https://new.url' REMOTE_SITE_NEW_DESC = 'New description' REMOTE_SITE_NEW_SECRET = build_secret() +REMOTE_SITE_UUID = uuid.uuid4() +REMOTE_SITE_FIELD = 'remote_site.{}'.format(REMOTE_SITE_UUID) EXAMPLE_APP_NAME = 'example_project_app' INVALID_UUID = '11111111-1111-1111-1111-111111111111' INVALID_SETTING_VALUE = 'INVALID VALUE' @@ -546,7 +555,9 @@ def test_get_not_found(self): self.assertEqual(response.status_code, 404) -class TestProjectCreateView(ProjectMixin, RoleAssignmentMixin, ViewTestBase): +class TestProjectCreateView( + ProjectMixin, RoleAssignmentMixin, RemoteSiteMixin, ViewTestBase +): """Tests for ProjectCreateView""" @classmethod @@ -559,6 +570,7 @@ def _get_post_data(cls, title, project_type, parent, owner): 'owner': owner.sodar_uuid, 'description': 'description', 'public_guest_access': False, + REMOTE_SITE_FIELD: False, } # Add settings values ret.update( @@ -568,7 +580,25 @@ def _get_post_data(cls, title, project_type, parent, owner): def setUp(self): super().setUp() + self.category = self.make_project( + 'TestCategory', PROJECT_TYPE_CATEGORY, None + ) + self.owner_as_cat = self.make_assignment( + self.category, self.user, self.role_owner + ) + self.remote_site = self.make_site( + name=REMOTE_SITE_NAME, + url=REMOTE_SITE_URL, + mode=SITE_MODE_TARGET, + description='', + secret=REMOTE_SITE_SECRET, + user_display=True, + sodar_uuid=REMOTE_SITE_UUID, + ) self.app_alert_model = get_backend_api('appalerts_backend').get_model() + self.url = reverse( + 'projectroles:create', kwargs={'project': self.category.sodar_uuid} + ) def test_get_top(self): """Test ProjectCreateView GET with top level category creation form""" @@ -581,6 +611,7 @@ def test_get_top(self): self.assertIsInstance(form.fields['type'].widget, HiddenInput) self.assertIsInstance(form.fields['parent'].widget, HiddenInput) self.assertEqual(form.initial['owner'], self.user) + self.assertNotIn(REMOTE_SITE_FIELD, form.fields) @override_settings(PROJECTROLES_DISABLE_CATEGORIES=True) def test_get_top_disable_categories(self): @@ -594,25 +625,15 @@ def test_get_top_disable_categories(self): self.assertIsInstance(form.fields['type'].widget, HiddenInput) self.assertIsInstance(form.fields['parent'].widget, HiddenInput) self.assertEqual(form.initial['owner'], self.user) + self.assertNotIn(REMOTE_SITE_FIELD, form.fields) def test_get_sub(self): """Test GET under category""" - category = self.make_project( - 'TestCategory', PROJECT_TYPE_CATEGORY, None - ) - self.make_assignment(category, self.user, self.role_owner) # Create another user to enable checking for owner selection self.make_user('user_new') - with self.login(self.user): - response = self.client.get( - reverse( - 'projectroles:create', - kwargs={'project': category.sodar_uuid}, - ) - ) + response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - form = response.context['form'] self.assertIsNotNone(form) self.assertEqual( @@ -631,27 +652,41 @@ def test_get_sub(self): ) self.assertIsInstance(form.fields['parent'].widget, HiddenInput) self.assertEqual(form.initial['owner'], self.user) + self.assertIn(REMOTE_SITE_FIELD, form.fields) + self.assertEqual(form.fields[REMOTE_SITE_FIELD].initial, False) + + @override_settings(PROJECTROLES_SITE_MODE=SITE_MODE_TARGET) + def test_get_sub_target_remote(self): + """Test GET under category as target with remote sites""" + self.remote_site.mode = SITE_MODE_SOURCE + self.remote_site.save() + # Create peer site + peer_site = self.make_site( + name='peer_site', + url='https://peer.site', + mode=SITE_MODE_PEER, + ) + with self.login(self.user): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + form = response.context['form'] + self.assertNotIn(REMOTE_SITE_FIELD, form.fields) + self.assertNotIn( + 'remote_site.{}'.format(peer_site.sodar_uuid), form.fields + ) def test_get_cat_member(self): """Test GET under category as category non-owner""" - category = self.make_project( - 'TestCategory', PROJECT_TYPE_CATEGORY, None - ) - self.make_assignment(category, self.user, self.role_owner) user_new = self.make_user('user_new') - self.make_assignment(category, user_new, self.role_contributor) + self.make_assignment(self.category, user_new, self.role_contributor) with self.login(user_new): - response = self.client.get( - reverse( - 'projectroles:create', - kwargs={'project': category.sodar_uuid}, - ) - ) + response = self.client.get(self.url) self.assertEqual(response.status_code, 200) form = response.context['form'] # Current user should be the initial value for owner self.assertEqual(form.initial['owner'], user_new) + self.assertIn(REMOTE_SITE_FIELD, form.fields) def test_get_project(self): """Test GET under project (should fail)""" @@ -680,27 +715,22 @@ def test_get_not_found(self): def test_get_parent_owner(self): """Test GET with parent owner as initial value""" - category = self.make_project( - 'TestCategory', PROJECT_TYPE_CATEGORY, None - ) user_new = self.make_user('user_new') - self.make_assignment(category, user_new, self.role_owner) + # self.make_assignment(category, user_new, self.role_owner) + self.owner_as_cat.user = user_new + self.owner_as_cat.save() with self.login(self.user): - response = self.client.get( - reverse( - 'projectroles:create', - kwargs={'project': category.sodar_uuid}, - ) - ) + response = self.client.get(self.url) self.assertEqual(response.status_code, 200) form = response.context['form'] self.assertEqual(form.initial['owner'], user_new) def test_post_top_level_category(self): """Test POST for top level category""" - self.assertEqual(Project.objects.all().count(), 0) + self.assertEqual(Project.objects.count(), 1) + self.assertEqual(RemoteProject.objects.count(), 0) data = self._get_post_data( - title='TestCategory', + title='NewTestCategory', project_type=PROJECT_TYPE_CATEGORY, parent=None, owner=self.user, @@ -709,39 +739,41 @@ def test_post_top_level_category(self): response = self.client.post(reverse('projectroles:create'), data) self.assertEqual(response.status_code, 302) - self.assertEqual(Project.objects.all().count(), 1) - project = Project.objects.first() - self.assertIsNotNone(project) + self.assertEqual(Project.objects.count(), 2) + category = Project.objects.filter(title='NewTestCategory').first() + self.assertIsNotNone(category) expected = { - 'id': project.pk, - 'title': 'TestCategory', + 'id': category.pk, + 'title': 'NewTestCategory', 'type': PROJECT_TYPE_CATEGORY, 'parent': None, 'description': 'description', 'public_guest_access': False, 'archive': False, - 'full_title': 'TestCategory', + 'full_title': 'NewTestCategory', 'has_public_children': False, - 'sodar_uuid': project.sodar_uuid, + 'sodar_uuid': category.sodar_uuid, } - model_dict = model_to_dict(project) + model_dict = model_to_dict(category) model_dict.pop('readme', None) self.assertEqual(model_dict, expected) + # Assert remote projects + self.assertEqual(RemoteProject.objects.count(), 0) # Assert settings - settings = AppSetting.objects.filter(project=project) + settings = AppSetting.objects.filter(project=category) self.assertEqual(settings.count(), 1) setting = settings.first() self.assertEqual(setting.name, 'project_category_bool_setting') # Assert owner role assignment owner_as = RoleAssignment.objects.get( - project=project, role=self.role_owner + project=category, role=self.role_owner ) expected = { 'id': owner_as.pk, - 'project': project.pk, + 'project': category.pk, 'role': self.role_owner.pk, 'user': self.user.pk, 'sodar_uuid': owner_as.sodar_uuid, @@ -754,7 +786,7 @@ def test_post_top_level_category(self): response, reverse( 'projectroles:detail', - kwargs={'project': project.sodar_uuid}, + kwargs={'project': category.sodar_uuid}, ), ) # Same user so no alerts or emails @@ -763,35 +795,24 @@ def test_post_top_level_category(self): def test_post_project(self): """Test POST for project creation""" - category = self.make_project( - 'TestCategory', PROJECT_TYPE_CATEGORY, None - ) - self.make_assignment(category, self.user, self.role_owner) - data = self._get_post_data( title='TestProject', project_type=PROJECT_TYPE_PROJECT, - parent=category, + parent=self.category, owner=self.user, ) with self.login(self.user): - response = self.client.post( - reverse( - 'projectroles:create', - kwargs={'project': category.sodar_uuid}, - ), - data, - ) + response = self.client.post(self.url, data) self.assertEqual(response.status_code, 302) - self.assertEqual(Project.objects.all().count(), 2) + self.assertEqual(Project.objects.count(), 2) project = Project.objects.get(type=PROJECT_TYPE_PROJECT) expected = { 'id': project.pk, 'title': 'TestProject', 'type': PROJECT_TYPE_PROJECT, - 'parent': category.pk, + 'parent': self.category.pk, 'description': 'description', 'public_guest_access': False, 'archive': False, @@ -803,6 +824,7 @@ def test_post_project(self): model_dict.pop('readme', None) self.assertEqual(model_dict, expected) + self.assertEqual(RemoteProject.objects.count(), 0) project_settings = [ 'project_bool_setting', 'project_callable_setting', @@ -840,29 +862,16 @@ def test_post_project(self): def test_post_project_different_owner(self): """Test POST for project with different owner""" - # Create category and add new user as member - category = self.make_project( - title='TestCategory', type=PROJECT_TYPE_CATEGORY, parent=None - ) - self.make_assignment(category, self.user, self.role_owner) user_new = self.make_user('user_new') - self.make_assignment(category, user_new, self.role_contributor) - + self.make_assignment(self.category, user_new, self.role_contributor) data = self._get_post_data( title='TestProject', project_type=PROJECT_TYPE_PROJECT, - parent=category, + parent=self.category, owner=user_new, ) with self.login(self.user): # Post as category owner - response = self.client.post( - reverse( - 'projectroles:create', - kwargs={'project': category.sodar_uuid}, - ), - data, - ) - + response = self.client.post(self.url, data) self.assertEqual(response.status_code, 302) self.assertEqual(Project.objects.all().count(), 2) project = Project.objects.get(type=PROJECT_TYPE_PROJECT) @@ -880,29 +889,17 @@ def test_post_project_different_owner(self): def test_post_project_different_owner_disable_email(self): """Test POST for project with different owner and disabled email""" - category = self.make_project( - title='TestCategory', type=PROJECT_TYPE_CATEGORY, parent=None - ) - self.make_assignment(category, self.user, self.role_owner) user_new = self.make_user('user_new') - self.make_assignment(category, user_new, self.role_contributor) + self.make_assignment(self.category, user_new, self.role_contributor) app_settings.set(APP_NAME, 'notify_email_role', False, user=user_new) - data = self._get_post_data( title='TestProject', project_type=PROJECT_TYPE_PROJECT, - parent=category, + parent=self.category, owner=user_new, ) with self.login(self.user): # Post as category owner - response = self.client.post( - reverse( - 'projectroles:create', - kwargs={'project': category.sodar_uuid}, - ), - data, - ) - + response = self.client.post(self.url, data) self.assertEqual(response.status_code, 302) self.assertEqual(Project.objects.all().count(), 2) project = Project.objects.get(type=PROJECT_TYPE_PROJECT) @@ -914,28 +911,16 @@ def test_post_project_different_owner_disable_email(self): def test_post_project_cat_member(self): """Test POST for project as category member""" - category = self.make_project( - title='TestCategory', type=PROJECT_TYPE_CATEGORY, parent=None - ) - self.make_assignment(category, self.user, self.role_owner) user_new = self.make_user('user_new') - self.make_assignment(category, user_new, self.role_contributor) - + self.make_assignment(self.category, user_new, self.role_contributor) data = self._get_post_data( title='TestProject', project_type=PROJECT_TYPE_PROJECT, - parent=category, + parent=self.category, owner=user_new, ) with self.login(user_new): - response = self.client.post( - reverse( - 'projectroles:create', - kwargs={'project': category.sodar_uuid}, - ), - data, - ) - + response = self.client.post(self.url, data) self.assertEqual(response.status_code, 302) self.assertEqual(Project.objects.all().count(), 2) project = Project.objects.get(type=PROJECT_TYPE_PROJECT) @@ -955,31 +940,19 @@ def test_post_project_cat_member(self): def test_post_project_cat_member_disable_email(self): """Test POST for project as category member with disabled email""" - category = self.make_project( - title='TestCategory', type=PROJECT_TYPE_CATEGORY, parent=None - ) - self.make_assignment(category, self.user, self.role_owner) user_new = self.make_user('user_new') - self.make_assignment(category, user_new, self.role_contributor) + self.make_assignment(self.category, user_new, self.role_contributor) app_settings.set( APP_NAME, 'notify_email_project', False, user=self.user ) - data = self._get_post_data( title='TestProject', project_type=PROJECT_TYPE_PROJECT, - parent=category, + parent=self.category, owner=user_new, ) with self.login(user_new): - response = self.client.post( - reverse( - 'projectroles:create', - kwargs={'project': category.sodar_uuid}, - ), - data, - ) - + response = self.client.post(self.url, data) self.assertEqual(response.status_code, 302) project = Project.objects.get(type=PROJECT_TYPE_PROJECT) self.assertEqual(project.get_owner().user, user_new) @@ -989,28 +962,41 @@ def test_post_project_cat_member_disable_email(self): def test_post_project_title_delimiter(self): """Test POST with category delimiter in title (should fail)""" - category = self.make_project( - 'TestCategory', PROJECT_TYPE_CATEGORY, None - ) - self.make_assignment(category, self.user, self.role_owner) self.assertEqual(Project.objects.all().count(), 1) data = self._get_post_data( title='Test{}Project'.format(CAT_DELIMITER), project_type=PROJECT_TYPE_PROJECT, - parent=category, + parent=self.category, owner=self.user, ) with self.login(self.user): - response = self.client.post( - reverse( - 'projectroles:create', - kwargs={'project': category.sodar_uuid}, - ), - data, - ) + response = self.client.post(self.url, data) self.assertEqual(response.status_code, 200) self.assertEqual(Project.objects.all().count(), 1) + def test_post_remote(self): + """Test POST with added remote project""" + self.assertEqual(RemoteProject.objects.count(), 0) + data = self._get_post_data( + title='TestProject', + project_type=PROJECT_TYPE_PROJECT, + parent=self.category, + owner=self.user, + ) + data[REMOTE_SITE_FIELD] = True + with self.login(self.user): + response = self.client.post(self.url, data) + + self.assertEqual(response.status_code, 302) + self.assertEqual(Project.objects.count(), 2) + project = Project.objects.get(type=PROJECT_TYPE_PROJECT) + self.assertEqual(RemoteProject.objects.count(), 1) + rp = RemoteProject.objects.first() + self.assertEqual(rp.project_uuid, project.sodar_uuid) + self.assertEqual(rp.project, project) + self.assertEqual(rp.site, self.remote_site) + self.assertEqual(rp.level, REMOTE_LEVEL_READ_ROLES) + class TestProjectUpdateView( ProjectMixin, RoleAssignmentMixin, RemoteTargetMixin, ViewTestBase @@ -1074,6 +1060,15 @@ def setUp(self): self.owner_as = self.make_assignment( self.project, self.user, self.role_owner ) + self.remote_site = self.make_site( + name=REMOTE_SITE_NAME, + url=REMOTE_SITE_URL, + mode=SITE_MODE_TARGET, + description='', + secret=REMOTE_SITE_SECRET, + user_display=True, + sodar_uuid=REMOTE_SITE_UUID, + ) self.app_alert_model = get_backend_api('appalerts_backend').get_model() self.url = reverse( 'projectroles:update', @@ -1083,6 +1078,7 @@ def setUp(self): 'projectroles:update', kwargs={'project': self.category.sodar_uuid}, ) + self.timeline = get_backend_api('timeline_backend') def test_get_project(self): """Test GET with project""" @@ -1094,6 +1090,70 @@ def test_get_project(self): self.assertIsInstance(form.fields['type'].widget, HiddenInput) self.assertNotIsInstance(form.fields['parent'].widget, HiddenInput) self.assertIsInstance(form.fields['owner'].widget, HiddenInput) + self.assertEqual(form.fields[REMOTE_SITE_FIELD].initial, None) + + def test_get_remote_site_user_display_disabled(self): + """Test GET with user_display disabled on remote site""" + self.remote_site.user_display = False + self.remote_site.save() + with self.login(self.user): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + form = response.context['form'] + self.assertNotIn(REMOTE_SITE_FIELD, form.fields) + + def test_get_remote_site_owner_modifiable_disabled(self): + """Test GET with owner_modifiable disabled on remote site""" + self.remote_site.owner_modifiable = False + self.remote_site.save() + with self.login(self.user): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + form = response.context['form'] + self.assertNotIn(REMOTE_SITE_FIELD, form.fields) + + def test_get_remote_project(self): + """Test GET with remote target project and READ_ROLES perm""" + self.make_remote_project( + project_uuid=self.project.sodar_uuid, + site=self.remote_site, + level=REMOTE_LEVEL_READ_ROLES, + project=self.project, + ) + with self.login(self.user): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + form = response.context['form'] + self.assertEqual(form.fields[REMOTE_SITE_FIELD].initial, True) + + def test_get_remote_projcet_revoked(self): + """Test GET with remote target project and REVOKED perm""" + self.make_remote_project( + project_uuid=self.project.sodar_uuid, + site=self.remote_site, + level=REMOTE_LEVEL_REVOKED, + project=self.project, + ) + with self.login(self.user): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + form = response.context['form'] + self.assertEqual(form.fields[REMOTE_SITE_FIELD].initial, False) + + def test_get_remote_project_read_info(self): + """Test GET with remote target project and READ_INFO perm""" + self.make_remote_project( + project_uuid=self.project.sodar_uuid, + site=self.remote_site, + level=REMOTE_LEVEL_READ_INFO, + project=self.project, + ) + with self.login(self.user): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + form = response.context['form'] + # NOTE: READ_INFO and VIEW_AVAIL are not currently supported + self.assertEqual(form.fields[REMOTE_SITE_FIELD].initial, False) def test_get_no_parent_role(self): """Test GET for current parent selectability without parent role""" @@ -1106,12 +1166,10 @@ def test_get_no_parent_role(self): 'TestCategory2', PROJECT_TYPE_CATEGORY, None ) self.make_assignment(category2, user_new, self.role_owner) - with self.login(user_new): response = self.client.get(self.url) self.assertEqual(response.status_code, 200) form = response.context['form'] - self.assertIsNotNone(form) # Ensure self.category (with no user_new rights) is initial self.assertEqual(form.initial['parent'], self.category.sodar_uuid) self.assertEqual(len(form.fields['parent'].choices), 2) @@ -1122,26 +1180,25 @@ def test_get_category(self): response = self.client.get(self.url_cat) self.assertEqual(response.status_code, 200) form = response.context['form'] - self.assertIsNotNone(form) self.assertIsInstance(form.fields['type'].widget, HiddenInput) self.assertNotIsInstance(form.fields['parent'].widget, HiddenInput) self.assertIsInstance(form.fields['owner'].widget, HiddenInput) + self.assertNotIn(REMOTE_SITE_FIELD, form.fields) @override_settings(PROJECTROLES_SITE_MODE=SITE_MODE_TARGET) - def test_get_remote(self): + def test_get_remote_as_target(self): """Test GET with remote project as target""" self.set_up_as_target(projects=[self.category, self.project]) with self.login(self.user): response = self.client.get(self.url) - self.assertEqual(response.status_code, 200) form = response.context['form'] - self.assertIsNotNone(form) self.assertIsInstance(form.fields['title'].widget, HiddenInput) self.assertIsInstance(form.fields['type'].widget, HiddenInput) self.assertIsInstance(form.fields['parent'].widget, HiddenInput) self.assertIsInstance(form.fields['description'].widget, HiddenInput) self.assertIsInstance(form.fields['readme'].widget, HiddenInput) + self.assertNotIn(REMOTE_SITE_FIELD, form.fields) self.assertNotIsInstance( form.fields[ 'settings.example_project_app.project_str_setting' @@ -1192,16 +1249,17 @@ def test_get_not_found(self): def test_post_project_superuser(self): """Test POST for project as superuser""" - timeline = get_backend_api('timeline_backend') category_new = self.make_project('NewCat', PROJECT_TYPE_CATEGORY, None) self.make_assignment(category_new, self.user, self.role_owner) self.assertEqual(Project.objects.all().count(), 3) + self.assertEqual(RemoteProject.objects.count(), 0) data = model_to_dict(self.project) data['title'] = 'updated title' data['description'] = 'updated description' data['parent'] = category_new.sodar_uuid # NOTE: Updated parent data['owner'] = self.user.sodar_uuid # NOTE: Must add owner + data[REMOTE_SITE_FIELD] = False # Add settings values ps = self._get_post_app_settings(self.project, self.user) data.update(ps) @@ -1226,6 +1284,8 @@ def test_post_project_superuser(self): model_dict.pop('readme', None) self.assertEqual(model_dict, expected) + # Assert remote projects + self.assertEqual(RemoteProject.objects.count(), 0) # Assert settings self._assert_app_settings(ps) # Assert hidden settings @@ -1253,12 +1313,16 @@ def test_post_project_superuser(self): ) # Assert timeline event tl_event = ( - timeline.get_project_events(self.project).order_by('-pk').first() + self.timeline.get_project_events(self.project) + .order_by('-pk') + .first() ) self.assertEqual(tl_event.event_name, 'project_update') self.assertIn('title', tl_event.extra_data) self.assertIn('description', tl_event.extra_data) self.assertIn('parent', tl_event.extra_data) + self.assertNotIn('remote_sites', tl_event.description) + self.assertNotIn('remote_sites', tl_event.extra_data) # No alert or mail, because the owner has not changed self.assertEqual(self.app_alert_model.objects.count(), 0) self.assertEqual(len(mail.outbox), 0) @@ -1286,12 +1350,14 @@ def test_post_project_regular_user(self): category_new = self.make_project('NewCat', PROJECT_TYPE_CATEGORY, None) self.make_assignment(category_new, user_new, self.role_owner) self.assertEqual(Project.objects.all().count(), 3) + self.assertEqual(RemoteProject.objects.count(), 0) data = model_to_dict(self.project) data['title'] = 'updated title' data['description'] = 'updated description' data['parent'] = category_new.sodar_uuid data['owner'] = user_new.sodar_uuid + data[REMOTE_SITE_FIELD] = False ps = self._get_post_app_settings(self.project, user_new) data.update(ps) with self.login(user_new): @@ -1315,6 +1381,7 @@ def test_post_project_regular_user(self): model_dict.pop('readme', None) self.assertEqual(model_dict, expected) + self.assertEqual(RemoteProject.objects.count(), 0) self._assert_app_settings(ps) # Hidden settings should remain as they were not changed hidden_val = app_settings.get( @@ -1484,7 +1551,7 @@ def test_post_category_parent(self): self.assertEqual(self.app_alert_model.objects.count(), 0) self.assertEqual(len(mail.outbox), 0) - def test_post_project_parent_different_owner(self): + def test_post_parent_different_owner(self): """Test POST with updated project parent and different parent owner""" user_new = self.make_user('user_new') self.owner_as_cat.user = user_new @@ -1515,7 +1582,7 @@ def test_post_project_parent_different_owner(self): mail.outbox[0].subject, ) - def test_post_project_parent_different_owner_disable_email(self): + def test_post_parent_different_owner_disable_email(self): """Test POST with updated parent and different parent owner with disabled email""" user_new = self.make_user('user_new') self.owner_as_cat.user = user_new @@ -1539,8 +1606,101 @@ def test_post_project_parent_different_owner_disable_email(self): self.assertEqual(self.app_alert_model.objects.first().user, user_new) self.assertEqual(len(mail.outbox), 0) - @override_settings(PROJECTROLES_SITE_MODE=SITE_MODE_TARGET) def test_post_remote(self): + """Test POST with enabled remote project""" + self.assertEqual(RemoteProject.objects.count(), 0) + data = model_to_dict(self.project) + data['parent'] = self.category.sodar_uuid + data['owner'] = self.user.sodar_uuid + data[REMOTE_SITE_FIELD] = True + ps = self._get_post_app_settings(self.project, self.user) + data.update(ps) + with self.login(self.user): + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(RemoteProject.objects.count(), 1) + rp = RemoteProject.objects.first() + self.assertEqual(rp.project_uuid, self.project.sodar_uuid) + self.assertEqual(rp.project, self.project) + self.assertEqual(rp.site, self.remote_site) + self.assertEqual(rp.level, REMOTE_LEVEL_READ_ROLES) + tl_event = ( + self.timeline.get_project_events(self.project) + .order_by('-pk') + .first() + ) + self.assertIn('remote_sites', tl_event.description) + self.assertEqual( + tl_event.extra_data['remote_sites'], + {str(self.remote_site.sodar_uuid): True}, + ) + + def test_post_remote_revoke(self): + """Test POST to revoke existing remote project""" + rp = self.make_remote_project( + project_uuid=self.project.sodar_uuid, + site=self.remote_site, + level=REMOTE_LEVEL_READ_ROLES, + project=self.project, + ) + self.assertEqual(RemoteProject.objects.count(), 1) + data = model_to_dict(self.project) + data['parent'] = self.category.sodar_uuid + data['owner'] = self.user.sodar_uuid + data[REMOTE_SITE_FIELD] = False + ps = self._get_post_app_settings(self.project, self.user) + data.update(ps) + with self.login(self.user): + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(RemoteProject.objects.count(), 1) + rp.refresh_from_db() + self.assertEqual(rp.level, REMOTE_LEVEL_REVOKED) + tl_event = ( + self.timeline.get_project_events(self.project) + .order_by('-pk') + .first() + ) + self.assertIn('remote_sites', tl_event.description) + self.assertEqual( + tl_event.extra_data['remote_sites'], + {str(self.remote_site.sodar_uuid): False}, + ) + + def test_post_remote_enable_revoked(self): + """Test POST to re-enable revoked remote project""" + rp = self.make_remote_project( + project_uuid=self.project.sodar_uuid, + site=self.remote_site, + level=REMOTE_LEVEL_REVOKED, + project=self.project, + ) + self.assertEqual(RemoteProject.objects.count(), 1) + data = model_to_dict(self.project) + data['parent'] = self.category.sodar_uuid + data['owner'] = self.user.sodar_uuid + data[REMOTE_SITE_FIELD] = True + ps = self._get_post_app_settings(self.project, self.user) + data.update(ps) + with self.login(self.user): + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(RemoteProject.objects.count(), 1) + rp.refresh_from_db() + self.assertEqual(rp.level, REMOTE_LEVEL_READ_ROLES) + tl_event = ( + self.timeline.get_project_events(self.project) + .order_by('-pk') + .first() + ) + self.assertIn('remote_sites', tl_event.description) + self.assertEqual( + tl_event.extra_data['remote_sites'], + {str(self.remote_site.sodar_uuid): True}, + ) + + @override_settings(PROJECTROLES_SITE_MODE=SITE_MODE_TARGET) + def test_post_target_remote(self): """Test POST with remote project as target""" self.set_up_as_target(projects=[self.category, self.project]) data = model_to_dict(self.project) @@ -4949,6 +5109,7 @@ def test_post_source(self): 'description': REMOTE_SITE_DESC, 'secret': REMOTE_SITE_SECRET, 'user_display': REMOTE_SITE_USER_DISPLAY, + 'owner_modifiable': REMOTE_SITE_OWNER_MODIFY, } with self.login(self.user): response = self.client.post(self.url, data) @@ -4964,6 +5125,7 @@ def test_post_source(self): 'secret': REMOTE_SITE_SECRET, 'sodar_uuid': site.sodar_uuid, 'user_display': REMOTE_SITE_USER_DISPLAY, + 'owner_modifiable': REMOTE_SITE_OWNER_MODIFY, } model_dict = model_to_dict(site) self.assertEqual(model_dict, expected) @@ -4989,6 +5151,7 @@ def test_post_target(self): 'description': REMOTE_SITE_DESC, 'secret': REMOTE_SITE_SECRET, 'user_display': REMOTE_SITE_USER_DISPLAY, + 'owner_modifiable': REMOTE_SITE_OWNER_MODIFY, } with self.login(self.user): response = self.client.post(self.url, data) @@ -5005,6 +5168,7 @@ def test_post_target(self): 'secret': REMOTE_SITE_SECRET, 'sodar_uuid': site.sodar_uuid, 'user_display': REMOTE_SITE_USER_DISPLAY, + 'owner_modifiable': REMOTE_SITE_OWNER_MODIFY, } model_dict = model_to_dict(site) self.assertEqual(model_dict, expected) @@ -5096,6 +5260,7 @@ def test_post(self): 'description': REMOTE_SITE_NEW_DESC, 'secret': REMOTE_SITE_SECRET, 'user_display': REMOTE_SITE_USER_DISPLAY, + 'owner_modifiable': REMOTE_SITE_OWNER_MODIFY, } with self.login(self.user): response = self.client.post(self.url, data) @@ -5111,6 +5276,7 @@ def test_post(self): 'secret': REMOTE_SITE_SECRET, 'sodar_uuid': site.sodar_uuid, 'user_display': REMOTE_SITE_USER_DISPLAY, + 'owner_modifiable': REMOTE_SITE_OWNER_MODIFY, } model_dict = model_to_dict(site) self.assertEqual(model_dict, expected) diff --git a/projectroles/views.py b/projectroles/views.py index 5b9e04c4..35777a8f 100644 --- a/projectroles/views.py +++ b/projectroles/views.py @@ -80,6 +80,7 @@ REMOTE_LEVEL_VIEW_AVAIL = SODAR_CONSTANTS['REMOTE_LEVEL_VIEW_AVAIL'] REMOTE_LEVEL_READ_INFO = SODAR_CONSTANTS['REMOTE_LEVEL_READ_INFO'] REMOTE_LEVEL_READ_ROLES = SODAR_CONSTANTS['REMOTE_LEVEL_READ_ROLES'] +REMOTE_LEVEL_REVOKED = SODAR_CONSTANTS['REMOTE_LEVEL_REVOKED'] APP_SETTING_SCOPE_PROJECT = SODAR_CONSTANTS['APP_SETTING_SCOPE_PROJECT'] APP_SETTING_SCOPE_PROJECT_USER = SODAR_CONSTANTS[ 'APP_SETTING_SCOPE_PROJECT_USER' @@ -932,8 +933,12 @@ def call_project_modify_api(cls, method_name, revert_name, method_args): class ProjectModifyMixin(ProjectModifyPluginViewMixin): """Mixin for Project creation/updating in UI and API views""" + #: Remote site fields + site_fields = {} + @staticmethod def _get_old_project_data(project): + """Get existing data from project fields""" return { 'title': project.title, 'parent': project.parent, @@ -943,6 +948,21 @@ def _get_old_project_data(project): 'public_guest_access': project.public_guest_access, } + @classmethod + def _get_remote_project_data(cls, project): + """Return existing remote project data""" + ret = {} + existing_sites = [] + for rp in RemoteProject.objects.filter(project=project): + ret[str(rp.site.sodar_uuid)] = rp.level == REMOTE_LEVEL_READ_ROLES + existing_sites.append(rp.site.sodar_uuid) + # Sites not yet added + for rs in RemoteSite.objects.filter( + mode=SITE_MODE_TARGET, user_display=True, owner_modifiable=True + ).exclude(sodar_uuid__in=existing_sites): + ret[str(rs.sodar_uuid)] = False + return ret + @staticmethod def _get_app_settings(data, instance, user): """ @@ -993,8 +1013,9 @@ def _get_app_settings(data, instance, user): project_settings[s_name] = s_data return project_settings - @staticmethod - def _get_project_update_data(old_data, project, project_settings): + def _get_project_update_data( + self, old_data, project, old_sites, sites, project_settings + ): extra_data = {} upd_fields = [] if old_data['title'] != project.title: @@ -1013,6 +1034,21 @@ def _get_project_update_data(old_data, project, project_settings): extra_data['public_guest_access'] = project.public_guest_access upd_fields.append('public_guest_access') + # Remote projects + if ( + settings.PROJECTROLES_SITE_MODE == SITE_MODE_SOURCE + and project.type == PROJECT_TYPE_PROJECT + ): + for s in [f.split('.')[1] for f in self.site_fields]: + if 'remote_sites' not in upd_fields and ( + s not in old_sites or bool(old_sites[s]) != bool(sites[s]) + ): + upd_fields.append('remote_sites') + if 'remote_sites' in upd_fields: + extra_data['remote_sites'] = { + k: bool(v) for k, v in sites.items() + } + # Settings for k, v in project_settings.items(): p_name = k.split('.')[1] @@ -1034,9 +1070,76 @@ def _get_timeline_ok_status(): else: return timeline.TL_STATUS_OK + def _update_remote_sites(self, project, data): + """Update project for remote sites""" + ret = {} + for f in self.site_fields: + site_uuid = f.split('.')[1] + site = RemoteSite.objects.filter(sodar_uuid=site_uuid).first() + # TODO: Validate site here + value = data[f] + rp = RemoteProject.objects.filter( + site=site, project=project + ).first() + modify = None + if rp and ( + (value and rp.level != REMOTE_LEVEL_READ_ROLES) + or (not value and rp.level == REMOTE_LEVEL_READ_ROLES) + ): + rp.level = ( + REMOTE_LEVEL_READ_ROLES if value else REMOTE_LEVEL_REVOKED + ) + rp.save() + modify = 'Updated' + elif not rp and value: # Only create if value is True + rp = RemoteProject.objects.create( + project_uuid=project.sodar_uuid, + project=project, + site=site, + level=REMOTE_LEVEL_READ_ROLES, + ) + modify = 'Created' + if modify: + logger.debug( + '{} RemoteProject for site "{}" ({}): {}'.format( + modify, site.name, site.sodar_uuid, rp.level + ) + ) + ret[site_uuid] = rp and rp.level == REMOTE_LEVEL_READ_ROLES + return ret + @classmethod + def _update_settings(cls, project, project_settings): + """Update project settings""" + is_remote = project.is_remote() + for k, v in project_settings.items(): + _, plugin_name, setting_name = k.split('.', 3) + # Skip updating global settings on target site + if is_remote: + # TODO: Optimize (this can require a lot of queries) + s_def = app_settings.get_definition( + setting_name, plugin_name=plugin_name + ) + if app_settings.get_global_value(s_def): + continue + app_settings.set( + plugin_name=k.split('.')[1], + setting_name=k.split('.')[2], + value=v, + project=project, + validate=True, + ) + def _create_timeline_event( - cls, project, action, owner, old_data, project_settings, request + self, + project, + action, + owner, + old_data, + old_sites, + sites, + project_settings, + request, ): timeline = get_backend_api('timeline_backend') if not timeline: @@ -1062,8 +1165,8 @@ def _create_timeline_event( else: # Update tl_desc = 'update ' + type_str.lower() - extra_data, upd_fields = cls._get_project_update_data( - old_data, project, project_settings + extra_data, upd_fields = self._get_project_update_data( + old_data, project, old_sites, sites, project_settings ) if extra_data.get('parent'): # Convert parent object into UUID extra_data['parent'] = str(extra_data['parent'].sodar_uuid) @@ -1082,28 +1185,6 @@ def _create_timeline_event( tl_event.add_object(owner, 'owner', owner.username) return tl_event - @classmethod - def _update_settings(cls, project, project_settings): - """Update project settings""" - is_remote = project.is_remote() - for k, v in project_settings.items(): - _, plugin_name, setting_name = k.split('.', 3) - # Skip updating global settings on target site - if is_remote: - # TODO: Optimize (this can require a lot of queries) - s_def = app_settings.get_definition( - setting_name, plugin_name=plugin_name - ) - if app_settings.get_global_value(s_def): - continue - app_settings.set( - plugin_name=k.split('.')[1], - setting_name=k.split('.')[2], - value=v, - project=project, - validate=True, - ) - @classmethod def _notify_users(cls, project, action, owner, old_parent, request): """ @@ -1242,6 +1323,18 @@ def modify_project(self, data, request, instance=None): if not owner and old_project: # In case of a PATCH request owner = old_project.get_owner().user + # Create/update RemoteProject objects + old_sites = {} + sites = {} + if ( + settings.PROJECTROLES_SITE_MODE == SITE_MODE_SOURCE + and project.type == PROJECT_TYPE_PROJECT + ): + self.site_fields = [f for f in data if f.startswith('remote_site.')] + old_sites = self._get_remote_project_data(project) + sites = self._update_remote_sites(project, data) + + # Get app settings, store old settings project_settings = self._get_app_settings(data, project, request.user) old_settings = None if action == PROJECT_ACTION_UPDATE: @@ -1249,7 +1342,14 @@ def modify_project(self, data, request, instance=None): # Create timeline event tl_event = self._create_timeline_event( - project, action, owner, old_data, project_settings, request + project, + action, + owner, + old_data, + old_sites, + sites, + project_settings, + request, ) # Get old parent for project moving old_parent = (