diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 750ef479..9424f223 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,55 @@ Changelog for the **SODAR Core** Django app package. Loosely follows the `Keep a Changelog `_ guidelines. +v1.0.2 (2024-09-09) +=================== + +Added +----- + +- **General** + - ``python3.11-gdbm`` dependency (#1491) +- **Projectroles** + - ``get_user_by_uuid()`` common template tag (#1478) + - ``ProjectInvite.get_url()`` helper (#1485) + - ``ProjectInvite.refresh_date_expire()`` helper (#1486) + +Changed +------- + +- **General** + - Upgrade minimum Django version to v4.2.16 (#1481) +- **Projectroles** + - Truncate app setting values in ``remoteproject_sync.html`` (#1474) + - JSON app setting value rendering in ``remoteproject_sync.html`` (#1472) + - Change ``AppSettingAPI.compare_value()`` into public method (#1479) + - Refactor ``AppLinkContent`` (#1470, #1483) +- **Userprofile** + - Improve user settings list layout (#1490) + +Fixed +----- + +- **General** + - Celery process raising ``dbm.error`` (#1491) + - Celery process raising ``broker_connection_retry`` warning (#1493) +- **Bgjobs** + - Non-migrated changes reported by squashed migrations (#1475) +- **Projectroles** + - Incorrect app plugin link order in ``get_project_app_links()`` (#1468) + - Remote sync crash on updating user with additional email (#1476) + - User scope app setting display in ``remoteproject_sync.html`` (#1478) + - Incorrect boolean comparison in ``AppSettingAPI._compare_value()`` with string value (#1473) + - Boolean app setting update status in remote sync (#1473) + +Removed +------- + +- **Projectroles** + - ``build_invite_url()`` utility method (#1485) + - ``get_expiry_date()`` utility method (#1486) + + v1.0.1 (2024-08-08) =================== diff --git a/README.rst b/README.rst index 6f446330..66591ecb 100644 --- a/README.rst +++ b/README.rst @@ -117,7 +117,7 @@ and breaking changes are possible. .. code-block:: console - pip install django-sodar-core==1.0.1 + pip install django-sodar-core==1.0.2 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/bgjobs/migrations/0001_squashed_0006_auto_20200526_1657.py b/bgjobs/migrations/0001_squashed_0006_auto_20200526_1657.py index 5f72aaa5..5bdd0b7f 100644 --- a/bgjobs/migrations/0001_squashed_0006_auto_20200526_1657.py +++ b/bgjobs/migrations/0001_squashed_0006_auto_20200526_1657.py @@ -81,14 +81,12 @@ class Migration(migrations.Migration): 'date_created', models.DateTimeField( auto_now_add=True, - default=django.utils.timezone.now, help_text='DateTime of creation', ), ), ( 'user', models.ForeignKey( - default=1, on_delete=django.db.models.deletion.CASCADE, related_name='background_jobs', to=settings.AUTH_USER_MODEL, diff --git a/codemeta.json b/codemeta.json index 9a8934aa..2ee7f48b 100644 --- a/codemeta.json +++ b/codemeta.json @@ -42,10 +42,10 @@ "codeRepository": "https://github.com/bihealth/sodar-core", "datePublished": "2023-12-06", "dateModified": "2023-12-06", - "dateCreated": "2024-08-08", + "dateCreated": "2024-09-09", "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": "v1.0.1" + "version": "v1.0.2" } diff --git a/config/settings/base.py b/config/settings/base.py index 16118f19..fb26cf2f 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -314,6 +314,8 @@ CELERYD_TASK_TIME_LIMIT = 5 * 60 # http://docs.celeryproject.org/en/latest/userguide/configuration.html#task-soft-time-limit CELERYD_TASK_SOFT_TIME_LIMIT = 60 +# https://docs.celeryq.dev/en/latest/userguide/configuration.html#broker-connection-retry-on-startup +CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = False # Django REST framework diff --git a/docs/source/app_projectroles_api_rest.rst b/docs/source/app_projectroles_api_rest.rst index 43c19a44..10629a54 100644 --- a/docs/source/app_projectroles_api_rest.rst +++ b/docs/source/app_projectroles_api_rest.rst @@ -101,6 +101,25 @@ field upon a successfully processed request. For creation views, the ``sodar_uuid`` of the created object is returned along with other object fields. +Pagination +---------- + +From SODAR Core V1.0 onwards, list views support pagination unless otherwise +specified. Pagination can be enabled by providing the ``?page=x`` query string +in the API request. This will change the return data into a paginated format. +Example: + +.. code-block:: python + + { + 'count' 170, + 'next': 'api/url?page=3', + 'previous': 'api/url?page=1', + 'results': [ + # ... + ] + } + Projectroles REST API Versioning ================================ diff --git a/docs/source/app_projectroles_integration.rst b/docs/source/app_projectroles_integration.rst index 798f8a3f..34441788 100644 --- a/docs/source/app_projectroles_integration.rst +++ b/docs/source/app_projectroles_integration.rst @@ -81,11 +81,11 @@ chapter. First, add the ``django-sodar-core`` dependency into your ``requirements/base.txt`` file. Make sure you are pointing to the desired -release tag or commit ID. +release tag. .. code-block:: console - django-sodar-core==1.0.1 + django-sodar-core==x.y.z Install the requirements for development: diff --git a/docs/source/conf.py b/docs/source/conf.py index 1a5db10f..88fae263 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -29,7 +29,7 @@ # The short X.Y version version = '1.0' # The full version, including alpha/beta/rc tags -release = '1.0.1' +release = '1.0.2' # -- General configuration --------------------------------------------------- diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst index 2d8d1a8a..542a51ce 100644 --- a/docs/source/getting_started.rst +++ b/docs/source/getting_started.rst @@ -12,12 +12,13 @@ Installation ============ The ``django-sodar-core`` package can be installed into your Django project -from PyPI as follows. It is strongly recommended to specify a version tag, as -the package is under active development and breaking changes are expected. +from PyPI as follows. It is strongly recommended to pin the import to a specific +version tag, as the package is under active development and breaking changes are +expected. .. code-block:: console - pip install django-sodar-core==1.0.1 + pip install django-sodar-core==x.y.z 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 a12f6819..8c14a719 100644 --- a/docs/source/major_changes.rst +++ b/docs/source/major_changes.rst @@ -10,6 +10,32 @@ older SODAR Core version. For a complete list of changes in current and previous releases, see the :ref:`full changelog`. +v1.0.2 (2024-09-09) +******************* + +Release Highlights +================== + +- Update app setting rendering in remote sync UI +- Fix project sidebar and dropdown app plugin link order +- Fix remote sync crash on updating user with additional email +- Fix Celery process issues +- General bug fixes and minor updates + +Breaking Changes +================ + +System Prerequisites +-------------------- + +Django Version + The minimum Django version has been bumped to v4.2.16. +Celery Support + It is recommended to install the ``python3.11-gdbm`` package (or equivalent + for the Python version in use) to ensure full compatibility of the current + Celery implementation. + + v1.0.1 (2024-08-08) ******************* diff --git a/projectroles/app_settings.py b/projectroles/app_settings.py index 6fe38362..c8b27459 100644 --- a/projectroles/app_settings.py +++ b/projectroles/app_settings.py @@ -302,24 +302,6 @@ def _get_json_value(cls, value): except Exception: raise ValueError('Value is not valid JSON: {}'.format(value)) - @classmethod - def _compare_value(cls, obj, input_value): - """ - Compare input value to value in an AppSetting object. Return True if - values match, False if there is a mismatch. - - :param obj: AppSetting object - :param input_value: Input value (string, int, bool or dict) - :return: Bool - """ - if obj.type == 'JSON': - return ( - not obj.value_json and not input_value - ) or obj.value_json == cls._get_json_value(input_value) - elif obj.type == 'BOOLEAN': - return bool(int(obj.value)) == input_value - return obj.value == str(input_value) - @classmethod def _log_set_debug( cls, action, plugin_name, setting_name, value, project, user @@ -587,7 +569,7 @@ def set( else: q_kwargs['app_plugin'] = None setting = AppSetting.objects.get(**q_kwargs) - if cls._compare_value(setting, value): + if cls.compare_value(setting, value): return False if validate: cls.validate( @@ -912,6 +894,26 @@ def get_global_value(cls, setting_def): return not setting_def['local'] # Inverse value return setting_def.get('global', APP_SETTING_GLOBAL_DEFAULT) + @classmethod + def compare_value(cls, obj, input_value): + """ + Compare input value to value in an AppSetting object. Return True if + values match, False if there is a mismatch. + + :param obj: AppSetting object + :param input_value: Input value (string, int, bool or dict) + :return: Bool + """ + if obj.type == 'JSON': + return ( + not obj.value_json and not input_value + ) or obj.value_json == cls._get_json_value(input_value) + elif obj.type == 'BOOLEAN': + if isinstance(input_value, str): + input_value = bool(int(input_value)) + return bool(int(obj.value)) == input_value + return obj.value == str(input_value) + def get_example_setting_default(project=None, user=None): """ diff --git a/projectroles/email.py b/projectroles/email.py index 8bfa7be0..2a3bccc0 100644 --- a/projectroles/email.py +++ b/projectroles/email.py @@ -12,7 +12,7 @@ from projectroles.app_settings import AppSettingAPI from projectroles.models import SODARUserAdditionalEmail from projectroles.plugins import get_app_plugin -from projectroles.utils import build_invite_url, get_display_name +from projectroles.utils import get_display_name app_settings = AppSettingAPI() @@ -502,12 +502,11 @@ def send_invite_mail(invite, request): :param request: HttpRequest object :return: Amount of sent email (int) """ - invite_url = build_invite_url(invite, request) message = get_invite_body( project=invite.project, issuer=invite.issuer, role_name=invite.role.name, - invite_url=invite_url, + invite_url=invite.get_url(request), date_expire_str=localtime(invite.date_expire).strftime( '%Y-%m-%d %H:%M' ), diff --git a/projectroles/management/commands/batchupdateroles.py b/projectroles/management/commands/batchupdateroles.py index b16ec99c..7838b604 100644 --- a/projectroles/management/commands/batchupdateroles.py +++ b/projectroles/management/commands/batchupdateroles.py @@ -18,7 +18,7 @@ SODAR_CONSTANTS, ) from projectroles.views import RoleAssignmentModifyMixin, ProjectInviteMixin -from projectroles.utils import get_expiry_date, build_secret +from projectroles.utils import build_secret logger = ManagementCommandLogger(__name__) @@ -116,7 +116,6 @@ def _invite_user(self, email, project, role): project=project, role=role, issuer=self.issuer, - date_expire=get_expiry_date(), secret=build_secret(), ) self.handle_invite(invite, self.request, add_message=False) diff --git a/projectroles/models.py b/projectroles/models.py index 01602443..e96e0acd 100644 --- a/projectroles/models.py +++ b/projectroles/models.py @@ -11,6 +11,7 @@ from django.db import models from django.db.models import Q from django.urls import reverse +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from djangoplugins.models import Plugin @@ -1101,6 +1102,22 @@ def __repr__(self): values = (self.project.title, self.email, self.role.name, self.active) return 'ProjectInvite({})'.format(', '.join(repr(v) for v in values)) + @classmethod + def _get_date_expire(cls): + """ + Return expiry date based on current date + INVITE_EXPIRY_DAYS + + :return: DateTime object + """ + return timezone.now() + timezone.timedelta( + days=settings.PROJECTROLES_INVITE_EXPIRY_DAYS + ) + + def save(self, *args, **kwargs): + if not self.pk and not self.date_expire: # Set date_expire on create + self.date_expire = self._get_date_expire() + super().save(*args, **kwargs) + # Custom row-level functions def is_ldap(self): @@ -1126,6 +1143,27 @@ def is_ldap(self): return True return False + def get_url(self, request): + """ + Return invite URL for a project invitation. + + :param request: HttpRequest object + :return: URL (string) + """ + return request.build_absolute_uri( + reverse( + 'projectroles:invite_accept', kwargs={'secret': self.secret} + ) + ) + + def reset_date_expire(self): + """ + Reset date_expire to current date plus defined expiry days. Saves the + object. + """ + self.date_expire = self._get_date_expire() + self.save() + # RemoteSite ------------------------------------------------------------------- diff --git a/projectroles/remote_projects.py b/projectroles/remote_projects.py index 3bf2e7d3..36f9f53a 100644 --- a/projectroles/remote_projects.py +++ b/projectroles/remote_projects.py @@ -17,10 +17,7 @@ from djangoplugins.models import Plugin -from projectroles.app_settings import ( - AppSettingAPI, - APP_SETTING_GLOBAL_DEFAULT, -) +from projectroles.app_settings import AppSettingAPI, APP_SETTING_GLOBAL_DEFAULT from projectroles.models import ( Project, Role, @@ -32,6 +29,7 @@ AppSetting, ) from projectroles.plugins import get_backend_api +from projectroles.utils import build_secret app_settings = AppSettingAPI() @@ -530,8 +528,13 @@ def _sync_user(self, uuid, user_data): ) ) for e in add_emails: + if SODARUserAdditionalEmail.objects.filter( + user=user, email=e + ).exists(): + continue + # TODO: Remove redundant secret once #1477 is implemented email_obj = SODARUserAdditionalEmail.objects.create( - user=user, email=e, verified=True + user=user, email=e, verified=True, secret=build_secret(16) ) logger.info( 'Created user {} additional email "{}"'.format( @@ -1169,7 +1172,7 @@ def _sync_app_setting(self, uuid, set_data): set_data['status'] = 'skipped' return # Skip if value is identical - if app_settings._compare_value( + if app_settings.compare_value( obj, ad['value_json'] if obj.type == 'JSON' else ad['value'] ): logger.info( @@ -1269,7 +1272,7 @@ def sync_remote_data(self, site, remote_data, request=None): return self.remote_data # Peer Sites - logger.info('Synchronizing Peer Sites...') + logger.info('Synchronizing peer sites..') if self.remote_data.get('peer_sites', None): for remote_site_uuid, site_data in self.remote_data[ 'peer_sites' @@ -1282,9 +1285,9 @@ def sync_remote_data(self, site, remote_data, request=None): self._update_peer_site(remote_site_uuid, site_data) else: self._create_peer_site(remote_site_uuid, site_data) - logger.info('Peer Site Sync OK') + logger.info('Peer site sync OK') else: - logger.info('No new Peer Sites to sync') + logger.info('No peer sites to sync') # Users logger.info('Synchronizing users..') diff --git a/projectroles/serializers.py b/projectroles/serializers.py index b459cfe1..b8ee6263 100644 --- a/projectroles/serializers.py +++ b/projectroles/serializers.py @@ -21,7 +21,7 @@ CAT_DELIMITER_ERROR_MSG, ROLE_PROJECT_TYPE_ERROR_MSG, ) -from projectroles.utils import build_secret, get_expiry_date +from projectroles.utils import build_secret from projectroles.views import ( ProjectModifyMixin, RoleAssignmentModifyMixin, @@ -349,7 +349,7 @@ def validate(self, attrs): def create(self, validated_data): validated_data['issuer'] = self.context['request'].user - validated_data['date_expire'] = get_expiry_date() + # validated_data['date_expire'] = get_expiry_date() validated_data['secret'] = build_secret() return super().create(validated_data) diff --git a/projectroles/templates/projectroles/remoteproject_sync.html b/projectroles/templates/projectroles/remoteproject_sync.html index 24816f08..059c510f 100644 --- a/projectroles/templates/projectroles/remoteproject_sync.html +++ b/projectroles/templates/projectroles/remoteproject_sync.html @@ -228,7 +228,7 @@

id="sodar-pr-remote-update-table-settings"> - {% get_display_name 'PROJECT' title=True %} + {% get_display_name 'PROJECT' title=True %}/User Name Plugin Value @@ -244,23 +244,25 @@

{% if p_obj %} {% get_project_link p_obj full_title=True as p_link %} {{ p_link | safe }} - {% else %} - {{ p.title }} + {% elif a.user_uuid %} + {% get_user_by_uuid a.user_uuid as u_obj %} + {% get_user_html u_obj as u_html %} + {{ u_html | safe }} {% endif %} {{ a.name }} {% if a.app_plugin %}{{ a.app_plugin }}{% else %}projectroles{% endif %} - - {% if a.type == 'JSON' %} - {{ a.value_json }} - {% elif a.type == 'BOOLEAN' %} - {% if a.value == '1' %}True{% else %}False{% endif %} - {% else %} - {{ a.value }} - {% endif %} - + {% if a.type == 'JSON' and a.value_json %} + JSON + {% elif a.type == 'JSON' %} + Empty + {% elif a.type == 'BOOLEAN' %} + {% if a.value == '1' %}True{% else %}False{% endif %} + {% else %} + {{ a.value | truncatechars:255 }} + {% endif %} {{ a.status | title }} {% endif %} diff --git a/projectroles/templatetags/projectroles_common_tags.py b/projectroles/templatetags/projectroles_common_tags.py index 96930c39..98b01642 100644 --- a/projectroles/templatetags/projectroles_common_tags.py +++ b/projectroles/templatetags/projectroles_common_tags.py @@ -57,19 +57,19 @@ def check_backend(name): @register.simple_tag def get_project_by_uuid(sodar_uuid): """Return Project by sodar_uuid""" - try: - return Project.objects.get(sodar_uuid=sodar_uuid) - except Project.DoesNotExist: - return None + return Project.objects.filter(sodar_uuid=sodar_uuid).first() + + +@register.simple_tag +def get_user_by_uuid(sodar_uuid): + """Return SODARUser by sodar_uuid""" + return User.objects.filter(sodar_uuid=sodar_uuid).first() @register.simple_tag def get_user_by_username(username): """Return User by username""" - try: - return User.objects.get(username=username) - except User.DoesNotExist: - return None + return User.objects.filter(username=username).first() # Django helpers --------------------------------------------------------------- diff --git a/projectroles/templatetags/projectroles_tags.py b/projectroles/templatetags/projectroles_tags.py index 160b7236..ce134f41 100644 --- a/projectroles/templatetags/projectroles_tags.py +++ b/projectroles/templatetags/projectroles_tags.py @@ -288,7 +288,7 @@ def get_project_app_links(request, project=None): """Return sidebar links""" if isinstance(request, str): return [] - return app_links.get_project_app_links( + return app_links.get_project_links( request.user, project, app_name=request.resolver_match.app_name, diff --git a/projectroles/tests/test_app_settings.py b/projectroles/tests/test_app_settings.py index a9262872..37ccfe06 100644 --- a/projectroles/tests/test_app_settings.py +++ b/projectroles/tests/test_app_settings.py @@ -1260,3 +1260,69 @@ def test_validate_form_app_settings_user_invalid(self): app_settings, user=self.user ) self.assertEqual(errors, {'user_str_setting': INVALID_SETTING_MSG}) + + def test_compare_value_string(self): + """Test compare_value() with string values""" + n = 'project_str_setting' + v = 'value' + vf = 'valueXYZ' + app_settings.set(EXAMPLE_APP_NAME, n, v, project=self.project) + obj = AppSetting.objects.get( + app_plugin__name=EXAMPLE_APP_NAME, name=n, project=self.project + ) + self.assertEqual(app_settings.compare_value(obj, v), True) + self.assertEqual(app_settings.compare_value(obj, vf), False) + + def test_compare_value_int(self): + """Test compare_value() with int values""" + n = 'project_int_setting' + v = 0 + vf = 1 + app_settings.set(EXAMPLE_APP_NAME, n, v, project=self.project) + obj = AppSetting.objects.get( + app_plugin__name=EXAMPLE_APP_NAME, name=n, project=self.project + ) + self.assertEqual(app_settings.compare_value(obj, v), True) + self.assertEqual(app_settings.compare_value(obj, vf), False) + self.assertEqual(app_settings.compare_value(obj, str(v)), True) + self.assertEqual(app_settings.compare_value(obj, str(vf)), False) + + def test_compare_value_bool(self): + """Test compare_value() with boolean values""" + n = 'project_bool_setting' + v = True + vf = False + app_settings.set(EXAMPLE_APP_NAME, n, v, project=self.project) + obj = AppSetting.objects.get( + app_plugin__name=EXAMPLE_APP_NAME, name=n, project=self.project + ) + self.assertEqual(app_settings.compare_value(obj, v), True) + self.assertEqual(app_settings.compare_value(obj, vf), False) + self.assertEqual(app_settings.compare_value(obj, '1'), True) + self.assertEqual(app_settings.compare_value(obj, '0'), False) + + def test_compare_value_json(self): + """Test compare_value() with JSON values""" + n = 'project_json_setting' + v = {'x': 1, 'y': 2} + vf = {'a': 3, 'b': 4} + app_settings.set(EXAMPLE_APP_NAME, n, v, project=self.project) + obj = AppSetting.objects.get( + app_plugin__name=EXAMPLE_APP_NAME, name=n, project=self.project + ) + self.assertEqual(app_settings.compare_value(obj, v), True) + self.assertEqual(app_settings.compare_value(obj, vf), False) + + def test_compare_value_json_empty(self): + """Test compare_value() with empty JSON values""" + n = 'project_json_setting' + v = {} + vf = {'x': 1, 'y': 2} + app_settings.set(EXAMPLE_APP_NAME, n, v, project=self.project) + obj = AppSetting.objects.get( + app_plugin__name=EXAMPLE_APP_NAME, name=n, project=self.project + ) + self.assertEqual(app_settings.compare_value(obj, v), True) + self.assertEqual(app_settings.compare_value(obj, vf), False) + self.assertEqual(app_settings.compare_value(obj, None), True) + self.assertEqual(app_settings.compare_value(obj, ''), True) diff --git a/projectroles/tests/test_models.py b/projectroles/tests/test_models.py index b080b469..4efac30c 100644 --- a/projectroles/tests/test_models.py +++ b/projectroles/tests/test_models.py @@ -2,13 +2,12 @@ import uuid -from django.conf import settings from django.contrib.auth.models import Group from django.core.exceptions import ValidationError from django.forms.models import model_to_dict from django.urls import reverse from django.utils import timezone -from django.test import override_settings +from django.test import RequestFactory, override_settings from test_plus.test import TestCase @@ -149,16 +148,7 @@ def make_invite( 'role': role, 'issuer': issuer, 'message': message, - 'date_expire': ( - date_expire - if date_expire - else ( - timezone.now() - + timezone.timedelta( - days=settings.PROJECTROLES_INVITE_EXPIRY_DAYS - ) - ) - ), + 'date_expire': date_expire, 'secret': secret or SECRET, 'active': True, } @@ -1006,6 +996,24 @@ def test_is_ldap_non_ldap_domain(self): """Test is_ldap() with non-LDAP domain in email""" self.assertEqual(self.invite.is_ldap(), False) + def test_get_url(self): + """Test get_url()""" + url = reverse( + 'projectroles:invite_accept', kwargs={'secret': self.invite.secret} + ) + request = RequestFactory().get(url) + self.assertEqual( + self.invite.get_url(request), request.build_absolute_uri() + ) + + def test_reset_date_expire(self): + """Test reset_date_expire()""" + old_date = self.invite.date_expire + self.assertIsNotNone(old_date) + self.invite.reset_date_expire() + self.assertNotEqual(self.invite.date_expire, old_date) + self.assertTrue(self.invite.date_expire > old_date) + @override_settings( ENABLE_LDAP=True, AUTH_LDAP_USERNAME_DOMAIN='xyz', diff --git a/projectroles/tests/test_remote_project_api.py b/projectroles/tests/test_remote_project_api.py index de047f9b..bf85126f 100644 --- a/projectroles/tests/test_remote_project_api.py +++ b/projectroles/tests/test_remote_project_api.py @@ -2398,6 +2398,23 @@ def test_update_user_add_email(self): self.assertEqual(email.email, ADD_EMAIL) self.assertEqual(email.verified, True) + def test_update_user_add_email_exists(self): + """Test sync with existing additional email on user""" + target_user = User.objects.get(sodar_uuid=SOURCE_USER_UUID) + SODARUserAdditionalEmail.objects.create( + user=target_user, + email=ADD_EMAIL, + verified=True, + secret=build_secret(16), + ) + self.assertEqual(SODARUserAdditionalEmail.objects.count(), 1) + remote_data = self.default_data + remote_data['users'][SOURCE_USER_UUID]['additional_emails'] = [ + ADD_EMAIL + ] + self.remote_api.sync_remote_data(self.source_site, remote_data) + self.assertEqual(SODARUserAdditionalEmail.objects.count(), 1) + def test_update_user_add_email_delete(self): """Test sync with deleting existing additional email on user""" self.make_email(self.user_target, ADD_EMAIL) diff --git a/projectroles/tests/test_templatetags.py b/projectroles/tests/test_templatetags.py index 73297dfa..cd3a814a 100644 --- a/projectroles/tests/test_templatetags.py +++ b/projectroles/tests/test_templatetags.py @@ -114,13 +114,20 @@ def test_check_backend(self): self.assertEqual(c_tags.check_backend('sodar_cache'), True) self.assertEqual(c_tags.check_backend('NON_EXISTING_PLUGIN'), False) - def test_get_projcet_by_uuid(self): + def test_get_project_by_uuid(self): """Test get_project_by_uuid()""" self.assertEqual( c_tags.get_project_by_uuid(self.project.sodar_uuid), self.project ) self.assertEqual(c_tags.get_project_by_uuid(NON_EXISTING_UUID), None) + def test_get_user_by_uuid(self): + """Test get_user_by_uuid()""" + self.assertEqual( + c_tags.get_user_by_uuid(self.user.sodar_uuid), self.user + ) + self.assertEqual(c_tags.get_user_by_uuid(NON_EXISTING_UUID), None) + def test_get_user_by_username(self): """Test get_user_by_username()""" self.assertEqual( @@ -604,20 +611,6 @@ def test_get_sidebar_links_project_detail_view(self): 'icon': 'mdi:cube', 'active': True, }, - { - 'name': 'app-plugin-bgjobs', - 'url': f'/bgjobs/list/{self.project.sodar_uuid}', - 'label': 'Background Jobs', - 'icon': 'mdi:server', - 'active': False, - }, - { - 'name': 'app-plugin-example_project_app', - 'url': f'/examples/project/{self.project.sodar_uuid}', - 'label': 'Example Project App', - 'icon': 'mdi:rocket-launch', - 'active': False, - }, { 'name': 'app-plugin-filesfolders', 'url': f'/files/{self.project.sodar_uuid}', @@ -632,6 +625,20 @@ def test_get_sidebar_links_project_detail_view(self): 'icon': 'mdi:clock-time-eight', 'active': False, }, + { + 'name': 'app-plugin-bgjobs', + 'url': f'/bgjobs/list/{self.project.sodar_uuid}', + 'label': 'Background Jobs', + 'icon': 'mdi:server', + 'active': False, + }, + { + 'name': 'app-plugin-example_project_app', + 'url': f'/examples/project/{self.project.sodar_uuid}', + 'label': 'Example Project App', + 'icon': 'mdi:rocket-launch', + 'active': False, + }, { 'name': 'project-roles', 'url': f'/project/members/{self.project.sodar_uuid}', diff --git a/projectroles/tests/test_utils.py b/projectroles/tests/test_utils.py new file mode 100644 index 00000000..207905f7 --- /dev/null +++ b/projectroles/tests/test_utils.py @@ -0,0 +1,585 @@ +"""Utils tests for the projectroles app""" + +import re + +from django.conf import settings +from django.contrib.auth.models import AnonymousUser +from django.test import override_settings +from django.urls import reverse + +from test_plus import TestCase + +from projectroles.models import SODAR_CONSTANTS +from projectroles.tests.test_models import ProjectMixin, RoleAssignmentMixin +from projectroles.tests.test_views import ViewTestBase +from projectroles.utils import ( + AppLinkContent, + get_display_name, + build_secret, + get_app_names, +) + + +app_links = AppLinkContent() + + +# SODAR constants +PROJECT_TYPE_CATEGORY = SODAR_CONSTANTS['PROJECT_TYPE_CATEGORY'] +PROJECT_TYPE_PROJECT = SODAR_CONSTANTS['PROJECT_TYPE_PROJECT'] +SITE_MODE_TARGET = SODAR_CONSTANTS['SITE_MODE_TARGET'] + +# Local constants +CONSTANTS_OVERRIDE = { + 'DISPLAY_NAMES': { + 'CATEGORY': {'default': 'bar', 'plural': 'bars'}, + 'PROJECT': {'default': 'foo', 'plural': 'foos'}, + } +} + + +class TestUtils(TestCase): + """Tests for general utilities""" + + def test_get_display_name(self): + """Test get_display_name()""" + self.assertEqual(get_display_name(PROJECT_TYPE_PROJECT), 'project') + self.assertEqual( + get_display_name(PROJECT_TYPE_PROJECT, title=True), 'Project' + ) + self.assertEqual( + get_display_name(PROJECT_TYPE_PROJECT, count=3), 'projects' + ) + self.assertEqual( + get_display_name(PROJECT_TYPE_PROJECT, title=True, count=3), + 'Projects', + ) + self.assertEqual(get_display_name(PROJECT_TYPE_CATEGORY), 'category') + self.assertEqual( + get_display_name(PROJECT_TYPE_CATEGORY, title=True), 'Category' + ) + self.assertEqual( + get_display_name(PROJECT_TYPE_CATEGORY, count=3), 'categories' + ) + self.assertEqual( + get_display_name(PROJECT_TYPE_CATEGORY, title=True, count=3), + 'Categories', + ) + + # TODO: Test with override + + def test_build_secret(self): + """Test build_secret()""" + secret = build_secret() + self.assertEqual(re.match(r'[a-z\d]{32}', secret).string, secret) + self.assertEqual(len(build_secret(16)), 16) + + @override_settings(PROJECTROLES_SECRET_LENGTH=16) + def test_build_secret_override(self): + """Test build_secret() with default length setting override""" + self.assertEqual(len(build_secret()), 16) + + def test_get_app_names(self): + """Test get_app_names()""" + app_names = get_app_names() + self.assertNotEqual(len(app_names), 0) + self.assertFalse(any([a.startswith('django.') for a in app_names])) + self.assertFalse(any(['.apps.' in a for a in app_names])) + self.assertNotIn(settings.SITE_PACKAGE, app_names) + + +class TestAppLinkContent(ProjectMixin, RoleAssignmentMixin, ViewTestBase): + """Tests for AppLinkContent""" + + def setUp(self): + super().setUp() + self.user_owner = self.make_user('user_owner') + self.category = self.make_project( + 'TestCategory', PROJECT_TYPE_CATEGORY, None + ) + self.owner_as_cat = self.make_assignment( + self.category, self.user_owner, self.role_owner + ) + self.project = self.make_project( + 'TestProject', PROJECT_TYPE_PROJECT, self.category + ) + self.owner_as = self.make_assignment( + self.project, self.user_owner, self.role_owner + ) + + def test_get_project_links(self): + """Test get_project_links() with project""" + kw = {'project': self.project.sodar_uuid} + expected = [ + { + 'name': 'project-detail', + 'url': reverse('projectroles:detail', kwargs=kw), + 'label': 'Project Overview', + 'icon': 'mdi:cube', + 'active': False, + }, + { + 'name': 'app-plugin-filesfolders', + 'url': reverse('filesfolders:list', kwargs=kw), + 'label': 'Files', + 'icon': 'mdi:file', + 'active': False, + }, + { + 'name': 'app-plugin-timeline', + 'url': reverse('timeline:list_project', kwargs=kw), + 'label': 'Timeline', + 'icon': 'mdi:clock-time-eight', + 'active': False, + }, + { + 'name': 'app-plugin-bgjobs', + 'url': reverse('bgjobs:list', kwargs=kw), + 'label': 'Background Jobs', + 'icon': 'mdi:server', + 'active': False, + }, + { + 'name': 'app-plugin-example_project_app', + 'url': reverse('example_project_app:example', kwargs=kw), + 'label': 'Example Project App', + 'icon': 'mdi:rocket-launch', + 'active': False, + }, + { + 'name': 'project-roles', + 'url': reverse('projectroles:roles', kwargs=kw), + 'label': 'Members', + 'icon': 'mdi:account-multiple', + 'active': False, + }, + { + 'name': 'project-update', + 'url': reverse('projectroles:update', kwargs=kw), + 'label': 'Update Project', + 'icon': 'mdi:lead-pencil', + 'active': False, + }, + ] + self.assertEqual( + app_links.get_project_links(self.user_owner, self.project), expected + ) + + def test_get_project_links_app_name(self): + """Test get_project_links() with project and specific app plugin""" + kw = {'project': self.project.sodar_uuid} + expected = [ + { + 'name': 'project-detail', + 'url': reverse('projectroles:detail', kwargs=kw), + 'label': 'Project Overview', + 'icon': 'mdi:cube', + 'active': False, + }, + { + 'name': 'app-plugin-filesfolders', + 'url': reverse('filesfolders:list', kwargs=kw), + 'label': 'Files', + 'icon': 'mdi:file', + 'active': True, # This should be active + }, + { + 'name': 'app-plugin-timeline', + 'url': reverse('timeline:list_project', kwargs=kw), + 'label': 'Timeline', + 'icon': 'mdi:clock-time-eight', + 'active': False, + }, + { + 'name': 'app-plugin-bgjobs', + 'url': reverse('bgjobs:list', kwargs=kw), + 'label': 'Background Jobs', + 'icon': 'mdi:server', + 'active': False, + }, + { + 'name': 'app-plugin-example_project_app', + 'url': reverse('example_project_app:example', kwargs=kw), + 'label': 'Example Project App', + 'icon': 'mdi:rocket-launch', + 'active': False, + }, + { + 'name': 'project-roles', + 'url': reverse('projectroles:roles', kwargs=kw), + 'label': 'Members', + 'icon': 'mdi:account-multiple', + 'active': False, + }, + { + 'name': 'project-update', + 'url': reverse('projectroles:update', kwargs=kw), + 'label': 'Update Project', + 'icon': 'mdi:lead-pencil', + 'active': False, + }, + ] + self.assertEqual( + app_links.get_project_links( + self.user_owner, self.project, app_name='filesfolders' + ), + expected, + ) + + def test_get_project_links_category(self): + """Test get_project_links() with category""" + kw = {'project': self.category.sodar_uuid} + expected = [ + { + 'name': 'project-detail', + 'url': reverse('projectroles:detail', kwargs=kw), + 'label': 'Category Overview', + 'icon': 'mdi:rhombus-split', + 'active': False, + }, + { + 'name': 'app-plugin-timeline', + 'url': reverse('timeline:list_project', kwargs=kw), + 'label': 'Timeline', + 'icon': 'mdi:clock-time-eight', + 'active': False, + }, + { + 'name': 'project-roles', + 'url': reverse('projectroles:roles', kwargs=kw), + 'label': 'Members', + 'icon': 'mdi:account-multiple', + 'active': False, + }, + { + 'name': 'project-update', + 'url': reverse('projectroles:update', kwargs=kw), + 'label': 'Update Category', + 'icon': 'mdi:lead-pencil', + 'active': False, + }, + { + 'name': 'project-create', + 'url': reverse('projectroles:create', kwargs=kw), + 'label': 'Create Project or Category', + 'icon': 'mdi:plus-thick', + 'active': False, + }, + ] + self.assertEqual( + app_links.get_project_links(self.user_owner, self.category), + expected, + ) + + def test_get_project_links_home_superuser(self): + """Test get_project_links() with home URL as superuser""" + expected = [ + { + 'name': 'home-project-create', + 'url': reverse('projectroles:create'), + 'label': 'Create Category', + 'icon': 'mdi:plus-thick', + 'active': False, + }, + ] + self.assertEqual( + app_links.get_project_links(self.user, url_name='home'), + expected, + ) + + def test_get_project_links_home_regular_user(self): + """Test get_project_links() with home URL as regular user""" + self.assertEqual( + app_links.get_project_links(self.user_owner, url_name='home'), [] + ) + + def test_get_project_links_url_name_projectroles(self): + """Test get_project_links() with projectroles URL name""" + links = app_links.get_project_links( + self.user_owner, + self.project, + app_name='projectroles', + url_name='roles', + ) + self.assertEqual(len(links), 7) + for i in range(0, 6): + if i == 5: + self.assertEqual(links[i]['name'], 'project-roles') + self.assertEqual(links[i]['active'], True) + else: + self.assertEqual(links[i]['active'], False) + + def test_get_project_links_url_name_app_plugin(self): + """Test get_project_links() with app plugin URL name""" + links = app_links.get_project_links( + self.user_owner, + self.project, + app_name='filesfolders', + url_name='file_create', + ) + self.assertEqual(len(links), 7) + for i in range(0, 6): + if i == 1: + self.assertEqual(links[i]['name'], 'app-plugin-filesfolders') + self.assertEqual(links[i]['active'], True) + else: + self.assertEqual(links[i]['active'], False) + + def test_get_project_links_contributor(self): + """Test get_project_links() with project as contributor""" + user_new = self.make_user('user_new') + self.make_assignment(self.project, user_new, self.role_contributor) + links = app_links.get_project_links(user_new, self.project) + self.assertEqual(len(links), 6) + link_names = [link['name'] for link in links] + self.assertNotIn('project-update', link_names) + + def test_get_project_links_guest(self): + """Test get_project_links() with project as guest""" + user_new = self.make_user('user_new') + self.make_assignment(self.project, user_new, self.role_guest) + links = app_links.get_project_links(user_new, self.project) + self.assertEqual(len(links), 6) + link_names = [link['name'] for link in links] + self.assertNotIn('project-update', link_names) + + def test_get_project_links_category_contributor(self): + """Test get_project_links() with category as contributor""" + user_new = self.make_user('user_new') + self.make_assignment(self.category, user_new, self.role_contributor) + links = app_links.get_project_links(user_new, self.category) + self.assertEqual(len(links), 4) + link_names = [link['name'] for link in links] + self.assertNotIn('project-update', link_names) # NOTE: Can create + + def test_get_project_links_category_guest(self): + """Test get_project_links() with category as guest""" + user_new = self.make_user('user_new') + self.make_assignment(self.category, user_new, self.role_guest) + links = app_links.get_project_links(user_new, self.category) + self.assertEqual(len(links), 3) + link_names = [link['name'] for link in links] + self.assertNotIn('project-update', link_names) + self.assertNotIn('project-create', link_names) + + @override_settings(PROJECTROLES_HIDE_PROJECT_APPS=['filesfolders']) + def test_get_project_links_hidden_app(self): + """Test get_project_links() with hidden app""" + links = app_links.get_project_links(self.user, self.project) + self.assertEqual(len(links), 6) + link_names = [link['name'] for link in links] + self.assertNotIn('app-plugin-filesfolders', link_names) + + @override_settings( + PROJECTROLES_SITE_MODE=SITE_MODE_TARGET, + PROJECTROLES_TARGET_CREATE=False, + ) + def test_get_project_links_category_target_disallow(self): + """Test get_project_links() as target site with creation disallowed""" + links = app_links.get_project_links(self.user_owner, self.category) + self.assertEqual(len(links), 4) + link_names = [link['name'] for link in links] + self.assertNotIn('project-create', link_names) + + @override_settings(PROJECTROLES_SITE_MODE=SITE_MODE_TARGET) + def test_get_project_links_category_target_allow(self): + """Test get_project_links() as target site with creation sallowed""" + links = app_links.get_project_links(self.user_owner, self.category) + self.assertEqual(len(links), 5) + + @override_settings( + PROJECTROLES_SITE_MODE=SITE_MODE_TARGET, + PROJECTROLES_TARGET_CREATE=False, + ) + def test_get_project_links_category_target_disallow_superuser(self): + """Test get_project_links() as superuser on target site with creation disallowed""" + links = app_links.get_project_links(self.user, self.category) + self.assertEqual(len(links), 4) + link_names = [link['name'] for link in links] + self.assertNotIn('project-create', link_names) + + def test_get_user_links(self): + """Test get_user_links() as regular user""" + expected = [ + { + 'name': 'appalerts', + 'url': reverse('appalerts:list'), + 'label': 'App Alerts', + 'icon': 'mdi:alert-octagram', + 'active': False, + }, + { + 'name': 'example_site_app', + 'url': reverse('example_site_app:example'), + 'label': 'Example Site App', + 'icon': 'mdi:rocket-launch-outline', + 'active': False, + }, + { + 'name': 'timeline_site', + 'url': reverse('timeline:list_site'), + 'label': 'Site-Wide Events', + 'icon': 'mdi:clock-time-eight-outline', + 'active': False, + }, + { + 'name': 'tokens', + 'url': reverse('tokens:list'), + 'label': 'API Tokens', + 'icon': 'mdi:key-chain-variant', + 'active': False, + }, + { + 'name': 'userprofile', + 'url': reverse('userprofile:detail'), + 'label': 'User Profile', + 'icon': 'mdi:account-details', + 'active': False, + }, + { + 'name': 'sign-out', + 'url': reverse('logout'), + 'label': 'Logout', + 'icon': 'mdi:logout-variant', + 'active': False, + }, + ] + self.assertEqual(app_links.get_user_links(self.user_owner), expected) + + def test_get_user_links_superuser(self): + """Test get_user_links() as superuser""" + expected = [ + { + 'name': 'adminalerts', + 'url': reverse('adminalerts:list'), + 'label': 'Admin Alerts', + 'icon': 'mdi:alert', + 'active': False, + }, + { + 'name': 'appalerts', + 'url': reverse('appalerts:list'), + 'label': 'App Alerts', + 'icon': 'mdi:alert-octagram', + 'active': False, + }, + { + 'name': 'bgjobs_site', + 'url': reverse('bgjobs:site_list'), + 'label': 'Site Background Jobs', + 'icon': 'mdi:server', + 'active': False, + }, + { + 'name': 'example_site_app', + 'url': reverse('example_site_app:example'), + 'label': 'Example Site App', + 'icon': 'mdi:rocket-launch-outline', + 'active': False, + }, + { + 'name': 'remotesites', + 'url': reverse('projectroles:remote_sites'), + 'label': 'Remote Site Access', + 'icon': 'mdi:cloud-sync', + 'active': False, + }, + { + 'name': 'siteinfo', + 'url': reverse('siteinfo:info'), + 'label': 'Site Info', + 'icon': 'mdi:bar-chart', + 'active': False, + }, + { + 'name': 'timeline_site', + 'url': reverse('timeline:list_site'), + 'label': 'Site-Wide Events', + 'icon': 'mdi:clock-time-eight-outline', + 'active': False, + }, + { + 'name': 'timeline_site_admin', + 'url': reverse('timeline:list_admin'), + 'label': 'All Timeline Events', + 'icon': 'mdi:web-clock', + 'active': False, + }, + { + 'name': 'tokens', + 'url': reverse('tokens:list'), + 'label': 'API Tokens', + 'icon': 'mdi:key-chain-variant', + 'active': False, + }, + { + 'name': 'userprofile', + 'url': reverse('userprofile:detail'), + 'label': 'User Profile', + 'icon': 'mdi:account-details', + 'active': False, + }, + { + 'name': 'admin', + 'url': '/admin/', + 'label': 'Django Admin', + 'icon': 'mdi:cogs', + 'active': False, + }, + { + 'name': 'sign-out', + 'url': reverse('logout'), + 'label': 'Logout', + 'icon': 'mdi:logout-variant', + 'active': False, + }, + ] + self.assertEqual(app_links.get_user_links(self.user), expected) + + def test_get_user_links_app_name(self): + """Test get_user_links() with app plugin name""" + links = app_links.get_user_links(self.user_owner, app_name='tokens') + self.assertEqual(len(links), 6) + for i in range(0, 5): + if i == 3: + self.assertEqual(links[i]['name'], 'tokens') + self.assertEqual(links[i]['active'], True) + else: + self.assertEqual(links[i]['active'], False) + + def test_get_user_links_url_name(self): + """Test get_user_links() with URL name""" + links = app_links.get_user_links( + self.user_owner, app_name='tokens', url_name='create' + ) + self.assertEqual(len(links), 6) + for i in range(0, 5): + if i == 3: + self.assertEqual(links[i]['name'], 'tokens') + self.assertEqual(links[i]['active'], True) + else: + self.assertEqual(links[i]['active'], False) + + def test_get_user_links_url_name_remote(self): + """Test get_user_links() with remote sites URL name""" + links = app_links.get_user_links( + self.user, app_name='projectroles', url_name='remote_site_create' + ) + self.assertEqual(len(links), 12) + for i in range(0, 12): + if i == 4: + self.assertEqual(links[i]['name'], 'remotesites') + self.assertEqual(links[i]['active'], True) + else: + self.assertEqual(links[i]['active'], False) + + def test_get_user_links_anon(self): + """Test get_user_links() as anonymous user""" + links = app_links.get_user_links(AnonymousUser()) + self.assertEqual(len(links), 1) + self.assertEqual(links[0]['name'], 'sign-in') + + @override_settings(PROJECTROLES_KIOSK_MODE=True) + def test_get_user_links_anon_kiosk_mode(self): + """Test get_user_links() as anonymous user and kiosk mode""" + links = app_links.get_user_links(AnonymousUser()) + self.assertEqual(len(links), 0) diff --git a/projectroles/tests/test_views_ajax.py b/projectroles/tests/test_views_ajax.py index 1cd6acca..c51f9bea 100644 --- a/projectroles/tests/test_views_ajax.py +++ b/projectroles/tests/test_views_ajax.py @@ -22,8 +22,10 @@ PROJECT_TYPE_PROJECT, ) from projectroles.tests.test_views_api import SerializedObjectMixin +from projectroles.utils import AppLinkContent +app_links = AppLinkContent() app_settings = AppSettingAPI() @@ -607,7 +609,7 @@ def setUp(self): ) def test_get(self): - """Test sidebar content retrieval""" + """Test SidebarContentAjaxView GET""" with self.login(self.user): response = self.client.get( reverse( @@ -617,62 +619,27 @@ def test_get(self): ) self.assertEqual(response.status_code, 200) expected = { - 'links': [ - { - 'name': 'project-detail', - 'url': f'/project/{self.project.sodar_uuid}', - 'label': 'Project Overview', - 'icon': 'mdi:cube', - 'active': False, - }, - { - 'name': 'app-plugin-bgjobs', - 'url': f'/bgjobs/list/{self.project.sodar_uuid}', - 'label': 'Background Jobs', - 'icon': 'mdi:server', - 'active': False, - }, - { - 'name': 'app-plugin-example_project_app', - 'url': f'/examples/project/{self.project.sodar_uuid}', - 'label': 'Example Project App', - 'icon': 'mdi:rocket-launch', - 'active': False, - }, - { - 'name': 'app-plugin-filesfolders', - 'url': f'/files/{self.project.sodar_uuid}', - 'label': 'Files', - 'icon': 'mdi:file', - 'active': False, - }, - { - 'name': 'app-plugin-timeline', - 'url': f'/timeline/{self.project.sodar_uuid}', - 'label': 'Timeline', - 'icon': 'mdi:clock-time-eight', - 'active': False, - }, - { - 'name': 'project-roles', - 'url': f'/project/members/{self.project.sodar_uuid}', - 'label': 'Members', - 'icon': 'mdi:account-multiple', - 'active': False, - }, - { - 'name': 'project-update', - 'url': f'/project/update/{self.project.sodar_uuid}', - 'label': 'Update Project', - 'icon': 'mdi:lead-pencil', - 'active': False, - }, - ], + 'links': app_links.get_project_links(self.user, self.project) + } + self.assertEqual(response.json(), expected) + + def test_get_category(self): + """Test GET with category""" + with self.login(self.user): + response = self.client.get( + reverse( + 'projectroles:ajax_sidebar', + kwargs={'project': self.category.sodar_uuid}, + ) + ) + self.assertEqual(response.status_code, 200) + expected = { + 'links': app_links.get_project_links(self.user, self.category) } self.assertEqual(response.json(), expected) - def test_get_app_links(self): - """Test sidebar content retrieval with specific app links""" + def test_get_app_link(self): + """Test GET with app plugin link""" with self.login(self.user): response = self.client.get( reverse( @@ -683,71 +650,32 @@ def test_get_app_links(self): ) self.assertEqual(response.status_code, 200) expected = { - 'links': [ - { - 'name': 'project-detail', - 'url': f'/project/{self.project.sodar_uuid}', - 'label': 'Project Overview', - 'icon': 'mdi:cube', - 'active': False, - }, - { - 'name': 'app-plugin-bgjobs', - 'url': f'/bgjobs/list/{self.project.sodar_uuid}', - 'label': 'Background Jobs', - 'icon': 'mdi:server', - 'active': False, - }, - { - 'name': 'app-plugin-example_project_app', - 'url': f'/examples/project/{self.project.sodar_uuid}', - 'label': 'Example Project App', - 'icon': 'mdi:rocket-launch', - 'active': False, - }, - { - 'name': 'app-plugin-filesfolders', - 'url': f'/files/{self.project.sodar_uuid}', - 'label': 'Files', - 'icon': 'mdi:file', - 'active': True, - }, - { - 'name': 'app-plugin-timeline', - 'url': f'/timeline/{self.project.sodar_uuid}', - 'label': 'Timeline', - 'icon': 'mdi:clock-time-eight', - 'active': False, - }, - { - 'name': 'project-roles', - 'url': f'/project/members/{self.project.sodar_uuid}', - 'label': 'Members', - 'icon': 'mdi:account-multiple', - 'active': False, - }, - { - 'name': 'project-update', - 'url': f'/project/update/{self.project.sodar_uuid}', - 'label': 'Update Project', - 'icon': 'mdi:lead-pencil', - 'active': False, - }, - ], + 'links': app_links.get_project_links( + self.user, self.project, app_name='filesfolders' + ) } self.assertEqual(response.json(), expected) - def test_get_no_access(self): - """Test sidebar content retrieval with no access""" - new_user = self.make_user('new_user') - with self.login(new_user): + def test_get_url_name(self): + """Test GET with URL name""" + with self.login(self.user): response = self.client.get( reverse( 'projectroles:ajax_sidebar', kwargs={'project': self.project.sodar_uuid}, ) + + '?app_name=filesfolders&url_name=file_create' ) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 200) + expected = { + 'links': app_links.get_project_links( + self.user, + self.project, + app_name='filesfolders', + url_name='file_create', + ) + } + self.assertEqual(response.json(), expected) class TestUserDropdownContentAjaxView(ViewTestBase): @@ -760,255 +688,60 @@ def setUp(self): self.user.save() self.reg_user = self.make_user('reg_user') - def test_regular_user(self): - """Test UserDropdownContentAjaxView with regular user""" + def test_get(self): + """Test UserDropdownContentAjaxView GET as regular user""" with self.login(self.reg_user): response = self.client.get( reverse('projectroles:ajax_user_dropdown') ) self.assertEqual(response.status_code, 200) - expected = { - 'links': [ - { - 'name': 'appalerts', - 'url': '/alerts/app/list', - 'label': 'App Alerts', - 'icon': 'mdi:alert-octagram', - 'active': False, - }, - { - 'name': 'example_site_app', - 'url': '/examples/site/example', - 'label': 'Example Site App', - 'icon': 'mdi:rocket-launch-outline', - 'active': False, - }, - { - 'name': 'timeline_site', - 'url': '/timeline/site', - 'label': 'Site-Wide Events', - 'icon': 'mdi:clock-time-eight-outline', - 'active': False, - }, - { - 'name': 'tokens', - 'url': '/tokens/', - 'label': 'API Tokens', - 'icon': 'mdi:key-chain-variant', - 'active': False, - }, - { - 'name': 'userprofile', - 'url': '/user/profile', - 'label': 'User Profile', - 'icon': 'mdi:account-details', - 'active': False, - }, - { - 'name': 'sign-out', - 'url': '/logout/', - 'label': 'Logout', - 'icon': 'mdi:logout-variant', - 'active': False, - }, - ] - } - self.assertEqual(response.json(), expected) + self.assertEqual( + response.json(), {'links': app_links.get_user_links(self.reg_user)} + ) - def test_superuser(self): - """Test UserDropdownContentAjaxView with superuser""" + def test_get_superuser(self): + """Test GET as superuser""" with self.login(self.user): response = self.client.get( reverse('projectroles:ajax_user_dropdown') ) self.assertEqual(response.status_code, 200) - expected = { - 'links': [ - { - 'name': 'adminalerts', - 'url': '/alerts/adm/list', - 'label': 'Admin Alerts', - 'icon': 'mdi:alert', - 'active': False, - }, - { - 'name': 'appalerts', - 'url': '/alerts/app/list', - 'label': 'App Alerts', - 'icon': 'mdi:alert-octagram', - 'active': False, - }, - { - 'name': 'bgjobs_site', - 'url': '/bgjobs/list', - 'label': 'Site Background Jobs', - 'icon': 'mdi:server', - 'active': False, - }, - { - 'name': 'example_site_app', - 'url': '/examples/site/example', - 'label': 'Example Site App', - 'icon': 'mdi:rocket-launch-outline', - 'active': False, - }, - { - 'name': 'remotesites', - 'url': '/project/remote/sites', - 'label': 'Remote Site Access', - 'icon': 'mdi:cloud-sync', - 'active': False, - }, - { - 'name': 'siteinfo', - 'url': '/siteinfo/info', - 'label': 'Site Info', - 'icon': 'mdi:bar-chart', - 'active': False, - }, - { - 'name': 'timeline_site', - 'url': '/timeline/site', - 'label': 'Site-Wide Events', - 'icon': 'mdi:clock-time-eight-outline', - 'active': False, - }, - { - 'name': 'timeline_site_admin', - 'url': '/timeline/site/all', - 'label': 'All Timeline Events', - 'icon': 'mdi:web-clock', - 'active': False, - }, - { - 'name': 'tokens', - 'url': '/tokens/', - 'label': 'API Tokens', - 'icon': 'mdi:key-chain-variant', - 'active': False, - }, - { - 'name': 'userprofile', - 'url': '/user/profile', - 'label': 'User Profile', - 'icon': 'mdi:account-details', - 'active': False, - }, - { - 'name': 'admin', - 'url': '/admin/', - 'label': 'Django Admin', - 'icon': 'mdi:cogs', - 'active': False, - }, - { - 'name': 'sign-out', - 'url': '/logout/', - 'label': 'Logout', - 'icon': 'mdi:logout-variant', - 'active': False, - }, - ] - } - self.assertEqual(response.json(), expected) + self.assertEqual( + response.json(), {'links': app_links.get_user_links(self.user)} + ) - def test_superuser_app_links(self): - """Test UserDropdownContentAjaxView with superuser""" - with self.login(self.user): + def test_get_app_name(self): + """Test GET with app plugin name""" + with self.login(self.reg_user): + response = self.client.get( + reverse('projectroles:ajax_user_dropdown') + '?app_name=tokens' + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + 'links': app_links.get_user_links( + self.reg_user, app_name='tokens' + ) + }, + ) + + def test_get_url_name(self): + """Test GET with URL name""" + with self.login(self.reg_user): response = self.client.get( reverse('projectroles:ajax_user_dropdown') - + '?app_name=example_site_app' + + '?app_name=tokens&url_name=create' ) self.assertEqual(response.status_code, 200) - expected = { - 'links': [ - { - 'name': 'adminalerts', - 'url': '/alerts/adm/list', - 'label': 'Admin Alerts', - 'icon': 'mdi:alert', - 'active': False, - }, - { - 'name': 'appalerts', - 'url': '/alerts/app/list', - 'label': 'App Alerts', - 'icon': 'mdi:alert-octagram', - 'active': False, - }, - { - 'name': 'bgjobs_site', - 'url': '/bgjobs/list', - 'label': 'Site Background Jobs', - 'icon': 'mdi:server', - 'active': False, - }, - { - 'name': 'example_site_app', - 'url': '/examples/site/example', - 'label': 'Example Site App', - 'icon': 'mdi:rocket-launch-outline', - 'active': True, - }, - { - 'name': 'remotesites', - 'url': '/project/remote/sites', - 'label': 'Remote Site Access', - 'icon': 'mdi:cloud-sync', - 'active': False, - }, - { - 'name': 'siteinfo', - 'url': '/siteinfo/info', - 'label': 'Site Info', - 'icon': 'mdi:bar-chart', - 'active': False, - }, - { - 'name': 'timeline_site', - 'url': '/timeline/site', - 'label': 'Site-Wide Events', - 'icon': 'mdi:clock-time-eight-outline', - 'active': False, - }, - { - 'name': 'timeline_site_admin', - 'url': '/timeline/site/all', - 'label': 'All Timeline Events', - 'icon': 'mdi:web-clock', - 'active': False, - }, - { - 'name': 'tokens', - 'url': '/tokens/', - 'label': 'API Tokens', - 'icon': 'mdi:key-chain-variant', - 'active': False, - }, - { - 'name': 'userprofile', - 'url': '/user/profile', - 'label': 'User Profile', - 'icon': 'mdi:account-details', - 'active': False, - }, - { - 'name': 'admin', - 'url': '/admin/', - 'label': 'Django Admin', - 'icon': 'mdi:cogs', - 'active': False, - }, - { - 'name': 'sign-out', - 'url': '/logout/', - 'label': 'Logout', - 'icon': 'mdi:logout-variant', - 'active': False, - }, - ] - } - self.assertEqual(response.json(), expected) + self.assertEqual( + response.json(), + { + 'links': app_links.get_user_links( + self.reg_user, app_name='tokens', url_name='create' + ) + }, + ) class TestCurrentUserRetrieveAjaxView(SerializedObjectMixin, TestCase): diff --git a/projectroles/utils.py b/projectroles/utils.py index d52645d3..9f026d7c 100644 --- a/projectroles/utils.py +++ b/projectroles/utils.py @@ -3,14 +3,10 @@ from django.conf import settings from django.urls import reverse -from django.utils import timezone from projectroles.plugins import get_active_plugins from projectroles.models import SODAR_CONSTANTS -# Settings -SECRET_LENGTH = getattr(settings, 'PROJECTROLES_SECRET_LENGTH', 32) -INVITE_EXPIRY_DAYS = settings.PROJECTROLES_INVITE_EXPIRY_DAYS # SODAR constants PROJECT_TYPE_PROJECT = SODAR_CONSTANTS['PROJECT_TYPE_PROJECT'] @@ -49,6 +45,7 @@ def get_display_name(key, title=False, count=1, plural=False): return ret.lower() if not title else ret.title() +# TODO: Deprecate (see #1487) def get_user_display_name(user, inc_user=False): """ Return full name of user for displaying. @@ -63,13 +60,15 @@ def get_user_display_name(user, inc_user=False): return user.username -def build_secret(length=SECRET_LENGTH): +def build_secret(length=None): """ Return secret string for e.g. public URLs. - :param length: Length of string if specified, default value from settings + :param length: Length of string, use None for default (integer or None) :return: Randomized secret (string) """ + if not length: + length = getattr(settings, 'PROJECTROLES_SECRET_LENGTH', 32) length = int(length) if int(length) <= 255 else 255 return ''.join( random.SystemRandom().choice(string.ascii_lowercase + string.digits) @@ -77,28 +76,6 @@ def build_secret(length=SECRET_LENGTH): ) -def build_invite_url(invite, request): - """ - Return invite URL for a project invitation. - - :param invite: ProjectInvite object - :param request: HTTP request - :return: URL (string) - """ - return request.build_absolute_uri( - reverse('projectroles:invite_accept', kwargs={'secret': invite.secret}) - ) - - -def get_expiry_date(): - """ - Return expiry date based on current date + INVITE_EXPIRY_DAYS - - :return: DateTime object - """ - return timezone.now() + timezone.timedelta(days=INVITE_EXPIRY_DAYS) - - def get_app_names(): """Return list of names for locally installed non-django apps""" ret = [] @@ -115,10 +92,11 @@ def get_app_names(): class AppLinkContent: """Class for generating application links for the UI""" + @classmethod def _is_active_projectroles( - self, app_name=None, url_name=None, link_names=None + cls, app_name=None, url_name=None, link_names=None ): - """Check if current URL is active under the projectroles app.""" + """Check if current URL is active under the projectroles app""" if not app_name and not url_name: return False # HACK: Avoid circular import @@ -130,10 +108,9 @@ def _is_active_projectroles( not link_names or url_name in link_names ) - def _is_active_plugin(self, app_plugin, app_name=None, url_name=None): - """ - Check if current URL is active for a specific app plugin. - """ + @classmethod + def _is_active_plugin(cls, app_plugin, app_name=None, url_name=None): + """Check if current URL is active for a specific app plugin""" if not app_name and not url_name: return False if app_plugin.name.startswith(app_name) and ( @@ -150,8 +127,9 @@ def _is_active_plugin(self, app_plugin, app_name=None, url_name=None): return True return False - def _is_app_visible(self, plugin, project, user): - """Check if app should be visible for user in a specific project.""" + @classmethod + def _is_app_visible(cls, plugin, project, user): + """Check if app should be visible for user in a specific project""" can_view_app = user.has_perm(plugin.app_permission, project) app_hidden = False if plugin.name in getattr( @@ -166,8 +144,9 @@ def _is_app_visible(self, plugin, project, user): return True return False - def _allow_project_creation(self): - """Check whether creating a project is allowed on the site.""" + @classmethod + def _allow_project_creation(cls): + """Check whether creating a project is allowed on the site""" if ( settings.PROJECTROLES_SITE_MODE == SODAR_CONSTANTS['SITE_MODE_TARGET'] @@ -176,14 +155,17 @@ def _allow_project_creation(self): return False return True - def get_project_app_links( + def get_project_links( self, user, project=None, app_name=None, url_name=None ): - """Return project app links based on the current project and user.""" + """Return project links based on the current project and user""" ret = [] + pr_display = get_display_name(PROJECT_TYPE_PROJECT, title=True) + cat_display = get_display_name(PROJECT_TYPE_CATEGORY, title=True) + # Add project related links if project: - project_display_name = get_display_name(project.type, title=True) + current_display = get_display_name(project.type, title=True) # Add project overview link ret.append( { @@ -192,7 +174,7 @@ def get_project_app_links( 'projectroles:detail', kwargs={'project': project.sodar_uuid}, ), - 'label': f'{project_display_name} Overview', + 'label': f'{current_display} Overview', 'icon': ( 'mdi:rhombus-split' if project.type == PROJECT_TYPE_CATEGORY @@ -206,7 +188,7 @@ def get_project_app_links( } ) # Add app plugins links - app_plugins = get_active_plugins() + app_plugins = get_active_plugins(custom_order=True) for plugin in app_plugins: if self._is_app_visible(plugin, project, user): ret.append( @@ -250,7 +232,7 @@ def get_project_app_links( 'projectroles:update', kwargs={'project': project.sodar_uuid}, ), - 'label': f'Update {project_display_name}', + 'label': f'Update {current_display}', 'icon': 'mdi:lead-pencil', 'active': self._is_active_projectroles( link_names=['update'], @@ -260,69 +242,45 @@ def get_project_app_links( } ) - # Add project and category creation links + # Add project/category creation link + allow_create = self._allow_project_creation() + create_active = self._is_active_projectroles( + link_names=['create'], app_name=app_name, url_name=url_name + ) + link = { + 'name': 'project-create', + 'icon': 'mdi:plus-thick', + 'active': create_active, + } if ( project and project.type == PROJECT_TYPE_CATEGORY and user.has_perm('projectroles.create_project', project) - and self._allow_project_creation() + and allow_create and not project.is_remote() ): - ret.append( - { - 'name': 'project-create', - 'url': reverse( - 'projectroles:create', - kwargs={'project': project.sodar_uuid}, - ), - 'label': 'Create ' - f'{get_display_name(PROJECT_TYPE_PROJECT, title=True)} ' - f'or {get_display_name(PROJECT_TYPE_CATEGORY, title=True)}', - 'icon': 'mdi:plus-thick', - 'active': self._is_active_projectroles( - link_names=['create'], - app_name=app_name, - url_name=url_name, - ), - } - ) - elif ( - getattr(settings, 'PROJECTROLES_DISABLE_CATEGORIES', False) - and user.is_superuser - ): - ret.append( - { - 'name': 'project-create', - 'url': reverse('projectroles:create'), - 'label': 'Create ' - f'{get_display_name(PROJECT_TYPE_PROJECT, title=True)}', - 'icon': 'mdi:plus-thick', - 'active': self._is_active_projectroles( - link_names=['create'], - app_name=app_name, - url_name=url_name, - ), - } + link['url'] = reverse( + 'projectroles:create', + kwargs={'project': project.sodar_uuid}, ) + link['label'] = f'Create {pr_display} or {cat_display}' + ret.append(link) elif ( (url_name == 'home' or app_name == 'projectroles' and not project) and user.has_perm('projectroles.create_project') - and self._allow_project_creation() + and allow_create ): - ret.append( - { - 'name': 'home-project-create', - 'url': reverse('projectroles:create'), - 'label': 'Create ' - f'{get_display_name(PROJECT_TYPE_CATEGORY, title=True)}', - 'icon': 'mdi:plus-thick', - 'active': self._is_active_projectroles( - link_names=['create'], - app_name=app_name, - url_name=url_name, - ), - } - ) + link['name'] = 'home-project-create' + link['url'] = reverse('projectroles:create') + link['label'] = f'Create {cat_display}' + ret.append(link) + elif ( + getattr(settings, 'PROJECTROLES_DISABLE_CATEGORIES', False) + and user.is_superuser + ): + link['url'] = reverse('projectroles:create') + link['label'] = f'Create {pr_display}' + ret.append(link) return ret def get_user_links(self, user, app_name=None, url_name=None): diff --git a/projectroles/views.py b/projectroles/views.py index 7bea9e9e..a1ece660 100644 --- a/projectroles/views.py +++ b/projectroles/views.py @@ -58,7 +58,7 @@ get_backend_api, ) from projectroles.remote_projects import RemoteProjectAPI -from projectroles.utils import get_expiry_date, get_display_name +from projectroles.utils import get_display_name app_settings = AppSettingAPI() @@ -2935,8 +2935,7 @@ def get(self, *args, **kwargs): ) ) # Reset invite expiration date - invite.date_expire = get_expiry_date() - invite.save() + invite.reset_date_expire() # Resend mail and add to timeline self.handle_invite(invite=invite, request=self.request, resend=True) return redirect( diff --git a/projectroles/views_ajax.py b/projectroles/views_ajax.py index adbe6c57..5268b393 100644 --- a/projectroles/views_ajax.py +++ b/projectroles/views_ajax.py @@ -484,8 +484,8 @@ def get(self, request, *args, **kwargs): project = self.get_project() app_name = request.GET.get('app_name') # Get the content for the sidebar - app_link_content = AppLinkContent() - sidebar_links = app_link_content.get_project_app_links( + app_links = AppLinkContent() + sidebar_links = app_links.get_project_links( request.user, project, app_name=app_name ) return JsonResponse({'links': sidebar_links}) @@ -515,8 +515,8 @@ class UserDropdownContentAjaxView(SODARBaseAjaxView): def get(self, request, *args, **kwargs): app_name = request.GET.get('app_name') # Get the content for the user dropdown - app_link_content = AppLinkContent() - user_dropdown_links = app_link_content.get_user_links( + app_links = AppLinkContent() + user_dropdown_links = app_links.get_user_links( request.user, app_name=app_name ) return JsonResponse({'links': user_dropdown_links}) diff --git a/requirements/base.txt b/requirements/base.txt index a8db904a..73f714f3 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -8,7 +8,7 @@ setuptools>=70.0.0, <70.1 packaging>=23.2, <24.0 # Django -django>=4.2.15, <5.0 +django>=4.2.16, <5.0 # Configuration django-environ>=0.11.2, <0.12 diff --git a/userprofile/templates/userprofile/detail.html b/userprofile/templates/userprofile/detail.html index 147fb603..7a7cd70f 100644 --- a/userprofile/templates/userprofile/detail.html +++ b/userprofile/templates/userprofile/detail.html @@ -94,14 +94,14 @@

{% for setting in user_settings %} -
+
{{ setting.label }} {% if setting.description %} {% get_info_link setting.description as info_link %} {{ info_link | safe }} {% endif %}
-
+
{% if setting.value == None or setting.value == '' %} N/A {% else %} diff --git a/utility/install_python.sh b/utility/install_python.sh index 2cab6db3..863ad020 100755 --- a/utility/install_python.sh +++ b/utility/install_python.sh @@ -5,5 +5,5 @@ echo "Installing Python 3.11" echo "***********************************************" add-apt-repository -y ppa:deadsnakes/ppa apt-get -y update -apt-get -y install python3.11 python3.11-dev python3.11-venv +apt-get -y install python3.11 python3.11-dev python3.11-venv python3.11-gdbm curl https://bootstrap.pypa.io/get-pip.py | sudo -H python3.11