diff --git a/.github/ISSUE_TEMPLATE/release_cleanup.md b/.github/ISSUE_TEMPLATE/release_cleanup.md index 63cf1b86..7b623c80 100644 --- a/.github/ISSUE_TEMPLATE/release_cleanup.md +++ b/.github/ISSUE_TEMPLATE/release_cleanup.md @@ -20,13 +20,14 @@ TBA - [ ] Review code style and cleanup if needed - [ ] Review and update doc entries if needed - [ ] Ensure all relevant updates are in `CHANGELOG` and major changes doc -- [ ] Ensure new version is in `CORE_API_ALLOWED_VERSIONS` +- [ ] Ensure REST API versions are up to date and documented - [ ] Upgrade version number of pypi package references in `README` and docs - [ ] Upgrade docs config version number (usually at `x.y.z-WIP` when developing) - [ ] Update latest version info in `codemeta.json` - [ ] Update version number and date in `CHANGELOG` - [ ] Update version number and date in `Major Changes` doc - [ ] Ensure docs can be built without errors +- [ ] Ensure `generateschema` runs without errors or warnings (until in CI) ## Notes diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 98f5b549..adddbc6a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,12 +7,12 @@ jobs: strategy: matrix: python-version: - - '3.8' - '3.9' - '3.10' + - '3.11' services: postgres: - image: postgres:11 + image: postgres:16 env: POSTGRES_DB: sodar_core POSTGRES_USER: sodar_core @@ -44,7 +44,7 @@ jobs: run: git fetch --prune --unshallow - name: Install project Python dependencies run: | - pip install "wheel>=0.38.4, <0.39" + pip install "wheel>=0.42.0, <0.43" pip install -r requirements/local.txt pip install -r requirements/test.txt - name: Download icons @@ -58,13 +58,13 @@ jobs: coverage report - name: Check linting run: flake8 . - if: ${{ matrix.python-version == '3.8' }} + if: ${{ matrix.python-version == '3.11' }} - name: Check formatting run: make black arg=--check - if: ${{ matrix.python-version == '3.8' }} + if: ${{ matrix.python-version == '3.11' }} - name: Report coverage with Coveralls uses: coverallsapp/github-action@master with: github-token: ${{ secrets.GITHUB_TOKEN }} path-to-lcov: './coverage.lcov' - if: ${{ matrix.python-version == '3.8' }} + if: ${{ matrix.python-version == '3.11' }} diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 978559aa..50ff3302 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,7 +1,7 @@ -image: python:3.8 +image: python:3.11 services: - - postgres:11 + - postgres:16 variables: POSTGRES_DB: sodar_core diff --git a/.readthedocs.yaml b/.readthedocs.yaml index efde0f22..39db7786 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -8,7 +8,7 @@ version: 2 build: os: ubuntu-20.04 tools: - python: '3.8' + python: '3.11' # Build documentation in the docs/ directory with Sphinx sphinx: diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6e2562aa..10a76842 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,150 @@ Changelog for the **SODAR Core** Django app package. Loosely follows the `Keep a Changelog `_ guidelines. +v1.0.0 (2024-07-19) +=================== + +Added +----- + +- **General** + - Python v3.11 support (#1157) + - Flake8 rule in ``Makefile`` (#1387) + - OpenID Connect (OIDC) authentication support (#1367) +- **Adminalerts** + - Admin alert email sending (#415) + - ``notify_email_alert`` app setting (#415) +- **Filesfolders** + - Optional pagination for REST API list views (#1313) +- **Projectroles** + - ``full_title`` field in ``ProjectSerializer`` and API views (#1314) + - Custom password argument in ``createdevusers`` management command (#1393) + - ``PluginObjectLink`` data class in plugins (#1343) + - ``PluginSearchResult`` data class in plugins (#1399) + - Target user ``sodar_uuid`` updating in remote sync (#1316, #1317) + - Update local user data in remote sync (#1407) + - ``USER`` scope settings in remote sync (#1322) + - ``AppLinkContent`` utility class (#1380, #1381) + - ``checkusers`` management command (#1410) + - ``SODARPageNumberPagination`` pagination class (#1313) + - Optional pagination for REST API list views (#1313) + - Email notification opt-out settings (#1417, #1418) + - CC and BCC field support in sending generic emails (#415) + - ``SODARUserAdditionalEmail`` model (#874) + - ``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 + - ``RemoteProjectAccessAjaxView`` Ajax view (#1358) + - Remote project access status updating in project detail view (#1358) + - ``SidebarContentAjaxView`` for sidebar and project dropdown content retrieval (#1366) + - ``UserDropdownContentAjaxView`` for user dropdown content retrieval (#1366, #1392) + - ``SODARUser.get_auth_type()`` helper (#1367) + - ``ProjectInvite.is_ldap()`` helper (#1367) + - ``AppSettingAPI.is_set()`` helper (#1450) + - ``checks`` module for Django checks (#504) + - Django check for enabled auth methods (#1451) +- **Timeline** + - ``sodar_uuid`` field in ``TimelineEventObjectRef`` model (#1415) + - REST API views (#1350) + - ``get_project()`` helpers in ``TimelineEvent`` and ``TimelineEventObjectRef`` (#1350) + - Optional pagination for REST API list views (#1313) +- **Userprofile** + - Additional email address management and verification (#874) + +Changed +------- + +- **General** + - Upgrade to Django v4.2 (#880) + - Upgrade minimum PostgreSQL version to v12 (#1074) + - Upgrade to PostgreSQL v16 in CI (#1074) + - Upgrade general Python dependencies (#1374) + - Reformat with black v24.3.0 (#1374) + - Update download URL in ``get_chromedriver_url.py`` (#1385) + - Add ``AUTH_LDAP_USER_SEARCH_BASE`` as a Django setting (#1410) + - Change ``ATOMIC_REQUESTS`` recommendation and default to ``True`` (#1281) + - Add OpenAPI dependencies (#1444) + - Squash migrations (#1446) +- **Filesfolders** + - Add migration required by Django v4.2 (#1396) + - Add app specific media type and versioning (#1278) +- **Projectroles** + - Rename ``AppSettingAPI`` ``app_name`` arguments to ``plugin_name`` (#1285) + - Default password in ``createdevusers`` management command (#1390) + - Deprecate ``local`` in app settings, use ``global`` instead (#1319) + - Enforce optional handling of app settings ``global`` attributes (#1395) + - Expect ``get_object_link()`` plugin methods to return ``PluginObjectLink`` (#1343) + - Deprecate returning ``dict`` from ``get_object_link()`` (#1343) + - Expect ``search()`` plugin methods to return list of ``PluginSearchResult`` objects (#1399) + - Deprecate returning ``dict`` from ``search()`` (#1399) + - Update core API view media type and versioning (#1278, #1406) + - Separate projectroles and remote sync API media types and versioning (#1278) + - Rename base test classes for consistency (#1259) + - Prevent setting global user app settings on target site in ``AppSettingAPI`` (#1329) + - Move project app link logic in ``AppLinkContent`` (#1380) + - Move user dropdown link logic in ``AppLinkContent`` (#1381, #1413) + - Do not recreate ``AppSetting`` objects on remote sync update (#1409) + - Enforce project and site uniqueness in ``RemoteProject`` model (#1433) + - Remove redundant permission check in ``project_detail.html`` (#1438) + - Move sidebar, project dropdown and user dropdown creation to ``utils`` (#1366) + - Refactor ``ProjectInviteProcessMixin.get_invite_type()`` into ``ProjectInvite.is_ldap()`` (#1367) +- **Sodarcache** + - Rewrite REST API views (#498, #1389) + - Raise ``update_cache()`` exception for ``synccache`` in debug mode (#1375) +- **Timeline** + - Update ``get_object_link()`` usage for ``PluginObjectLink`` return data (#1343) + - Rename ``ProjectEvent*`` models to ``TimelineEvent*`` (#1414) + - Move event name from separate column into badge (#1370) + - Use constants for event status types (#973) +- **Userprofile** + - Disable global user settings on target site in ``UserSettingsForm`` (#1329) + +Fixed +----- + +- **General** + - ``README.rst`` badge rendering (#1402) +- **Filesfolders** + - OpenAPI ``generateschema`` errors and warnings (#1442) +- **Projectroles** + - ``SODARUser.update_full_name()`` not working with existing name (#1371) + - Legacy public guest access in child category breaks category updating (#1404) + - Incorrect DAL widget highlight colour after upgrade (#1412) + - ``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) + - Revoked remote projects displayed in project detail view (#1432) + - Invalid URLs for remote peer projects in project detail view (#1435) + - Redundant ``Project.get_source_site()`` calls in project detail view (#1436) + - ``RemoteSite.get_access_date()`` invalid date sorting (#1437) + - OpenAPI ``generateschema`` compatibility (#1440, #1442) + - ``ProjectCreateView`` allows ``POST`` with disabled target project creation (#1448) + - Plugin existence not explicitly checked in ``AppSettingAPI.set()`` update query (#1452) + - ``search_advanced.html`` header layout (#1453) +- **Sodarcache** + - REST API set view ``app_name`` incorrectly set (#1405) +- **Timeline** + - OpenAPI ``generateschema`` warnings (#1442) + +Removed +------- + +- **General** + - SAML support (#1368) + - Python v3.8 support (#1382) +- **Projectroles** + - ``PROJECTROLES_HIDE_APP_LINKS`` setting (#1143) + - ``CORE_API_*`` Django settings (#1278) + - Project starring timeline event creation (#1294) + - ``user_email_additional`` app setting (#874) + - ``get_visible_projects()`` template tag (#1432) + - App setting value max length limit (#1443) + - Redundant project permission in ``UserSettingRetrieveAPIView`` (#1449) + + v0.13.4 (2024-02-16) ==================== diff --git a/Makefile b/Makefile index 0c199696..723dd00c 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ define USAGE= @echo -e @echo -e "Usage:" @echo -e "\tmake black [arg=--] -- black formatting" +@echo -e "\tmake flake -- run flake8" @echo -e "\tmake celery -- start celery worker" @echo -e "\tmake serve -- start source server" @echo -e "\tmake serve_target -- start target server" @@ -25,6 +26,11 @@ black: black . -l 80 --skip-string-normalization --exclude ".git|.venv|.tox|build|env|src|docs|migrations|versioneer.py" $(arg) +.PHONY: flake +flake: + flake8 . + + .PHONY: celery celery: celery -A config worker -l info --beat diff --git a/README.rst b/README.rst index ea5ed9dd..6964883d 100644 --- a/README.rst +++ b/README.rst @@ -1,28 +1,30 @@ SODAR Core ^^^^^^^^^^ -.. image:: https://badge.fury.io/py/django-sodar-core.svg +.. |b1| image:: https://badge.fury.io/py/django-sodar-core.svg :target: https://badge.fury.io/py/django-sodar-core -.. image:: https://github.com/bihealth/sodar-core/actions/workflows/build.yml/badge.svg +.. |b2| image:: https://github.com/bihealth/sodar-core/actions/workflows/build.yml/badge.svg :target: https://github.com/bihealth/sodar-core/actions?query=workflow%3ABuild -.. image:: https://coveralls.io/repos/github/bihealth/sodar-core/badge.svg?branch=main +.. |b3| image:: https://coveralls.io/repos/github/bihealth/sodar-core/badge.svg?branch=main :target: https://coveralls.io/github/bihealth/sodar-core?branch=main -.. image:: https://img.shields.io/badge/License-MIT-green.svg +.. |b4| image:: https://img.shields.io/badge/License-MIT-green.svg :target: https://opensource.org/licenses/MIT -.. image:: https://img.shields.io/badge/code%20style-black-000000.svg +.. |b5| image:: https://img.shields.io/badge/code%20style-black-000000.svg :target: https://github.com/ambv/black -.. image:: https://readthedocs.org/projects/sodar-core/badge/?version=latest +.. |b6| image:: https://readthedocs.org/projects/sodar-core/badge/?version=latest :target: https://sodar-core.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status -.. image:: https://zenodo.org/badge/DOI/10.5281/zenodo.4269346.svg +.. |b7| image:: https://zenodo.org/badge/DOI/10.5281/zenodo.4269346.svg :target: https://doi.org/10.5281/zenodo.4269346 +|b1| |b2| |b3| |b4| |b5| |b6| |b7| + SODAR Core is a framework for Django web application development. It was conceived to facilitate the creation of scientific data management and @@ -115,7 +117,7 @@ and breaking changes are possible. .. code-block:: console - pip install django-sodar-core==0.13.4 + pip install django-sodar-core==1.0.0 For installing a development version you can point your dependency to a specific commit ID in GitHub. Note that these versions may not be stable. diff --git a/adminalerts/forms.py b/adminalerts/forms.py index b0e5f637..cf6f341b 100644 --- a/adminalerts/forms.py +++ b/adminalerts/forms.py @@ -9,9 +9,20 @@ from adminalerts.models import AdminAlert +# Local constants +EMAIL_HELP_CREATE = 'Send alert as email to all users on this site' +EMAIL_HELP_UPDATE = 'Send updated alert as email to all users on this site' + + class AdminAlertForm(SODARModelForm): """Form for AdminAlert creation/updating""" + send_email = forms.BooleanField( + initial=True, + label='Send alert as email', + required=False, + ) + class Meta: model = AdminAlert fields = [ @@ -19,6 +30,7 @@ class Meta: 'date_expire', 'active', 'require_auth', + 'send_email', 'description', ] @@ -40,13 +52,17 @@ def __init__(self, current_user=None, *args, **kwargs): # Creation if not self.instance.pk: - self.fields[ - 'date_expire' - ].initial = timezone.now() + timezone.timedelta(days=1) + self.initial['date_expire'] = timezone.now() + timezone.timedelta( + days=1 + ) + self.fields['send_email'].help_text = EMAIL_HELP_CREATE # Updating else: # self.instance.pk # Set description value as raw markdown self.initial['description'] = self.instance.description.raw + self.fields['send_email'].help_text = EMAIL_HELP_UPDATE + # Sending email for update should be false by default + self.initial['send_email'] = False def clean(self): """Custom form validation and cleanup""" diff --git a/adminalerts/migrations/0001_squashed_0006_adminalert_require_auth.py b/adminalerts/migrations/0001_squashed_0006_adminalert_require_auth.py new file mode 100644 index 00000000..f2ee5eda --- /dev/null +++ b/adminalerts/migrations/0001_squashed_0006_adminalert_require_auth.py @@ -0,0 +1,113 @@ +# Generated by Django 4.2.14 on 2024-07-16 09:17 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import markupfield.fields +import uuid + + +class Migration(migrations.Migration): + + replaces = [ + ('adminalerts', '0001_initial'), + ('adminalerts', '0002_adminalert_user'), + ('adminalerts', '0003_auto_20180802_1558'), + ('adminalerts', '0004_rename_uuid'), + ('adminalerts', '0005_update_uuid'), + ('adminalerts', '0006_adminalert_require_auth'), + ] + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AdminAlert', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'message', + models.CharField( + help_text='Alert message to be shown for users', max_length=255 + ), + ), + ( + 'description', + markupfield.fields.MarkupField( + blank=True, + help_text='Full description of alert (optional, will be shown on a separate page)', + null=True, + rendered_field=True, + ), + ), + ( + 'date_created', + models.DateTimeField( + auto_now_add=True, help_text='Alert creation timestamp' + ), + ), + ( + 'date_expire', + models.DateTimeField(help_text='Alert expiration timestamp'), + ), + ( + 'active', + models.BooleanField( + default=True, + help_text='Alert status (for disabling the alert before expiration)', + ), + ), + ( + 'sodar_uuid', + models.UUIDField( + default=uuid.uuid4, + help_text='Adminalerts SODAR UUID', + unique=True, + ), + ), + ( + 'user', + models.ForeignKey( + help_text='Superuser who has set the alert', + on_delete=django.db.models.deletion.CASCADE, + related_name='alerts', + to=settings.AUTH_USER_MODEL, + ), + ), + ('_description_rendered', models.TextField(editable=False, null=True)), + ( + 'description_markup_type', + models.CharField( + choices=[ + ('', '--'), + ('html', 'HTML'), + ('plain', 'Plain'), + ('markdown', 'Markdown'), + ('restructuredtext', 'Restructured Text'), + ], + default='markdown', + editable=False, + max_length=30, + ), + ), + ( + 'require_auth', + models.BooleanField( + default=True, help_text='Require authorization to view alert' + ), + ), + ], + ), + ] diff --git a/adminalerts/models.py b/adminalerts/models.py index 851e829c..1770a99a 100644 --- a/adminalerts/models.py +++ b/adminalerts/models.py @@ -73,9 +73,11 @@ class AdminAlert(models.Model): def __str__(self): return '{}{}'.format( self.message, - ' [ACTIVE]' - if (self.active and self.date_expire > timezone.now()) - else '', + ( + ' [ACTIVE]' + if (self.active and self.date_expire > timezone.now()) + else '' + ), ) def __repr__(self): diff --git a/adminalerts/plugins.py b/adminalerts/plugins.py index 22d941e8..0e48682e 100644 --- a/adminalerts/plugins.py +++ b/adminalerts/plugins.py @@ -4,12 +4,17 @@ from django.utils import timezone # Projectroles dependency +from projectroles.models import SODAR_CONSTANTS from projectroles.plugins import SiteAppPluginPoint from adminalerts.models import AdminAlert from adminalerts.urls import urlpatterns +# SODAR constants +APP_SETTING_SCOPE_USER = SODAR_CONSTANTS['APP_SETTING_SCOPE_USER'] + + class SiteAppPlugin(SiteAppPluginPoint): """Projectroles plugin for registering the app""" @@ -22,6 +27,22 @@ class SiteAppPlugin(SiteAppPluginPoint): #: UI URLs urls = urlpatterns + #: App settings definition + app_settings = { + 'notify_email_alert': { + 'scope': APP_SETTING_SCOPE_USER, + 'type': 'BOOLEAN', + 'default': True, + 'label': 'Receive email for admin alerts', + 'description': ( + 'Receive email for important administrator alerts regarding ' + 'e.g. site downtime.' + ), + 'user_modifiable': True, + 'global': False, + } + } + #: Iconify icon icon = 'mdi:alert' diff --git a/adminalerts/tests/test_permissions.py b/adminalerts/tests/test_permissions.py index 48d73ef6..46d16336 100644 --- a/adminalerts/tests/test_permissions.py +++ b/adminalerts/tests/test_permissions.py @@ -4,12 +4,12 @@ from django.urls import reverse # Projectroles dependency -from projectroles.tests.test_permissions import TestSiteAppPermissionBase +from projectroles.tests.test_permissions import SiteAppPermissionTestBase from adminalerts.tests.test_models import AdminAlertMixin -class AdminalertsPermissionTestBase(AdminAlertMixin, TestSiteAppPermissionBase): +class AdminalertsPermissionTestBase(AdminAlertMixin, SiteAppPermissionTestBase): """Base test class for adminalerts UI view permission tests""" def setUp(self): diff --git a/adminalerts/tests/test_permissions_ajax.py b/adminalerts/tests/test_permissions_ajax.py index 9c2bb9e7..d16e55d2 100644 --- a/adminalerts/tests/test_permissions_ajax.py +++ b/adminalerts/tests/test_permissions_ajax.py @@ -4,13 +4,13 @@ from django.urls import reverse # Projectroles dependency -from projectroles.tests.test_permissions import TestSiteAppPermissionBase +from projectroles.tests.test_permissions import SiteAppPermissionTestBase from adminalerts.tests.test_models import AdminAlertMixin class TestAdminAlertActiveToggleAjaxView( - AdminAlertMixin, TestSiteAppPermissionBase + AdminAlertMixin, SiteAppPermissionTestBase ): """Permission tests for AdminAlertActiveToggleAjaxView""" diff --git a/adminalerts/tests/test_ui.py b/adminalerts/tests/test_ui.py index c97bc8b3..9dce5b9f 100644 --- a/adminalerts/tests/test_ui.py +++ b/adminalerts/tests/test_ui.py @@ -7,12 +7,12 @@ from selenium.webdriver.common.by import By # Projectroles dependency -from projectroles.tests.test_ui import TestUIBase +from projectroles.tests.test_ui import UITestBase from adminalerts.tests.test_models import AdminAlertMixin -class TestAlertUIBase(AdminAlertMixin, TestUIBase): +class AdminAlertUITestBase(AdminAlertMixin, UITestBase): def setUp(self): super().setUp() # Create users @@ -30,7 +30,7 @@ def setUp(self): ) -class TestAlertMessage(TestAlertUIBase): +class TestAlertMessage(AdminAlertUITestBase): """Tests for the admin alert message""" def test_message(self): @@ -78,7 +78,7 @@ def test_message_login_no_auth(self): ) -class TestListView(TestAlertUIBase): +class TestListView(AdminAlertUITestBase): """Tests for the admin alert list view""" def test_list_items(self): diff --git a/adminalerts/tests/test_views.py b/adminalerts/tests/test_views.py index 54490fab..9143ead5 100644 --- a/adminalerts/tests/test_views.py +++ b/adminalerts/tests/test_views.py @@ -1,15 +1,49 @@ """Tests for UI views in the adminalerts app""" + +from django.conf import settings +from django.core import mail from django.urls import reverse from django.utils import timezone from test_plus.test import TestCase +# Projectroles dependency +from projectroles.app_settings import AppSettingAPI +from projectroles.tests.test_models import SODARUserAdditionalEmailMixin + from adminalerts.models import AdminAlert from adminalerts.tests.test_models import AdminAlertMixin +from adminalerts.views import EMAIL_SUBJECT + + +app_settings = AppSettingAPI() -class TestViewsBase(AdminAlertMixin, TestCase): - """Base class for view testing""" +# Local constants +APP_NAME = 'adminalerts' +ALERT_MSG = 'New alert' +ALERT_MSG_UPDATED = 'Updated alert' +ALERT_DESC = 'Description' +ALERT_DESC_UPDATED = 'Updated description' +ALERT_DESC_MARKDOWN = '## Description' +EMAIL_DESC_LEGEND = 'Additional details' +ADD_EMAIL = 'add1@example.com' +ADD_EMAIL2 = 'add2@example.com' + + +class AdminalertsViewTestBase( + AdminAlertMixin, SODARUserAdditionalEmailMixin, TestCase +): + """Base class for adminalerts view testing""" + + def _make_alert(self): + return self.make_alert( + message=ALERT_MSG, + user=self.superuser, + description=ALERT_DESC, + active=True, + require_auth=True, + ) def setUp(self): # Create users @@ -17,27 +51,23 @@ def setUp(self): self.superuser.is_superuser = True self.superuser.is_staff = True self.superuser.save() - self.regular_user = self.make_user('regular_user') + self.user_regular = self.make_user('user_regular') # No user self.anonymous = None - # Create alert - self.alert = self.make_alert( - message='alert', - user=self.superuser, - description='description', - active=True, - require_auth=True, - ) self.expiry_str = ( timezone.now() + timezone.timedelta(days=1) ).strftime('%Y-%m-%d') -class TestAdminAlertListView(TestViewsBase): - """Tests for the alert list view""" +class TestAdminAlertListView(AdminalertsViewTestBase): + """Tests for AdminAlertListView""" - def test_render(self): - """Test rendering of the alert list view""" + def setUp(self): + super().setUp() + self.alert = self._make_alert() + + def test_get(self): + """Test AdminAlertListView GET""" with self.login(self.superuser): response = self.client.get(reverse('adminalerts:list')) self.assertEqual(response.status_code, 200) @@ -45,11 +75,15 @@ def test_render(self): self.assertEqual(response.context['object_list'][0].pk, self.alert.pk) -class TestAdminAlertDetailView(TestViewsBase): - """Tests for the alert detail view""" +class TestAdminAlertDetailView(AdminalertsViewTestBase): + """Tests for AdminAlertDetailView""" + + def setUp(self): + super().setUp() + self.alert = self._make_alert() - def test_render(self): - """Test rendering of the alert detail view""" + def test_get(self): + """Test AdminAlertDetailView GET""" with self.login(self.superuser): response = self.client.get( reverse( @@ -61,146 +95,314 @@ def test_render(self): self.assertEqual(response.context['object'], self.alert) -class TestAdminAlertCreateView(TestViewsBase): - """Tests for the alert creation view""" +class TestAdminAlertCreateView(AdminalertsViewTestBase): + """Tests for AdminAlertCreateView""" - def test_render(self): - """Test rendering of the alert creation view""" + def _get_post_data(self, **kwargs): + ret = { + 'message': ALERT_MSG, + 'description': ALERT_DESC, + 'date_expire': self.expiry_str, + 'active': True, + 'require_auth': True, + 'send_email': True, + } + ret.update(**kwargs) + return ret + + def setUp(self): + super().setUp() + self.url = reverse('adminalerts:create') + + def test_get(self): + """Test AdminAlertCreateView GET""" with self.login(self.superuser): - response = self.client.get(reverse('adminalerts:create')) + response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - def test_create(self): - """Test creating an admin alert""" - self.assertEqual(AdminAlert.objects.all().count(), 1) - post_data = { - 'message': 'new alert', - 'description': 'description', - 'date_expire': self.expiry_str, - 'active': 1, - 'require_auth': 1, - } + def test_post(self): + """Test POST""" + self.assertEqual(AdminAlert.objects.all().count(), 0) + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data() with self.login(self.superuser): - response = self.client.post( - reverse('adminalerts:create'), post_data - ) + response = self.client.post(self.url, data) self.assertEqual(response.status_code, 302) self.assertEqual(response.url, reverse('adminalerts:list')) - self.assertEqual(AdminAlert.objects.all().count(), 2) - - def test_create_expired(self): - """Test creating an admin alert with and old date_expiry (should fail)""" self.assertEqual(AdminAlert.objects.all().count(), 1) - expiry_fail = (timezone.now() + timezone.timedelta(days=-1)).strftime( - '%Y-%m-%d' + self.assertEqual(len(mail.outbox), 1) + self.assertIn( + EMAIL_SUBJECT.format(state='New', message=ALERT_MSG), + mail.outbox[0].subject, ) - post_data = { - 'message': 'new alert', - 'description': 'description', - 'date_expire': expiry_fail, - 'active': 1, - 'require_auth': 1, - } + self.assertEqual( + mail.outbox[0].recipients(), + [settings.EMAIL_SENDER, self.user_regular.email], + ) + self.assertEqual(mail.outbox[0].to, [settings.EMAIL_SENDER]) + self.assertEqual(mail.outbox[0].bcc, [self.user_regular.email]) + self.assertIn(ALERT_MSG, mail.outbox[0].body) + self.assertIn(EMAIL_DESC_LEGEND, mail.outbox[0].body) + self.assertIn(ALERT_DESC, mail.outbox[0].body) + + def test_post_no_description(self): + """Test POST with no description""" + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data(description='') with self.login(self.superuser): - response = self.client.post( - reverse('adminalerts:create'), post_data - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(AdminAlert.objects.all().count(), 1) + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 1) + self.assertIn(ALERT_MSG, mail.outbox[0].body) + self.assertNotIn(EMAIL_DESC_LEGEND, mail.outbox[0].body) + def test_post_markdown_description(self): + """Test POST with markdown description""" + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data(description=ALERT_DESC_MARKDOWN) + with self.login(self.superuser): + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 1) + self.assertIn(ALERT_MSG, mail.outbox[0].body) + self.assertIn(EMAIL_DESC_LEGEND, mail.outbox[0].body) + # Description should be provided in raw format + self.assertIn(ALERT_DESC_MARKDOWN, mail.outbox[0].body) -class TestAdminAlertUpdateView(TestViewsBase): - """Tests for the alert update view""" + def test_post_no_email(self): + """Test POST with no email to be sent""" + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data(send_email=False) + with self.login(self.superuser): + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 0) - def test_render(self): - """Test rendering of the alert update view""" + def test_post_inactive(self): + """Test POST with inactive state""" + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data(active=False) with self.login(self.superuser): - response = self.client.get( - reverse( - 'adminalerts:update', - kwargs={'adminalert': self.alert.sodar_uuid}, - ) - ) + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 0) + + def test_post_multiple_users(self): + """Test POST with multiple users""" + user_new = self.make_user('user_new') + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data() + with self.login(self.superuser): + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual( + mail.outbox[0].recipients(), + [settings.EMAIL_SENDER, user_new.email, self.user_regular.email], + ) + self.assertIn(ALERT_MSG, mail.outbox[0].body) + self.assertIn(EMAIL_DESC_LEGEND, mail.outbox[0].body) + self.assertIn(ALERT_DESC, mail.outbox[0].body) + + def test_post_add_email_regular_user(self): + """Test POST with additional emails on regular user""" + self.make_email(self.user_regular, ADD_EMAIL) + self.make_email(self.user_regular, ADD_EMAIL2) + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data() + with self.login(self.superuser): + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual( + mail.outbox[0].recipients(), + [ + settings.EMAIL_SENDER, + self.user_regular.email, + ADD_EMAIL, + ADD_EMAIL2, + ], + ) + + def test_post_add_email_regular_user_unverified(self): + """Test POST with additional and unverified emails on regular user""" + self.make_email(self.user_regular, ADD_EMAIL) + self.make_email(self.user_regular, ADD_EMAIL2, verified=False) + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data() + with self.login(self.superuser): + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual( + mail.outbox[0].recipients(), + [ + settings.EMAIL_SENDER, + self.user_regular.email, + ADD_EMAIL, + ], + ) + + def test_post_add_email_superuser(self): + """Test POST with additional emails on superuser""" + self.make_email(self.superuser, ADD_EMAIL) + self.make_email(self.superuser, ADD_EMAIL2) + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data() + with self.login(self.superuser): + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 1) + # Superuser additional emails should not be included + self.assertEqual( + mail.outbox[0].recipients(), + [settings.EMAIL_SENDER, self.user_regular.email], + ) + + def test_post_email_disable(self): + """Test POST with email notifications disabled""" + app_settings.set( + APP_NAME, 'notify_email_alert', False, user=self.user_regular + ) + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data() + with self.login(self.superuser): + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 0) + + def test_post_expired(self): + """Test POST with old expiry date (should fail)""" + self.assertEqual(AdminAlert.objects.all().count(), 0) + expire_fail = (timezone.now() + timezone.timedelta(days=-1)).strftime( + '%Y-%m-%d' + ) + data = self._get_post_data(date_expire=expire_fail) + with self.login(self.superuser): + response = self.client.post(self.url, data) self.assertEqual(response.status_code, 200) + self.assertEqual(AdminAlert.objects.all().count(), 0) - def test_update(self): - """Test updating an admin alert""" - self.assertEqual(AdminAlert.objects.all().count(), 1) - post_data = { - 'message': 'updated alert', - 'description': 'updated description', +class TestAdminAlertUpdateView(AdminalertsViewTestBase): + """Tests for AdminAlertUpdateView""" + + def _get_post_data(self, **kwargs): + ret = { + 'message': ALERT_MSG_UPDATED, + 'description': ALERT_DESC_UPDATED, 'date_expire': self.expiry_str, - 'active': '', + 'active': False, + 'require_auth': True, + 'send_email': False, } + ret.update(kwargs) + return ret + + def setUp(self): + super().setUp() + self.alert = self._make_alert() + self.url = reverse( + 'adminalerts:update', + kwargs={'adminalert': self.alert.sodar_uuid}, + ) + + def test_get(self): + """Test AdminAlertUpdateView GET""" with self.login(self.superuser): - response = self.client.post( - reverse( - 'adminalerts:update', - kwargs={'adminalert': self.alert.sodar_uuid}, - ), - post_data, - ) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + def test_post(self): + """Test POST""" + self.assertEqual(AdminAlert.objects.all().count(), 1) + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data() + with self.login(self.superuser): + response = self.client.post(self.url, data) self.assertEqual(response.status_code, 302) self.assertEqual(response.url, reverse('adminalerts:list')) self.assertEqual(AdminAlert.objects.all().count(), 1) self.alert.refresh_from_db() - self.assertEqual(self.alert.message, 'updated alert') - self.assertEqual(self.alert.description.raw, 'updated description') + self.assertEqual(self.alert.message, ALERT_MSG_UPDATED) + self.assertEqual(self.alert.description.raw, ALERT_DESC_UPDATED) self.assertEqual(self.alert.active, False) + self.assertEqual(len(mail.outbox), 0) + + def test_post_email(self): + """Test POST with email update enabled""" + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data(active=True, send_email=True) + with self.login(self.superuser): + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 1) + self.assertIn( + EMAIL_SUBJECT.format(state='Updated', message=ALERT_MSG_UPDATED), + mail.outbox[0].subject, + ) + self.assertEqual( + mail.outbox[0].recipients(), + [settings.EMAIL_SENDER, self.user_regular.email], + ) + + def test_post_email_inactive(self): + """Test POST with email update enabled and inactive alert""" + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data(send_email=True) + with self.login(self.superuser): + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 0) # No email for inactive event - def test_update_user(self): - """Test updating an admin alert with a different user""" + def test_post_email_disable(self): + """Test POST with disabled email notifications""" + app_settings.set( + APP_NAME, 'notify_email_alert', False, user=self.user_regular + ) + self.assertEqual(len(mail.outbox), 0) + data = self._get_post_data(active=True, send_email=True) + with self.login(self.superuser): + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(len(mail.outbox), 0) + + def test_post_user(self): + """Test POST by different user""" superuser2 = self.make_user('superuser2') superuser2.is_superuser = True superuser2.save() - - post_data = { - 'message': 'updated alert', - 'description': 'updated description', - 'date_expire': self.expiry_str, - 'active': '', - } + data = self._get_post_data() with self.login(superuser2): - response = self.client.post( - reverse( - 'adminalerts:update', - kwargs={'adminalert': self.alert.sodar_uuid}, - ), - post_data, - ) - + response = self.client.post(self.url, data) self.assertEqual(response.status_code, 302) self.assertEqual(response.url, reverse('adminalerts:list')) self.alert.refresh_from_db() self.assertEqual(self.alert.user, superuser2) -class TestAdminAlertDeleteView(TestViewsBase): - """Tests for the alert deletion view""" +class TestAdminAlertDeleteView(AdminalertsViewTestBase): + """Tests for AdminAlertDeleteView""" - def test_render(self): - """Test rendering of the alert deletion view""" + def setUp(self): + super().setUp() + self.alert = self._make_alert() + self.url = reverse( + 'adminalerts:delete', + kwargs={'adminalert': self.alert.sodar_uuid}, + ) + + def test_get(self): + """Test AdminAlertDeleteView GET""" with self.login(self.superuser): - response = self.client.get( - reverse( - 'adminalerts:delete', - kwargs={'adminalert': self.alert.sodar_uuid}, - ) - ) + response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - def test_delete(self): - """Test deleting an admin alert""" + def test_post(self): + """Test POST""" self.assertEqual(AdminAlert.objects.all().count(), 1) with self.login(self.superuser): - response = self.client.post( - reverse( - 'adminalerts:delete', - kwargs={'adminalert': self.alert.sodar_uuid}, - ) - ) + response = self.client.post(self.url) self.assertEqual(response.status_code, 302) self.assertEqual(response.url, reverse('adminalerts:list')) self.assertEqual(AdminAlert.objects.all().count(), 0) diff --git a/adminalerts/tests/test_views_ajax.py b/adminalerts/tests/test_views_ajax.py index 575d351d..6e6473df 100644 --- a/adminalerts/tests/test_views_ajax.py +++ b/adminalerts/tests/test_views_ajax.py @@ -4,44 +4,38 @@ from django.urls import reverse -from adminalerts.tests.test_views import TestViewsBase +from adminalerts.tests.test_views import AdminalertsViewTestBase -class TestAdminAlertActiveToggleAjaxView(TestViewsBase): - """Tests for the AdminAlert activation toggling Ajax view""" +class TestAdminAlertActiveToggleAjaxView(AdminalertsViewTestBase): + """Tests for AdminAlertActiveToggleAjaxView""" - def test_deactivate_alert(self): - """Test alert deactivation""" - self.assertTrue(self.alert.active) + def setUp(self): + super().setUp() + self.alert = self._make_alert() + self.url = reverse( + 'adminalerts:ajax_active_toggle', + kwargs={'adminalert': self.alert.sodar_uuid}, + ) + def test_post_deactivate(self): + """Test AdminAlertActiveToggleAjaxView POST to deactivate alert""" + self.assertTrue(self.alert.active) with self.login(self.superuser): - response = self.client.post( - reverse( - 'adminalerts:ajax_active_toggle', - kwargs={'adminalert': self.alert.sodar_uuid}, - ), - ) + response = self.client.post(self.url) self.assertEqual(response.status_code, 200) - data = json.loads(response.content) self.alert.refresh_from_db() self.assertFalse(self.alert.active) self.assertFalse(data['is_active']) - def test_activate_alert(self): - """Test alert activation""" + def test_post_activate(self): + """Test POST to activate alert""" self.alert.active = False self.alert.save() - with self.login(self.superuser): - response = self.client.post( - reverse( - 'adminalerts:ajax_active_toggle', - kwargs={'adminalert': self.alert.sodar_uuid}, - ), - ) + response = self.client.post(self.url) self.assertEqual(response.status_code, 200) - data = json.loads(response.content) self.alert.refresh_from_db() self.assertTrue(self.alert.active) diff --git a/adminalerts/views.py b/adminalerts/views.py index 0b46b8f9..adb12f32 100644 --- a/adminalerts/views.py +++ b/adminalerts/views.py @@ -1,7 +1,10 @@ """UI views for the adminalerts app""" +import logging + from django.conf import settings from django.contrib import messages +from django.contrib.auth import get_user_model from django.shortcuts import redirect from django.urls import reverse from django.views.generic import ( @@ -14,6 +17,8 @@ from django.views.generic.edit import ModelFormMixin # Projectroles dependency +from projectroles.app_settings import AppSettingAPI +from projectroles.email import get_email_user, get_user_addr, send_generic_mail from projectroles.views import ( LoggedInPermissionMixin, HTTPRefererMixin, @@ -25,7 +30,26 @@ from adminalerts.models import AdminAlert +app_settings = AppSettingAPI() +logger = logging.getLogger(__name__) +User = get_user_model() + + +# Local constants +APP_NAME = 'adminalerts' DEFAULT_PAGINATION = 15 +EMAIL_SUBJECT = '{state} admin alert: {message}' +EMAIL_BODY = r''' +An admin alert has been {action}d by {issuer}: + +{message} +'''.lstrip() +EMAIL_BODY_DESCRIPTION = r''' +Additional details: +---------------------------------------- +{description} +---------------------------------------- +''' # Listing/details views -------------------------------------------------------- @@ -63,10 +87,80 @@ class AdminAlertDetailView( class AdminAlertModifyMixin(ModelFormMixin): + """Common modification methods for AdminAlert create/update views""" + + @classmethod + def _get_email_recipients(cls, alert): + """ + Return list of email addresses for alert email recipients, excluding the + alert issuer. + """ + ret = [] + users = User.objects.exclude(sodar_uuid=alert.user.sodar_uuid).order_by( + 'email' + ) + for u in users: + if not app_settings.get(APP_NAME, 'notify_email_alert', user=u): + continue + if not u.email: + logger.warning('No email set for user: {}'.format(u.username)) + continue + user_emails = get_user_addr(u) + ret += [e for e in user_emails if e not in ret] + return ret + + def _send_email(self, alert, action): + """ + Send email alerts to all users except for the alert issuer. + + :param alert: AdminAlert object + :param action: "create" or "update" (string) + """ + subject = EMAIL_SUBJECT.format( + state='New' if action == 'create' else 'Updated', + message=alert.message, + ) + body = EMAIL_BODY.format( + action=action, + issuer=get_email_user(alert.user), + message=alert.message, + ) + if alert.description: + body += EMAIL_BODY_DESCRIPTION.format( + description=alert.description.raw + ) + recipients = self._get_email_recipients(alert) + # NOTE: Recipients go under bcc + # NOTE: If we have no recipients in bcc we cancel sending + if len(recipients) == 0: + return 0 + return send_generic_mail( + subject, + body, + [settings.EMAIL_SENDER], + self.request, + reply_to=None, + bcc=recipients, + ) + def form_valid(self, form): - form.save() form_action = 'update' if self.object else 'create' - messages.success(self.request, 'Alert {}d.'.format(form_action)) + self.object = form.save() + email_count = 0 + email_msg_suffix = '' + if ( + form.cleaned_data['send_email'] + and self.object.active + and settings.PROJECTROLES_SEND_EMAIL + ): + email_count = self._send_email(form.instance, form_action) + if email_count > 0: + email_msg_suffix = ', {} email{} sent'.format( + email_count, 's' if email_count != 1 else '' + ) + messages.success( + self.request, 'Alert {}d{}.'.format(form_action, email_msg_suffix) + ) return redirect(reverse('adminalerts:list')) diff --git a/appalerts/plugins.py b/appalerts/plugins.py index 643fa304..d0c7fb19 100644 --- a/appalerts/plugins.py +++ b/appalerts/plugins.py @@ -1,6 +1,5 @@ """Plugins for the appalerts app""" - # Projectroles dependency from projectroles.plugins import SiteAppPluginPoint, BackendPluginPoint diff --git a/appalerts/tests/test_permissions.py b/appalerts/tests/test_permissions.py index bce409d7..04e408d8 100644 --- a/appalerts/tests/test_permissions.py +++ b/appalerts/tests/test_permissions.py @@ -4,12 +4,12 @@ from django.urls import reverse # Projectroles dependency -from projectroles.tests.test_permissions import TestSiteAppPermissionBase +from projectroles.tests.test_permissions import SiteAppPermissionTestBase from appalerts.tests.test_models import AppAlertMixin -class AppalertsPermissionTestBase(AppAlertMixin, TestSiteAppPermissionBase): +class AppalertsPermissionTestBase(AppAlertMixin, SiteAppPermissionTestBase): """Base test class for appalerts view permission tests""" def setUp(self): diff --git a/appalerts/tests/test_ui.py b/appalerts/tests/test_ui.py index eca42d6a..8f88286f 100644 --- a/appalerts/tests/test_ui.py +++ b/appalerts/tests/test_ui.py @@ -10,13 +10,13 @@ from selenium.webdriver.support.ui import WebDriverWait # Projectroles dependency -from projectroles.tests.test_ui import TestUIBase +from projectroles.tests.test_ui import UITestBase from appalerts.models import AppAlert from appalerts.tests.test_models import AppAlertMixin -class TestAlertUIBase(AppAlertMixin, TestUIBase): +class AlertUITestBase(AppAlertMixin, UITestBase): def setUp(self): super().setUp() # Create users @@ -37,7 +37,7 @@ def setUp(self): ) -class TestListView(TestAlertUIBase): +class TestListView(AlertUITestBase): """Tests for app alert list view""" def _find_alert_element(self, alert): @@ -205,7 +205,7 @@ def test_alert_reload(self): ) -class TestTitlebarBadge(TestAlertUIBase): +class TestTitlebarBadge(AlertUITestBase): """Tests for the site titlebar badge""" def test_render(self): diff --git a/appalerts/tests/test_views.py b/appalerts/tests/test_views.py index f67ae4cf..19bb51b1 100644 --- a/appalerts/tests/test_views.py +++ b/appalerts/tests/test_views.py @@ -7,7 +7,7 @@ from appalerts.tests.test_models import AppAlertMixin -class TestViewsBase(AppAlertMixin, TestCase): +class ViewTestBase(AppAlertMixin, TestCase): """Base class for appalerts view testing""" def setUp(self): @@ -26,7 +26,7 @@ def setUp(self): ) -class TestAppAlertListView(TestViewsBase): +class TestAppAlertListView(ViewTestBase): """Tests for the alert list view""" def test_render_superuser(self): @@ -54,7 +54,7 @@ def test_render_no_alert_user(self): self.assertEqual(response.context['object_list'].count(), 0) -class TestAppAlertRedirectView(TestViewsBase): +class TestAppAlertRedirectView(ViewTestBase): """Tests for the alert redirect view""" list_url = reverse('appalerts:list') @@ -102,7 +102,7 @@ def test_redirect_no_alert_user(self): self.assertEqual(self.alert.active, True) -class TestAppAlertStatusAjaxView(TestViewsBase): +class TestAppAlertStatusAjaxView(ViewTestBase): """Tests for the alert status ajax view""" def test_get_user_with_alerts(self): @@ -120,7 +120,7 @@ def test_get_user_no_alerts(self): self.assertEqual(response.data['alerts'], 0) -class TestAppAlertDismissAjaxView(TestViewsBase): +class TestAppAlertDismissAjaxView(ViewTestBase): """Tests for the alert dismissal ajax view""" def test_post_superuser(self): diff --git a/bgjobs/migrations/0001_squashed_0006_auto_20200526_1657.py b/bgjobs/migrations/0001_squashed_0006_auto_20200526_1657.py new file mode 100644 index 00000000..5f72aaa5 --- /dev/null +++ b/bgjobs/migrations/0001_squashed_0006_auto_20200526_1657.py @@ -0,0 +1,148 @@ +# Generated by Django 4.2.14 on 2024-07-16 09:22 + +from django.conf import settings +from django.db import migrations, models +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + replaces = [ + ('bgjobs', '0001_initial'), + ('bgjobs', '0002_backgroundjoblogentry'), + ('bgjobs', '0003_backgroundjob_date_created'), + ('bgjobs', '0004_backgroundjob_user'), + ('bgjobs', '0005_auto_20190128_1210'), + ('bgjobs', '0006_auto_20200526_1657'), + ] + + initial = True + + dependencies = [ + ('projectroles', '0005_update_uuid'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BackgroundJob', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'date_modified', + models.DateTimeField( + auto_now=True, help_text='DateTime of last modification' + ), + ), + ( + 'sodar_uuid', + models.UUIDField( + default=uuid.uuid4, help_text='BG Job SODAR UUID', unique=True + ), + ), + ( + 'job_type', + models.CharField(help_text='Type of the job', max_length=512), + ), + ('name', models.CharField(max_length=512)), + ('description', models.TextField()), + ( + 'status', + models.CharField( + choices=[ + ('initial', 'initial'), + ('running', 'running'), + ('done', 'done'), + ('failed', 'failed'), + ], + default='initial', + max_length=50, + ), + ), + ( + 'project', + models.ForeignKey( + help_text='Project in which this objects belongs', + null=True, + on_delete=django.db.models.deletion.CASCADE, + to='projectroles.project', + ), + ), + ( + '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, + ), + ), + ], + options={ + 'ordering': ['-date_created'], + }, + ), + migrations.CreateModel( + name='BackgroundJobLogEntry', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'date_created', + models.DateTimeField( + auto_now_add=True, help_text='DateTime of creation' + ), + ), + ( + 'level', + models.CharField( + choices=[ + ('debug', 'debug'), + ('info', 'info'), + ('warning', 'warning'), + ('error', 'error'), + ], + help_text='Level of log entry', + max_length=50, + ), + ), + ('message', models.TextField(help_text='Log level\'s message')), + ( + 'job', + models.ForeignKey( + help_text='Owning background job', + on_delete=django.db.models.deletion.CASCADE, + related_name='log_entries', + to='bgjobs.backgroundjob', + ), + ), + ], + options={ + 'ordering': ['date_created'], + }, + ), + ] diff --git a/bgjobs/views.py b/bgjobs/views.py index f1378f16..92021aed 100644 --- a/bgjobs/views.py +++ b/bgjobs/views.py @@ -100,7 +100,7 @@ def post(self, _request, **_kwargs): description='Clearing {} background jobs'.format( 'user-owned' if self.which_jobs != 'all' else 'all' ), - status_type='OK', + status_type=timeline.TL_STATUS_OK, ) messages.success( self.request, 'Removed {} background jobs.'.format(bg_job_count) diff --git a/codemeta.json b/codemeta.json index ee04b1c7..6a15681c 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-02-16", + "dateCreated": "2024-07-19", "description": "SODAR Core: A Django-based framework for scientific data management and analysis web apps", "keywords": "Python, Django, scientific data managmenent, software library", "license": "MIT", "title": "SODAR Core", - "version": "v0.13.4" + "version": "v1.0.0" } diff --git a/config/settings/base.py b/config/settings/base.py index bef23cfd..16118f19 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -2,12 +2,14 @@ Django settings for the SODAR Core Example Site project. For more information on this file, see -https://docs.djangoproject.com/en/dev/topics/settings/ +https://docs.djangoproject.com/en/4.2/topics/settings/ For the full list of settings and their values, see -https://docs.djangoproject.com/en/3.2/ref/settings/ +https://docs.djangoproject.com/en/4.2/ref/settings/ """ + import environ +import itertools import os from projectroles.constants import get_sodar_constants @@ -22,7 +24,7 @@ env = environ.Env() # .env file, should load only in development environment -READ_DOT_ENV_FILE = env.bool('DJANGO_READ_DOT_ENV_FILE', default=False) +READ_DOT_ENV_FILE = env.bool('DJANGO_READ_DOT_ENV_FILE', False) if READ_DOT_ENV_FILE: # Operating System Environment variables have precedence over variables @@ -60,12 +62,12 @@ 'markupfield', # For markdown 'rest_framework', # For API views 'knox', # For token auth + 'social_django', # For OIDC authentication 'docs', # For the online user documentation/manual 'db_file_storage', # For filesfolders 'dal', # For user search combo box 'dal_select2', 'dj_iconify.apps.DjIconifyConfig', # Iconify for SVG icons - 'django_saml2_auth', # SAML2 support ] # Project apps @@ -143,18 +145,16 @@ for x in env.list('ADMINS', default=['Admin User:admin@example.com']) ] -# See: https://docs.djangoproject.com/en/3.2/ref/settings/#managers +# See: https://docs.djangoproject.com/en/4.2/ref/settings/#managers MANAGERS = ADMINS # DATABASE CONFIGURATION # ------------------------------------------------------------------------------ -# See: https://docs.djangoproject.com/en/3.2/ref/settings/#databases +# See: https://docs.djangoproject.com/en/4.2/ref/settings/#databases # Uses django-environ to accept uri format # See: https://django-environ.readthedocs.io/en/latest/#supported-types -DATABASES = { - 'default': env.db('DATABASE_URL', default='postgres:///sodar_core') -} -DATABASES['default']['ATOMIC_REQUESTS'] = False +DATABASES = {'default': env.db('DATABASE_URL', 'postgres:///sodar_core')} +DATABASES['default']['ATOMIC_REQUESTS'] = True # Set default auto field (for Django 3.2+) DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' @@ -171,24 +171,24 @@ # In a Windows environment this must be set to your system time zone. TIME_ZONE = 'Europe/Berlin' -# See: https://docs.djangoproject.com/en/3.2/ref/settings/#language-code +# See: https://docs.djangoproject.com/en/4.2/ref/settings/#language-code LANGUAGE_CODE = 'en-us' -# See: https://docs.djangoproject.com/en/3.2/ref/settings/#site-id +# See: https://docs.djangoproject.com/en/4.2/ref/settings/#site-id SITE_ID = 1 -# See: https://docs.djangoproject.com/en/3.2/ref/settings/#use-i18n +# See: https://docs.djangoproject.com/en/4.2/ref/settings/#use-i18n USE_I18N = False -# See: https://docs.djangoproject.com/en/3.2/ref/settings/#use-l10n +# See: https://docs.djangoproject.com/en/4.2/ref/settings/#use-l10n USE_L10N = True -# See: https://docs.djangoproject.com/en/3.2/ref/settings/#use-tz +# See: https://docs.djangoproject.com/en/4.2/ref/settings/#use-tz USE_TZ = True # TEMPLATE CONFIGURATION # ------------------------------------------------------------------------------ -# See: https://docs.djangoproject.com/en/3.2/ref/settings/#templates +# See: https://docs.djangoproject.com/en/4.2/ref/settings/#templates TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', @@ -316,15 +316,24 @@ CELERYD_TASK_SOFT_TIME_LIMIT = 60 -# Django REST framework default auth classes +# Django REST framework +# ------------------------------------------------------------------------------ + REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework.authentication.BasicAuthentication', 'rest_framework.authentication.SessionAuthentication', 'knox.auth.TokenAuthentication', - ) + ), + 'DEFAULT_PAGINATION_CLASS': ( + 'rest_framework.pagination.PageNumberPagination' + ), + 'PAGE_SIZE': env.int('SODAR_API_PAGE_SIZE', 100), } +# Additional authentication settings +# ------------------------------------------------------------------------------ + # Knox settings TOKEN_TTL = None @@ -344,7 +353,6 @@ LDAP_ALT_DOMAINS = env.list('LDAP_ALT_DOMAINS', None, default=[]) if ENABLE_LDAP: - import itertools import ldap from django_auth_ldap.config import LDAPSearch @@ -366,15 +374,16 @@ AUTH_LDAP_CA_CERT_FILE = env.str('AUTH_LDAP_CA_CERT_FILE', None) AUTH_LDAP_CONNECTION_OPTIONS = {**LDAP_DEFAULT_CONN_OPTIONS} if AUTH_LDAP_CA_CERT_FILE: - AUTH_LDAP_CONNECTION_OPTIONS[ - ldap.OPT_X_TLS_CACERTFILE - ] = AUTH_LDAP_CA_CERT_FILE + AUTH_LDAP_CONNECTION_OPTIONS[ldap.OPT_X_TLS_CACERTFILE] = ( + AUTH_LDAP_CA_CERT_FILE + ) AUTH_LDAP_CONNECTION_OPTIONS[ldap.OPT_X_TLS_NEWCTX] = 0 AUTH_LDAP_USER_FILTER = env.str( 'AUTH_LDAP_USER_FILTER', '(sAMAccountName=%(user)s)' ) + AUTH_LDAP_USER_SEARCH_BASE = env.str('AUTH_LDAP_USER_SEARCH_BASE', None) AUTH_LDAP_USER_SEARCH = LDAPSearch( - env.str('AUTH_LDAP_USER_SEARCH_BASE', None), + AUTH_LDAP_USER_SEARCH_BASE, ldap.SCOPE_SUBTREE, AUTH_LDAP_USER_FILTER, ) @@ -399,15 +408,18 @@ AUTH_LDAP2_CA_CERT_FILE = env.str('AUTH_LDAP2_CA_CERT_FILE', None) AUTH_LDAP2_CONNECTION_OPTIONS = {**LDAP_DEFAULT_CONN_OPTIONS} if AUTH_LDAP2_CA_CERT_FILE: - AUTH_LDAP2_CONNECTION_OPTIONS[ - ldap.OPT_X_TLS_CACERTFILE - ] = AUTH_LDAP2_CA_CERT_FILE + AUTH_LDAP2_CONNECTION_OPTIONS[ldap.OPT_X_TLS_CACERTFILE] = ( + AUTH_LDAP2_CA_CERT_FILE + ) AUTH_LDAP2_CONNECTION_OPTIONS[ldap.OPT_X_TLS_NEWCTX] = 0 AUTH_LDAP2_USER_FILTER = env.str( 'AUTH_LDAP2_USER_FILTER', '(sAMAccountName=%(user)s)' ) + AUTH_LDAP2_USER_SEARCH_BASE = env.str( + 'AUTH_LDAP2_USER_SEARCH_BASE', None + ) AUTH_LDAP2_USER_SEARCH = LDAPSearch( - env.str('AUTH_LDAP2_USER_SEARCH_BASE', None), + AUTH_LDAP2_USER_SEARCH_BASE, ldap.SCOPE_SUBTREE, AUTH_LDAP2_USER_FILTER, ) @@ -424,80 +436,40 @@ ) -# SAML configuration +# OpenID Connect (OIDC) configuration # ------------------------------------------------------------------------------ +ENABLE_OIDC = env.bool('ENABLE_OIDC', False) -ENABLE_SAML = env.bool('ENABLE_SAML', False) -SAML2_AUTH = { - # Required setting - # Pysaml2 Saml client settings - # See: https://pysaml2.readthedocs.io/en/latest/howto/config.html - 'SAML_CLIENT_SETTINGS': { - # Optional entity ID string to be passed in the 'Issuer' element of - # authn request, if required by the IDP. - 'entityid': env.str('SAML_CLIENT_ENTITY_ID', 'SODARcore'), - 'entitybaseurl': env.str( - 'SAML_CLIENT_ENTITY_URL', 'https://localhost:8000' - ), - # The auto(dynamic) metadata configuration URL of SAML2 - 'metadata': { - 'local': [ - env.str('SAML_CLIENT_METADATA_FILE', 'metadata.xml'), - ], - }, - 'service': { - 'sp': { - 'idp': env.str( - 'SAML_CLIENT_IPD', - 'https://sso.hpc.bihealth.org/auth/realms/cubi', - ), - # Keycloak expects client signature - 'authn_requests_signed': 'true', - # Enforce POST binding which is required by keycloak - 'binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', - }, - }, - 'key_file': env.str('SAML_CLIENT_KEY_FILE', 'key.pem'), - 'cert_file': env.str('SAML_CLIENT_CERT_FILE', 'cert.pem'), - 'xmlsec_binary': env.str('SAML_CLIENT_XMLSEC1', '/usr/bin/xmlsec1'), - 'encryption_keypairs': [ - { - 'key_file': env.str('SAML_CLIENT_KEY_FILE', 'key.pem'), - 'cert_file': env.str('SAML_CLIENT_CERT_FILE', 'cert.pem'), - } - ], - }, - # Custom target redirect URL after the user get logged in. - # Defaults to /admin if not set. This setting will be overwritten if you - # have parameter ?next= specificed in the login URL. - 'DEFAULT_NEXT_URL': '/', - # # Optional settings below - # 'NEW_USER_PROFILE': { - # 'USER_GROUPS': [], # The default group name when a new user logs in - # 'ACTIVE_STATUS': True, # The default active status for new users - # 'STAFF_STATUS': True, # The staff status for new users - # 'SUPERUSER_STATUS': False, # The superuser status for new users - # }, - # 'ATTRIBUTES_MAP': env.dict( - # 'SAML_ATTRIBUTES_MAP', - # default={ - # Change values to corresponding SAML2 userprofile attributes. - # 'email': 'Email', - # 'username': 'UserName', - # 'first_name': 'FirstName', - # 'last_name': 'LastName', - # } - # ), - # 'TRIGGER': { - # 'FIND_USER': 'path.to.your.find.user.hook.method', - # 'NEW_USER': 'path.to.your.new.user.hook.method', - # 'CREATE_USER': 'path.to.your.create.user.hook.method', - # 'BEFORE_LOGIN': 'path.to.your.login.hook.method', - # }, - # Custom URL to validate incoming SAML requests against - # 'ASSERTION_URL': 'https://your.url.here', -} +if ENABLE_OIDC: + AUTHENTICATION_BACKENDS = tuple( + itertools.chain( + ('social_core.backends.open_id_connect.OpenIdConnectAuth',), + AUTHENTICATION_BACKENDS, + ) + ) + TEMPLATES[0]['OPTIONS']['context_processors'] += [ + 'social_django.context_processors.backends', + 'social_django.context_processors.login_redirect', + ] + SOCIAL_AUTH_JSONFIELD_ENABLED = True + SOCIAL_AUTH_JSONFIELD_CUSTOM = 'django.db.models.JSONField' + SOCIAL_AUTH_USER_MODEL = AUTH_USER_MODEL + SOCIAL_AUTH_ADMIN_USER_SEARCH_FIELDS = [ + 'username', + 'name', + 'first_name', + 'last_name', + 'email', + ] + SOCIAL_AUTH_OIDC_OIDC_ENDPOINT = env.str( + 'SOCIAL_AUTH_OIDC_OIDC_ENDPOINT', None + ) + SOCIAL_AUTH_OIDC_KEY = env.str('SOCIAL_AUTH_OIDC_KEY', 'CHANGEME') + SOCIAL_AUTH_OIDC_SECRET = env.str('SOCIAL_AUTH_OIDC_SECRET', 'CHANGEME') + SOCIAL_AUTH_OIDC_USERNAME_KEY = env.str( + 'SOCIAL_AUTH_OIDC_USERNAME_KEY', 'username' + ) # Logging @@ -584,9 +556,11 @@ def set_logging(level=None): ) # SODAR API settings +# DEPRECATED: To be removed in SODAR Core v1.1 (see #1401) SODAR_API_DEFAULT_VERSION = '0.1' SODAR_API_ALLOWED_VERSIONS = [SODAR_API_DEFAULT_VERSION] SODAR_API_MEDIA_TYPE = 'application/your.application+json' +# SODAR API host URL SODAR_API_DEFAULT_HOST = env.url( 'SODAR_API_DEFAULT_HOST', 'http://0.0.0.0:8000' ) @@ -641,10 +615,10 @@ def set_logging(level=None): 'PROJECTROLES_SEARCH_OMIT_APPS', None, [] ) PROJECTROLES_TARGET_SYNC_ENABLE = env.bool( - 'PROJECTROLES_TARGET_SYNC_ENABLE', default=False + 'PROJECTROLES_TARGET_SYNC_ENABLE', False ) PROJECTROLES_TARGET_SYNC_INTERVAL = env.int( - 'PROJECTROLES_TARGET_SYNC_INTERVAL', default=5 + 'PROJECTROLES_TARGET_SYNC_INTERVAL', 5 ) # Optional projectroles settings @@ -662,8 +636,6 @@ def set_logging(level=None): PROJECTROLES_HIDE_PROJECT_APPS = env.list( 'PROJECTROLES_HIDE_PROJECT_APPS', None, [] ) -# NOTE: This setting has been deprecated and will be removed in v0.14 -PROJECTROLES_HIDE_APP_LINKS = env.list('PROJECTROLES_HIDE_APP_LINKS', None, []) # Set limit for delegate roles per project (if 0, no limit is applied) PROJECTROLES_DELEGATE_LIMIT = env.int('PROJECTROLES_DELEGATE_LIMIT', 1) diff --git a/config/settings/local.py b/config/settings/local.py index 6e751a1c..57423f09 100644 --- a/config/settings/local.py +++ b/config/settings/local.py @@ -21,7 +21,7 @@ # DEBUG # ------------------------------------------------------------------------------ -DEBUG = env.bool('DJANGO_DEBUG', default=True) +DEBUG = env.bool('DJANGO_DEBUG', True) TEMPLATES[0]['OPTIONS']['debug'] = DEBUG # SECRET CONFIGURATION @@ -49,7 +49,7 @@ # django-debug-toolbar # ------------------------------------------------------------------------------ -ENABLE_DEBUG_TOOLBAR = env.bool('ENABLE_DEBUG_TOOLBAR', default=True) +ENABLE_DEBUG_TOOLBAR = env.bool('ENABLE_DEBUG_TOOLBAR', True) if ENABLE_DEBUG_TOOLBAR: MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware'] diff --git a/config/settings/local_target.py b/config/settings/local_target.py index dfec48f1..78baa1f2 100644 --- a/config/settings/local_target.py +++ b/config/settings/local_target.py @@ -5,7 +5,7 @@ # DATABASE CONFIGURATION # ------------------------------------------------------------------------------ -# See: https://docs.djangoproject.com/en/3.2/ref/settings/#databases +# See: https://docs.djangoproject.com/en/4.2/ref/settings/#databases # Uses django-environ to accept uri format # See: https://django-environ.readthedocs.io/en/latest/#supported-types DATABASES['default']['NAME'] = 'sodar_core_target' diff --git a/config/settings/local_target2.py b/config/settings/local_target2.py index 95a86cac..09272985 100644 --- a/config/settings/local_target2.py +++ b/config/settings/local_target2.py @@ -5,7 +5,7 @@ # DATABASE CONFIGURATION # ------------------------------------------------------------------------------ -# See: https://docs.djangoproject.com/en/3.2/ref/settings/#databases +# See: https://docs.djangoproject.com/en/4.2/ref/settings/#databases # Uses django-environ to accept uri format # See: https://django-environ.readthedocs.io/en/latest/#supported-types DATABASES['default']['NAME'] = 'sodar_core_target2' diff --git a/config/settings/test.py b/config/settings/test.py index 26a56b74..99bd6a76 100644 --- a/config/settings/test.py +++ b/config/settings/test.py @@ -23,6 +23,11 @@ ADMINS = [('Admin User', 'admin@example.com')] MANAGERS = ADMINS +# DATABASE CONFIGURATION +# ------------------------------------------------------------------------------ +# Set False to support parallel testing, see issue #1428 +DATABASES['default']['ATOMIC_REQUESTS'] = False + # Mail settings # ------------------------------------------------------------------------------ EMAIL_HOST = 'localhost' @@ -31,6 +36,7 @@ # In-memory email backend stores messages in django.core.mail.outbox # for unit testing purposes EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend' +EMAIL_SENDER = 'noreply@example.com' # CACHING # ------------------------------------------------------------------------------ @@ -64,6 +70,25 @@ ] ] +# Django REST framework +# ------------------------------------------------------------------------------ + +# Set pagination page size to 1 for easy testing +REST_FRAMEWORK['PAGE_SIZE'] = 1 + + +# LDAP configuration +# ------------------------------------------------------------------------------ + +ENABLE_LDAP = False + + +# OpenID Connect (OIDC) configuration +# ------------------------------------------------------------------------------ + +ENABLE_OIDC = False + + # Logging # ------------------------------------------------------------------------------ diff --git a/config/urls.py b/config/urls.py index 6a66b18a..4acec08f 100644 --- a/config/urls.py +++ b/config/urls.py @@ -7,7 +7,6 @@ from django.urls import path from django.views import defaults as default_views -import django_saml2_auth.views # Projectroles dependency from projectroles.views import HomeView @@ -28,6 +27,8 @@ path('api/auth/', include('knox.urls')), # Iconify SVG icons path('icons/', include('dj_iconify.urls')), + # Social auth for OIDC support + path('social/', include('social_django.urls')), # Projectroles URLs path('project/', include('projectroles.urls')), # Admin Alerts URLs @@ -54,24 +55,8 @@ path('examples/project/', include('example_project_app.urls')), # Example site app URLs path('examples/site/', include('example_site_app.urls')), - # These are the SAML2 related URLs. You can change "^saml2_auth/" regex to - # any path you want, like "^sso_auth/", "^sso_login/", etc. (required) - path('saml2_auth/', include('django_saml2_auth.urls')), - # The following line will replace the default user login with SAML2 (optional) - # If you want to specific the after-login-redirect-URL, use parameter "?next=/the/path/you/want" - # with this view. - path('sso/login/', django_saml2_auth.views.signin), - # The following line will replace the admin login with SAML2 (optional) - # If you want to specific the after-login-redirect-URL, use parameter "?next=/the/path/you/want" - # with this view. - path('sso/admin/login/', django_saml2_auth.views.signin), - # The following line will replace the default user logout with the signout page (optional) - path('sso/logout/', django_saml2_auth.views.signout), - # The following line will replace the default admin user logout with the signout page (optional) - path('sso/admin/logout/', django_saml2_auth.views.signout), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) - if settings.DEBUG: # This allows the error pages to be debugged during development, just visit # these url in browser to see how these error pages look like. diff --git a/config/wsgi.py b/config/wsgi.py index 42ae5374..d6df6b04 100644 --- a/config/wsgi.py +++ b/config/wsgi.py @@ -13,6 +13,7 @@ framework. """ + import os import sys diff --git a/docs/source/_static/app_adminalerts/alert_form.png b/docs/source/_static/app_adminalerts/alert_form.png index 6a390ddb..431baeea 100644 Binary files a/docs/source/_static/app_adminalerts/alert_form.png and b/docs/source/_static/app_adminalerts/alert_form.png differ diff --git a/docs/source/_static/app_projectroles/sodar_login.png b/docs/source/_static/app_projectroles/sodar_login.png index 4bfcb197..620e1c01 100644 Binary files a/docs/source/_static/app_projectroles/sodar_login.png and b/docs/source/_static/app_projectroles/sodar_login.png differ diff --git a/docs/source/_static/app_projectroles/sodar_remote_project_links.png b/docs/source/_static/app_projectroles/sodar_remote_project_links.png new file mode 100644 index 00000000..6bd6326b Binary files /dev/null and b/docs/source/_static/app_projectroles/sodar_remote_project_links.png differ 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/_static/app_timeline/sodar_timeline.png b/docs/source/_static/app_timeline/sodar_timeline.png index de212143..830f0ce2 100644 Binary files a/docs/source/_static/app_timeline/sodar_timeline.png and b/docs/source/_static/app_timeline/sodar_timeline.png differ diff --git a/docs/source/_static/saml/keycloak_client_config.png b/docs/source/_static/saml/keycloak_client_config.png deleted file mode 100644 index e6babb28..00000000 Binary files a/docs/source/_static/saml/keycloak_client_config.png and /dev/null differ diff --git a/docs/source/_static/saml/keycloak_metadata_download.png b/docs/source/_static/saml/keycloak_metadata_download.png deleted file mode 100644 index e4958516..00000000 Binary files a/docs/source/_static/saml/keycloak_metadata_download.png and /dev/null differ diff --git a/docs/source/_static/saml/keycloak_saml_key_download1.png b/docs/source/_static/saml/keycloak_saml_key_download1.png deleted file mode 100644 index 93815076..00000000 Binary files a/docs/source/_static/saml/keycloak_saml_key_download1.png and /dev/null differ diff --git a/docs/source/_static/saml/keycloak_saml_key_download2.png b/docs/source/_static/saml/keycloak_saml_key_download2.png deleted file mode 100644 index 1a3be912..00000000 Binary files a/docs/source/_static/saml/keycloak_saml_key_download2.png and /dev/null differ diff --git a/docs/source/app_adminalerts.rst b/docs/source/app_adminalerts.rst index a74907b9..8914f65c 100644 --- a/docs/source/app_adminalerts.rst +++ b/docs/source/app_adminalerts.rst @@ -120,6 +120,11 @@ Require Auth If set true, this alert will only be shown to users logged in to the site. If false, it will also appear in the login screen as well as for anonymous users if allowed on the site. +Send Alert as Email + Send alert as email to all users with email notifications enabled, with the + exception of the sender. This is enabled by default when creating an alert. + When updating an existing alert it is initially disabled to avoid redundant + emails when e.g. fixing a typo. Description A longer description, which can be accessed through the :guilabel:`Details` link in the alert element. Markdown syntax is supported. diff --git a/docs/source/app_filesfolders_api_rest.rst b/docs/source/app_filesfolders_api_rest.rst index 6ba832fa..fef852cb 100644 --- a/docs/source/app_filesfolders_api_rest.rst +++ b/docs/source/app_filesfolders_api_rest.rst @@ -11,6 +11,23 @@ addition to the GUI. For general information on REST API usage in SODAR Core, see :ref:`app_projectroles_api_rest`. + +Filesfolders REST API Versioning +================================ + +Media Type + ``application/vnd.bihealth.sodar-core.filesfolders+json`` +Current Version + ``1.0`` +Accepted Versions + ``1.0`` +Header Example + ``Accept: application/vnd.bihealth.sodar-core.filesfolders+json; version=x.y`` + + +Filesfolders REST API Views +=========================== + .. currentmodule:: filesfolders.views_api .. autoclass:: FolderListCreateAPIView diff --git a/docs/source/app_projectroles_api_django.rst b/docs/source/app_projectroles_api_django.rst index d16ef3d7..9385c1e5 100644 --- a/docs/source/app_projectroles_api_django.rst +++ b/docs/source/app_projectroles_api_django.rst @@ -106,6 +106,18 @@ General utility functions are stored in ``utils.py``. :members: +Common Use Ajax Views +===================== + +Ajax views intended to be used in a SODAR Core based site are described here. + +.. currentmodule:: projectroles.views_ajax + +.. autoclass:: SidebarContentAjaxView + +.. autoclass:: UserDropdownContentAjaxView + + .. _app_projectroles_api_django_rest: Base REST API View Classes @@ -152,6 +164,9 @@ Base API View Mixins .. autoclass:: ProjectQuerysetMixin :members: +.. autoclass:: SODARPageNumberPagination + :members: + .. _app_projectroles_api_django_ajax: diff --git a/docs/source/app_projectroles_api_rest.rst b/docs/source/app_projectroles_api_rest.rst index 36aef4f1..43c19a44 100644 --- a/docs/source/app_projectroles_api_rest.rst +++ b/docs/source/app_projectroles_api_rest.rst @@ -12,9 +12,10 @@ HTTP API calls in addition to the GUI. API Usage ========= -Usage of the REST API is detailed in this section. These instructions also apply -to REST APIs in any other application within SODAR Core and are recommended -as guidelines for API development in your SODAR Core based Django site. +General information on usage of the REST APIs in SODAR Core is detailed in this +section. These instructions also apply to REST APIs in any other application +within SODAR Core. They are recommended as guidelines for API development in +your SODAR Core based Django site. Authentication -------------- @@ -38,21 +39,14 @@ desired API version in your HTTP requests is optional, it is **strongly recommended**. This ensures you will get the appropriate return data and avoid running into unexpected incompatibility issues. -To enable versioning, add the ``Accept`` header to your request with the -following media type and version syntax. Replace the version number with your -expected version. +From SODAR Core v1.0 onwards, each application is expected to use its own media +type and version numbering. To enable versioning, add the ``Accept`` header to +your request with the app's respective media type and version number. Example +for the projectroles API: .. code-block:: console - Accept: application/vnd.bihealth.sodar-core+json; version=0.13.2 - -.. note:: - - The media type and version for internal SODAR Core apps are by design - intended to be different to applications implemented in your Django site. - Only use the aforementioned values when calling REST API views in - projectroles or other applications installed from the django-sodar-core - package. + Accept: application/vnd.bihealth.sodar-core.projectroles+json; version=x.y Model Access and Permissions ---------------------------- @@ -108,8 +102,21 @@ For creation views, the ``sodar_uuid`` of the created object is returned along with other object fields. -API Views -========= +Projectroles REST API Versioning +================================ + +Media Type + ``application/vnd.bihealth.sodar-core.projectroles+json`` +Current Version + ``1.0`` +Accepted Versions + ``1.0`` +Header Example + ``Accept: application/vnd.bihealth.sodar-core.projectroles+json; version=x.y`` + + +Projectroles REST API Views +=========================== .. currentmodule:: projectroles.views_api diff --git a/docs/source/app_projectroles_basics.rst b/docs/source/app_projectroles_basics.rst index 8769225f..c831d42b 100644 --- a/docs/source/app_projectroles_basics.rst +++ b/docs/source/app_projectroles_basics.rst @@ -121,7 +121,7 @@ Among the data which can be synchronized: - General project information such as title, description and readme - Project category structure - User roles in projects -- User accounts for LDAP/AD users (required for the previous step) +- User accounts for LDAP/AD and OIDC users (required for the previous step) - Information of other Target Sites linking a common project Target sites read remote project information from the source site. When diff --git a/docs/source/app_projectroles_integration.rst b/docs/source/app_projectroles_integration.rst index e47c7eb2..eb835d58 100644 --- a/docs/source/app_projectroles_integration.rst +++ b/docs/source/app_projectroles_integration.rst @@ -85,7 +85,7 @@ release tag or commit ID. .. code-block:: console - django-sodar-core==0.13.4 + django-sodar-core==1.0.0 Install the requirements for development: diff --git a/docs/source/app_projectroles_settings.rst b/docs/source/app_projectroles_settings.rst index 27341534..b7d563fe 100644 --- a/docs/source/app_projectroles_settings.rst +++ b/docs/source/app_projectroles_settings.rst @@ -69,16 +69,14 @@ following apps need to be included in the list in order for SODAR Core to work: Database ======== -Under ``DATABASES``, the setting below is recommended: +Under ``DATABASES``, we recommend setting ``ATOMIC_REQUESTS`` to ``True`` as in +the following sample. This ensures transactions to be atomic on a view-level. +It is still possible to ensure atomicity of specific blocks of code with +Django's ``transaction.atomic`` decorator or context manager. .. code-block:: python - DATABASES['default']['ATOMIC_REQUESTS'] = False - -.. note:: - - If this conflicts with your existing set up, you can modify the code in your - other apps to use e.g. ``@transaction.atomic``. + DATABASES['default']['ATOMIC_REQUESTS'] = True Templates @@ -244,9 +242,6 @@ The following projectroles settings are **optional**: app views and URLs are still accessible via other links or knowing the URL. The names should correspond to the ``name`` property in project app plugins (list) -``PROJECTROLES_HIDE_APP_LINKS`` - **DEPRECATED**, use ``PROJECTROLES_HIDE_PROJECT_APPS`` instead. This will be - removed in v1.0 ``PROJECTROLES_DELEGATE_LIMIT`` The number of delegate roles allowed per project. The amount is limited to 1 per project if not set, unlimited if set to 0. Will be ignored for remote @@ -333,38 +328,40 @@ information see :ref:`dev_backend_app`. ENABLED_BACKEND_PLUGINS = env.list('ENABLED_BACKEND_PLUGINS', None, []) -API View Settings (Optional) +REST API Settings (Optional) ============================ -If you want to build an API to your site using SODAR Core functionality, it is -recommended to base your API views on ``projectroles.views.SODARAPIBaseView``. -Using this base class also allows you to define your API media type, version -number and allowed versions via Django settings. +.. warning:: -The recommended API setup uses accept header versioning. The -``SODAR_API_MEDIA_TYPE`` setting should be changed to your organization and API -identification if API views are introduced. The ``SODAR_API_DEFAULT_HOST`` -setting should post to the externally visible host of your server and be -configured in your environment settings. + General site-based REST API versioning settings have been deprecated in + SODAR Core v1.0. They will be removed in v1.1. You are expected to provide + your own app-based media type and versioning schema. For more information, + see :ref:`dev_project_app_rest_api`. -These settings are **optional**. Default values will be used if they are unset. +If your site provides a REST API, the ``SODAR_API_DEFAULT_HOST`` setting should +point to the externally visible host of your server and be configured in your +environment settings. Example: -Example: +.. code-block:: python + + SODAR_API_DEFAULT_HOST = env.url('SODAR_API_DEFAULT_HOST', 'http://0.0.0.0:8000') + +For enabling page size customization for pagination, it's recommended to set +``REST_FRAMEWORK['PAGE_SIZE']`` using an environment variable as follows: .. code-block:: python - SODAR_API_DEFAULT_VERSION = '0.1' - SODAR_API_ACCEPTED_VERSIONS = [SODAR_API_DEFAULT_VERSION] - SODAR_API_MEDIA_TYPE = 'application/your.application+json' # Change this - SODAR_API_DEFAULT_HOST = SODAR_API_DEFAULT_HOST = env.url('SODAR_API_DEFAULT_HOST', 'http://0.0.0.0:8000') + REST_FRAMEWORK = { + 'PAGE_SIZE': env.int('SODAR_API_PAGE_SIZE', 100), + } LDAP/AD Configuration (Optional) ================================ If you want to utilize LDAP/AD user logins as configured by projectroles, you -can add the following configuration. Make sure to also add the related env -variables to your configuration. +can add the following configuration. Make sure to also add the related +environment variables to your configuration. This part of the setup is **optional**. @@ -389,6 +386,7 @@ This part of the setup is **optional**. ENABLE_LDAP = env.bool('ENABLE_LDAP', False) ENABLE_LDAP_SECONDARY = env.bool('ENABLE_LDAP_SECONDARY', False) LDAP_DEBUG = env.bool('LDAP_DEBUG', False) + LDAP_ALT_DOMAINS = env.list('LDAP_ALT_DOMAINS', None, default=[]) if ENABLE_LDAP: import itertools @@ -409,20 +407,20 @@ This part of the setup is **optional**. AUTH_LDAP_SERVER_URI = env.str('AUTH_LDAP_SERVER_URI', None) AUTH_LDAP_BIND_DN = env.str('AUTH_LDAP_BIND_DN', None) AUTH_LDAP_BIND_PASSWORD = env.str('AUTH_LDAP_BIND_PASSWORD', None) - AUTH_LDAP_START_TLS = env.str('AUTH_LDAP_START_TLS', False) + AUTH_LDAP_START_TLS = env.bool('AUTH_LDAP_START_TLS', False) AUTH_LDAP_CA_CERT_FILE = env.str('AUTH_LDAP_CA_CERT_FILE', None) AUTH_LDAP_CONNECTION_OPTIONS = {**LDAP_DEFAULT_CONN_OPTIONS} - if AUTH_LDAP_CA_CERT_FILE is not None: - AUTH_LDAP_CONNECTION_OPTIONS[ - ldap.OPT_X_TLS_CACERTFILE - ] = AUTH_LDAP_CA_CERT_FILE + if AUTH_LDAP_CA_CERT_FILE: + AUTH_LDAP_CONNECTION_OPTIONS[ldap.OPT_X_TLS_CACERTFILE] = ( + AUTH_LDAP_CA_CERT_FILE + ) AUTH_LDAP_CONNECTION_OPTIONS[ldap.OPT_X_TLS_NEWCTX] = 0 AUTH_LDAP_USER_FILTER = env.str( 'AUTH_LDAP_USER_FILTER', '(sAMAccountName=%(user)s)' ) - + AUTH_LDAP_USER_SEARCH_BASE = env.str('AUTH_LDAP_USER_SEARCH_BASE', None) AUTH_LDAP_USER_SEARCH = LDAPSearch( - env.str('AUTH_LDAP_USER_SEARCH_BASE', None), + AUTH_LDAP_USER_SEARCH_BASE, ldap.SCOPE_SUBTREE, AUTH_LDAP_USER_FILTER, ) @@ -431,7 +429,6 @@ This part of the setup is **optional**. AUTH_LDAP_DOMAIN_PRINTABLE = env.str( 'AUTH_LDAP_DOMAIN_PRINTABLE', AUTH_LDAP_USERNAME_DOMAIN ) - AUTHENTICATION_BACKENDS = tuple( itertools.chain( ('projectroles.auth_backends.PrimaryLDAPBackend',), @@ -444,20 +441,22 @@ This part of the setup is **optional**. AUTH_LDAP2_SERVER_URI = env.str('AUTH_LDAP2_SERVER_URI', None) AUTH_LDAP2_BIND_DN = env.str('AUTH_LDAP2_BIND_DN', None) AUTH_LDAP2_BIND_PASSWORD = env.str('AUTH_LDAP2_BIND_PASSWORD', None) - AUTH_LDAP2_START_TLS = env.str('AUTH_LDAP2_START_TLS', False) + AUTH_LDAP2_START_TLS = env.bool('AUTH_LDAP2_START_TLS', False) AUTH_LDAP2_CA_CERT_FILE = env.str('AUTH_LDAP2_CA_CERT_FILE', None) AUTH_LDAP2_CONNECTION_OPTIONS = {**LDAP_DEFAULT_CONN_OPTIONS} - if AUTH_LDAP2_CA_CERT_FILE is not None: - AUTH_LDAP2_CONNECTION_OPTIONS[ - ldap.OPT_X_TLS_CACERTFILE - ] = AUTH_LDAP2_CA_CERT_FILE + if AUTH_LDAP2_CA_CERT_FILE: + AUTH_LDAP2_CONNECTION_OPTIONS[ldap.OPT_X_TLS_CACERTFILE] = ( + AUTH_LDAP2_CA_CERT_FILE + ) AUTH_LDAP2_CONNECTION_OPTIONS[ldap.OPT_X_TLS_NEWCTX] = 0 AUTH_LDAP2_USER_FILTER = env.str( 'AUTH_LDAP2_USER_FILTER', '(sAMAccountName=%(user)s)' ) - + AUTH_LDAP2_USER_SEARCH_BASE = env.str( + 'AUTH_LDAP2_USER_SEARCH_BASE', None + ) AUTH_LDAP2_USER_SEARCH = LDAPSearch( - env.str('AUTH_LDAP2_USER_SEARCH_BASE', None), + AUTH_LDAP2_USER_SEARCH_BASE, ldap.SCOPE_SUBTREE, AUTH_LDAP2_USER_FILTER, ) @@ -466,7 +465,6 @@ This part of the setup is **optional**. AUTH_LDAP2_DOMAIN_PRINTABLE = env.str( 'AUTH_LDAP2_DOMAIN_PRINTABLE', AUTH_LDAP2_USERNAME_DOMAIN ) - AUTHENTICATION_BACKENDS = tuple( itertools.chain( ('projectroles.auth_backends.SecondaryLDAPBackend',), @@ -475,137 +473,120 @@ This part of the setup is **optional**. ) -SAML SSO Configuration (Optional) -================================= +.. _app_projectroles_settings_oidc: -Optional Single Sign-On (SSO) authorization via SAML is also available. To -enable this feature, set ``ENABLE_SAML=1`` in your environment. Configuring SAML -for SSO requires proper configuration of the Keycloak SSO server and the SAML -client library. +OpenID Connect (OIDC) Configuration (Optional) +============================================== -Keycloak --------- +SODAR Core supports single sign-on authentication via OIDC from v1.0 onwards. To +enable OIDC logins, add the following Django settings and related environment +variables to your configuration. -Create a new client in Keycloak and configure it as follows. Please note that -**Client ID** can be chosen however you like, but it must match the setting -in the client. +This part of the setup is **optional**. -.. figure:: _static/saml/keycloak_client_config.png +OIDC support is implemented using the ``social_django`` app. You first need to +add the app to your ``INSTALLED_APPS``: -To generate the ``metadata.xml`` file required for the client, go to the -**Realm Settings** page and in the **General** tab, click -``SAML 2.0 Identity Provider Metadata`` to download the xml data. Save it -somewhere on the client, the preferred name is ``metadata.xml``. +.. code-block:: python -.. figure:: _static/saml/keycloak_metadata_download.png + THIRD_PARTY_APPS = [ + # ... + 'social_django', # For OIDC authentication + ] + +Next, you must add the app's URL patterns in ``config/urls.py``: + +.. code-block:: python + + urlpatterns = [ + # ... + # Social auth for OIDC support + path('social/', include('social_django.urls')), + ] + +Finally, you should add the following Django settings in your ``base.py`` +settings file: + +.. code-block:: python -For the signing of the request send to the Keycloak server you will require a -certificate and key provided by the Keycloak server and incorporated into the -configuration of the client. Switch to the ``SAML Keys``. Make sure to select -``PKCS12`` as **Archive Format**. + ENABLE_OIDC = env.bool('ENABLE_OIDC', False) -.. figure:: _static/saml/keycloak_saml_key_download1.png -.. figure:: _static/saml/keycloak_saml_key_download2.png + if ENABLE_OIDC: + AUTHENTICATION_BACKENDS = tuple( + itertools.chain( + ('social_core.backends.open_id_connect.OpenIdConnectAuth',), + AUTHENTICATION_BACKENDS, + ) + ) + TEMPLATES[0]['OPTIONS']['context_processors'] += [ + 'social_django.context_processors.backends', + 'social_django.context_processors.login_redirect', + ] + SOCIAL_AUTH_JSONFIELD_ENABLED = True + SOCIAL_AUTH_JSONFIELD_CUSTOM = 'django.db.models.JSONField' + SOCIAL_AUTH_USER_MODEL = AUTH_USER_MODEL + SOCIAL_AUTH_ADMIN_USER_SEARCH_FIELDS = [ + 'username', + 'name', + 'first_name', + 'last_name', + 'email', + ] + SOCIAL_AUTH_OIDC_OIDC_ENDPOINT = env.str( + 'SOCIAL_AUTH_OIDC_OIDC_ENDPOINT', None + ) + SOCIAL_AUTH_OIDC_KEY = env.str('SOCIAL_AUTH_OIDC_KEY', 'CHANGEME') + SOCIAL_AUTH_OIDC_SECRET = env.str('SOCIAL_AUTH_OIDC_SECRET', 'CHANGEME') + SOCIAL_AUTH_OIDC_USERNAME_KEY = env.str( + 'SOCIAL_AUTH_OIDC_USERNAME_KEY', 'username' + ) -Convert the archive on the commandline with the follow command and store them in -some place on your client. +Critical settings which should be provided through environment variables: -.. code:: +``SOCIAL_AUTH_OIDC_OIDC_ENDPOINT`` + Endpoint URL for the OIDC provider. The configuration file + ``.well-known/openid-configuration`` is expected to be found under this URL. +``SOCIAL_AUTH_OIDC_KEY`` + Your client ID in the OIDC provider. +``SOCIAL_AUTH_OIDC_SECRET`` + Secret for the OIDC provider. +``SOCIAL_AUTH_OIDC_USERNAME_KEY`` + If the username key of the browser is expected to be something other than + the default ``username``, it can be configured here. The values in this must + be unique and should preferably be human readable. If the OIDC provider does + not return such a username directly, it is possible to e.g. use the user + email as a unique user name. - openssl pkcs12 -in keystore.p12 -password "pass:" -nodes | openssl x509 -out cert.pem - openssl pkcs12 -in keystore.p12 -password "pass:" -nodes -nocerts | openssl rsa -out key.pem +If you want to provide a custom template for directing login to your OIDC +provider, add it as ``{PROJECTROLES_TEMPLATE_INCLUDE_PATH}/_login_oidc.html`` +(by default: ``your_site/templates/include/_login_oidc.html``). The include will +be displayed as an element in the login view. -SODAR Core ----------- +Below is an example of a custom template. You can e.g. change the content of the +link to the logo of your OIDC provider. Note that the login URL must equal +``{% url 'social:begin' 'oidc' %}?next={{ oidc_redirect_url|default:'/' }}`` to +ensure it works in all views. -Make sure that your ``config/settings/base.py`` contains the following -configuration: +.. code-block:: django -.. code-block:: python + + OpenID Connect Login + - ENABLE_SAML = env.bool('ENABLE_SAML', False) - SAML2_AUTH = { - # Required setting - # Pysaml2 Saml client settings - # See: https://pysaml2.readthedocs.io/en/latest/howto/config.html - 'SAML_CLIENT_SETTINGS': { - # Optional entity ID string to be passed in the 'Issuer' element of - # authn request, if required by the IDP. - 'entityid': env.str('SAML_CLIENT_ENTITY_ID', 'SODARcore'), - 'entitybaseurl': env.str( - 'SAML_CLIENT_ENTITY_URL', 'https://localhost:8000' - ), - # The auto(dynamic) metadata configuration URL of SAML2 - 'metadata': { - 'local': [ - env.str('SAML_CLIENT_METADATA_FILE', 'metadata.xml'), - ], - }, - 'service': { - 'sp': { - 'idp': env.str( - 'SAML_CLIENT_IPD', - 'https://sso.hpc.bihealth.org/auth/realms/cubi', - ), - # Keycloak expects client signature - 'authn_requests_signed': 'true', - # Enforce POST binding which is required by keycloak - 'binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', - }, - }, - 'key_file': env.str('SAML_CLIENT_KEY_FILE', 'key.pem'), - 'cert_file': env.str('SAML_CLIENT_CERT_FILE', 'cert.pem'), - 'xmlsec_binary': env.str('SAML_CLIENT_XMLSEC1', '/usr/bin/xmlsec1'), - 'encryption_keypairs': [ - { - 'key_file': env.str('SAML_CLIENT_KEY_FILE', 'key.pem'), - 'cert_file': env.str('SAML_CLIENT_CERT_FILE', 'cert.pem'), - } - ], - }, - # Custom target redirect URL after the user get logged in. - # Defaults to /admin if not set. This setting will be overwritten if you - # have parameter ?next= specified in the login URL. - 'DEFAULT_NEXT_URL': '/', - # # Optional settings below - # 'NEW_USER_PROFILE': { - # 'USER_GROUPS': [], # The default group name when a new user logs in - # 'ACTIVE_STATUS': True, # The default active status for new users - # 'STAFF_STATUS': True, # The staff status for new users - # 'SUPERUSER_STATUS': False, # The superuser status for new users - # }, - # 'ATTRIBUTES_MAP': env.dict( - # 'SAML_ATTRIBUTES_MAP', - # default={ - # Change values to corresponding SAML2 userprofile attributes. - # 'email': 'Email', - # 'username': 'UserName', - # 'first_name': 'FirstName', - # 'last_name': 'LastName', - # } - # ), - # 'TRIGGER': { - # 'FIND_USER': 'path.to.your.find.user.hook.method', - # 'NEW_USER': 'path.to.your.new.user.hook.method', - # 'CREATE_USER': 'path.to.your.create.user.hook.method', - # 'BEFORE_LOGIN': 'path.to.your.login.hook.method', - # }, - # Custom URL to validate incoming SAML requests against - # 'ASSERTION_URL': 'https://your.url.here', - } -Add the following settings to your environment variables: +SAML SSO Configuration (Removed in v1.0) +======================================== -.. code-block:: +.. note:: - ENABLE_SAML=1 - SAML_CLIENT_ENTITY_ID= - SAML_CLIENT_ENTITY_URL= - SAML_CLIENT_METADATA_FILE= - SAML_CLIENT_IPO= - SAML_CLIENT_KEY_FILE= - SAML_CLIENT_CERT_FILE= - SAML_CLIENT_XMLSEC1= + SAML support has been removed in SODAR Core v1.0. It has been replaced with + the possibility to set up OpenID Connect (OIDC) authentication. The library + previously used for SAML in SODAR Core is incompatible with Django v4.x. We + are unaware of SODAR Core based projects requiring SAML at this time. If + there are specific needs to use SAML on a SODAR Core based site, we are + happy to review pull requests to reintroduce it. Please note the + implementation has to support Django v4.2+. Global JS/CSS Include Modifications (Optional) diff --git a/docs/source/app_projectroles_usage.rst b/docs/source/app_projectroles_usage.rst index 592a8e64..0d5d7253 100644 --- a/docs/source/app_projectroles_usage.rst +++ b/docs/source/app_projectroles_usage.rst @@ -25,14 +25,19 @@ Core based Django site. One can either log in using a local Django user or, if LDAP/AD is enabled, their LDAP/AD credentials from a supported site. In the latter case, the user domain -must be appended to the user name in form of ``user@DOMAIN``. Single sign-on -with SAML can also be made available. +must be appended to the user name in form of ``user@DOMAIN``. + +If OpenID Connect (OIDC) single-sign on authentication is enabled, an extra +login element will be displayed next to the standard login controls. This will +take the user to the login view of the OIDC provider. The element can be +replaced with a custom template to e.g. use specific graphics recommended by the +provider. .. figure:: _static/app_projectroles/sodar_login.png :align: center :scale: 75% - SODAR login form + SODAR Core login form User Interface @@ -198,6 +203,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 ------------ @@ -309,11 +324,12 @@ Invites ------- Invites are accepted by the responding user clicking on a link supplied in their -invite email and either logging in to the site with their LDAP/AD credentials or -creating a local user. The latter is only allowed if local users are enabled in -the site's Django settings and the user email domain is not associated with -configured LDAP domains. Invites expire after a certain time and can be reissued -or revoked on the **Project Invites** page. +invite email. Depending on how the site is configured, users can then either +login to the site using their LDAP/OIDC credentials or create a local user. The +latter is only allowed if local users are enabled in the site's Django settings +and the user email domain is not associated with configured LDAP domains. +Invites expire after a certain time and can be reissued or revoked on the +:guilabel:`Project Invites` page. Batch Member Modifications -------------------------- @@ -323,6 +339,19 @@ project permissions, or by a site admin using the ``batchupdateroles`` management command. The latter supports multiple projects in one batch. It is also able to send invites to users who have not yet signed up on the site. +User Status Checking +-------------------- + +An administrator can check status of external LDAP user accounts using the +``checkusers`` management command. This will list accounts disabled or locked +out of the LDAP server. Use the ``-h`` flag to see additional options. + +.. code-block:: console + + $ ./manage.py checkusers + + +.. _app_projectroles_usage_remote: Remote Projects =============== @@ -345,7 +374,8 @@ project data can be provided. A target site can define exactly one source site, from which project data can be retrieved from. To enable remote project data and member synchronization, you must first set up -either a target or a source site depending on the role of your own SODAR site. +either a target or a source site depending on the role of your own SODAR Core +based site. .. figure:: _static/app_projectroles/sodar_remote_sites.png :align: center @@ -362,10 +392,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 @@ -381,6 +434,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 @@ -405,15 +467,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 -------------- @@ -450,7 +503,29 @@ Alternatively, the following management command can be used: If a local user is the owner of a synchronized project on the source site, the user defined in the ``PROJECTROLES_DEFAULT_ADMIN`` will be given the owner role. Hence you **must** have this setting defined if you are - implementing a SODAR site in target mode. + implementing a SODAR Core based site in target mode. + +.. note:: + + Local non-owner users can be granted roles if + ``PROJECTROLES_ALLOW_LOCAL_USERS`` is set on the target site. However, local + users must be manually created by a target site admin in order for their + data and roles to be synchronized. + +Project Detail View Links +------------------------- + +Links to the same project on other sites will appear in the +:guilabel:`Project on Other Sites` card in the project detail view. If the +remote site has not yet synchronized this project, the link will appear grayed +out and unclickable. On a remote site, the source project will be labeled as +such. + +.. figure:: _static/app_projectroles/sodar_remote_project_links.png + :align: center + :scale: 75% + + Remote project links in the project detail view Search diff --git a/docs/source/app_sodarcache.rst b/docs/source/app_sodarcache.rst index 54f6383f..062011c3 100644 --- a/docs/source/app_sodarcache.rst +++ b/docs/source/app_sodarcache.rst @@ -13,6 +13,7 @@ queries to databases other than the local Django PostgreSQL. :maxdepth: 3 :caption: Contents: - Installation - Usage - Django API Documentation + Installation + Usage + Django API Documentation + REST API Documentation diff --git a/docs/source/app_sodarcache_api_rest.rst b/docs/source/app_sodarcache_api_rest.rst new file mode 100644 index 00000000..74ecd4a6 --- /dev/null +++ b/docs/source/app_sodarcache_api_rest.rst @@ -0,0 +1,34 @@ +.. _app_sodarcache_api_rest: + + +Sodarcache REST API Documentation +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This document contains the HTTP REST API documentation for the ``sodarcache`` +app. + + +Sodarcache REST API Versioning +============================== + +Media Type + ``application/vnd.bihealth.sodar-core.sodarcache+json`` +Current Version + ``1.0`` +Accepted Versions + ``1.0`` +Header Example + ``Accept: application/vnd.bihealth.sodar-core.sodarcache+json; version=x.y`` + + +Sodarcache REST API Views +========================= + +.. currentmodule:: sodarcache.views_api + +.. autoclass:: CacheItemRetrieveAPIView + +.. autoclass:: CacheItemDateRetrieveAPIView + +.. autoclass:: CacheItemSetAPIView + diff --git a/docs/source/app_timeline.rst b/docs/source/app_timeline.rst index 69081f43..dc3d78aa 100644 --- a/docs/source/app_timeline.rst +++ b/docs/source/app_timeline.rst @@ -27,3 +27,4 @@ the :ref:`timeline usage documentation `. Installation Usage Django API Documentation + REST API Documentation diff --git a/docs/source/app_timeline_api_rest.rst b/docs/source/app_timeline_api_rest.rst new file mode 100644 index 00000000..322adf4c --- /dev/null +++ b/docs/source/app_timeline_api_rest.rst @@ -0,0 +1,33 @@ +.. _app_timeline_api_rest: + + +Timeline REST API Documentation +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This document contains the HTTP REST API documentation for the ``timeline`` +app. + + +Timeline REST API Versioning +============================ + +Media Type + ``application/vnd.bihealth.sodar-core.timeline+json`` +Current Version + ``1.0`` +Accepted Versions + ``1.0`` +Header Example + ``Accept: application/vnd.bihealth.sodar-core.timeline+json; version=x.y`` + + +Timeline REST API Views +======================= + +.. currentmodule:: timeline.views_api + +.. autoclass:: ProjectTimelineEventListAPIView + +.. autoclass:: SiteTimelineEventListAPIView + +.. autoclass:: TimelineEventRetrieveAPIView diff --git a/docs/source/app_timeline_usage.rst b/docs/source/app_timeline_usage.rst index 0346ce22..4699b688 100644 --- a/docs/source/app_timeline_usage.rst +++ b/docs/source/app_timeline_usage.rst @@ -24,15 +24,19 @@ All Timeline Events .. figure:: _static/app_timeline/sodar_timeline.png :align: center - :scale: 50% + :scale: 65% Timeline project event list view -The event list layout is practically similar for each view. By clicking on the -time stamp for each event, you can view the details on different status updates +The event list layout is mostly similar for each view. By clicking on the +timestamp for each event, you can view the details on different status updates for the execution of the event. This is used e.g. in case of asynchronous events. +The event description contains a badge with the event name, which can be used +to refer to similar events and e.g. search for events of a specific type using +the site search. + By clicking on the clock icon next to an object link in the event description, you can view the event history of that object. The link itself will take you to the relevant view for the object on your Django site. @@ -152,9 +156,9 @@ Defining Status States need to pay attention to this functionality right now. By default, ``timeline.add_event()`` treats events as synchronous and -automatically saves them with the status of ``OK``. However, in case of e.g. -asynchronous requests, you can alter this by setting the ``status_type`` and -(optionally) ``status_desc`` types upon creation. +automatically saves them with the status of ``TL_STATUS_OK``. However, in case +of e.g. asynchronous requests, you can alter this by setting the ``status_type`` +and (optionally) ``status_desc`` types upon creation. .. code-block:: python @@ -164,7 +168,7 @@ asynchronous requests, you can alter this by setting the ``status_type`` and user=request.user, event_name='some_event', description='Description', - status_type='SUBMIT', + status_type=TL_STATUS_SUBMIT status_desc='Just submitted this') After that, you can add new status states for the event using the object @@ -172,18 +176,18 @@ returned by ``timeline.add_event()``: .. code-block:: python - tl_event.set_status('OK', 'Submission was successful!') + tl_event.set_status(timeline.TL_STATUS_SUBMIT, 'Submission was successful!') Currently supported status types are listed below, some only applicable to async events: -- ``OK``: All OK, event successfully performed -- ``INFO``: Used for events which do not change anything, e.g. viewing something - within an app -- ``INIT``: Initializing the event in progress -- ``SUBMIT``: Event submitted asynchronously -- ``FAILED``: Asynchronous event submission failed -- ``CANCEL``: Event cancelled +- ``TL_STATUS_OK``: All OK, event successfully performed +- ``TL_STATUS_INFO``: Used for events which do not change anything, e.g. viewing + something within an app +- ``TL_STATUS_INIT``: Initializing the event in progress +- ``TL_STATUS_SUBMIT``: Event submitted asynchronously +- ``TL_STATUS_FAILED``: Asynchronous event submission failed +- ``TL_STATUS_CANCEL``: Event cancelled Extra Data ---------- diff --git a/docs/source/app_userprofile.rst b/docs/source/app_userprofile.rst index 633671f5..572318a9 100644 --- a/docs/source/app_userprofile.rst +++ b/docs/source/app_userprofile.rst @@ -81,12 +81,40 @@ app plugins. User settings defined in the ``projectroles`` app, available for all SODAR Core using sites: +Receive Email for Admin Alerts + Receive email for :ref:`admin alerts `. Display Project UUID Copying Link If set true, display a link in the project title bar for copying the project UUID into the clipboard. -Additional Email - In addition to the default user email, also send email notifications to - these addresses. +Receive Email for Project Updates + Receive email notifications for project or category creation, updating, + moving and archiving. +Receive Email for Project Membership Updates + Receive email notifications for project or category membership updates and + invitation activity. In the development setup, the SODAR Core example site apps also provide additional settings for demonstrating settings features. + + +Additional Emails +================= + +The user can configure additional emails for their user account in case they +want to receive automated emails to addresses other than their primary address. +The user profile view displays additional emails and provides controls for +managing these addresses. + +.. hint:: + + Managing addresses is only possible on a source site. On a target site, + emails will be visible but not mofifiable. + +A new additional email address can be added with a form accessible by clicking +on the :guilabel:`Add Email` button. After creation, a verification email will +be sent to the specified address. Opening a link contained in the email and +logging into the site will verify the email. Only verified email addresses will +receive automated emails from the site. + +For each email address displayed in the list, there are controls to re-send the +verification email (in case of an unverified email) and deleting the address. diff --git a/docs/source/conf.py b/docs/source/conf.py index c0712f2d..f628b7d5 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -27,9 +27,9 @@ author = 'BIH Core Unit Bioinformatics' # The short X.Y version -version = '0.13' +version = '1.0' # The full version, including alpha/beta/rc tags -release = '0.13.4' +release = '1.0.0' # -- General configuration --------------------------------------------------- diff --git a/docs/source/dev_backend_app.rst b/docs/source/dev_backend_app.rst index de77e8c2..1f9963d5 100644 --- a/docs/source/dev_backend_app.rst +++ b/docs/source/dev_backend_app.rst @@ -90,7 +90,8 @@ Implementing the following is **optional**: Return statistics for the siteinfo app. See details in :ref:`the siteinfo documentation `. ``get_object_link()`` - Return object link for a Timeline event. + Return object link for a Timeline event. Expected to return a + ``PluginObjectLink`` object or ``None``. ``get_extra_data_link()`` Return extra data link for a Timeline event. diff --git a/docs/source/dev_core_install.rst b/docs/source/dev_core_install.rst index 5992aeb5..baeae331 100644 --- a/docs/source/dev_core_install.rst +++ b/docs/source/dev_core_install.rst @@ -26,7 +26,8 @@ system of choice. System Dependencies =================== -To get started, install the OS dependencies, PostgreSQL >=11 and Python >=3.8. +To get started, install the OS dependencies, Python >=3.9 (3.11 recommended) and +PostgreSQL >=12 (16 recommended). .. code-block:: console @@ -59,6 +60,13 @@ Example of the database URL variable as set within an ``.env`` file: DATABASE_URL=postgres://your-db:your-db@127.0.0.1/your-db +Asynchronous Celery tasks require running a Redis server. For development, you +can install it with the following script: + +.. code-block:: console + + $ sudo utility/install_redis.sh + Repository and Environment Setup ================================ @@ -134,7 +142,9 @@ Optional Steps For creating a group of example users for your development site, you can run the ``createdevusers`` management command. This creates the users "alice", "bob", -"carol", "dan" and "erin", all with the password "password". +"carol", "dan" and "erin". The users will be created with the password +"sodarpass", unless a custom password is supplied via the ``-p`` or +``--password`` argument. .. code-block:: console diff --git a/docs/source/dev_project_app.rst b/docs/source/dev_project_app.rst index 79118d40..00c32c68 100644 --- a/docs/source/dev_project_app.rst +++ b/docs/source/dev_project_app.rst @@ -53,7 +53,7 @@ to set up a fresh app generated in the standard way with It is also assumed that apps are more or less created according to best practices defined by `Two Scoops `_, with the -use of `Class-Based Views `_ +use of `Class-Based Views `_ being a requirement. @@ -96,7 +96,7 @@ To provide a unique identifier for objects in the SODAR context, add a When updating an existing Django model with an existing database, the ``sodar_uuid`` field needs to be populated. See - `instructions in Django documentation `_ + `instructions in Django documentation `_ on how to create the required migrations. Model Example @@ -247,12 +247,13 @@ Implementing the following is **optional**: List of names for app-specific Django settings to be displayed for administrators in the siteinfo app. ``get_object_link()`` - Return object link for a Timeline event. + Return object link for a Timeline event. Expected to return a + ``PluginObjectLink`` object or ``None``. ``get_extra_data_link()`` Return extra data link for a Timeline event. ``search()`` Function called when searching for data related to the app if search is - enabled. + enabled. Expected to return a list of ``PluginSearchResult`` objects. ``get_statistics()`` Return statistics for the siteinfo app. See details in :ref:`the siteinfo documentation `. @@ -595,7 +596,7 @@ Project Search API and Template =============================== If you want to implement search in your project app, you need to implement the -``search()`` method in your plugin as well as a template for displaying the +``search()`` method in your plugin, as well as a template for displaying the results. .. hint:: @@ -627,29 +628,33 @@ See the signature of ``search()`` in .. note:: - Within this function, you are expected to verify appropriate access of the + Within this method, you are expected to verify appropriate access of the searching user yourself! -.. warning:: +The return data is a list of one or more ``PluginSearchResult`` objects. The +objects are expected to be split between search categories, of which there can +be one or multiple. This is useful where e.g. the same type of HTML list isn't +suitable for all returnable types. If only returning one type of data, you can +use e.g. ``all`` as your only category. Example of a return data: - The old expected signature of providing a single ``search_term`` argument - has been deprecated in v0.9 and will be removed in the next major release! +.. code-block:: python -The return data is a dictionary, which is split by groups in case your app can -return multiple different lists for data. This is useful where e.g. the same -type of HTML list isn't suitable for all returnable types. If only returning one -type of data, you can just use e.g. ``all`` as your only category. Example of -the result: + from projectroles.plugins import PluginSearchResult + # ... + return [ + PluginSearchResult( + category='all', # Category ID to be used in your search template + title='List title', # Title of the result set + search_types=[], # Object types included in this category + items=[], # List or QuerySet of objects returned by search + ) + ] -.. code-block:: python +.. warning:: + + The earlier search implementation expected a ``dict`` as return data. This + has been deprecated and support for it will be removed in SODAR Core v1.1. - return { - 'all': { # 1-N categories to be included - 'title': 'List title', # Title of the result list to be displayed - 'search_types': [], # Object types included in this category - 'items': [] # The actual objects returned - } - } Search Template --------------- @@ -700,6 +705,8 @@ API Views API view usage in project apps is detailed in this section. +.. _dev_project_app_rest_api: + Rest API Views -------------- @@ -713,14 +720,40 @@ methods of authentication: Knox tokens and Django session auth. These can of course be modified by overriding/extending the base classes. For versioning we strongly recommend using accept header versioning, which is -what is supported by the SODAR Core base classes. For this, supply your custom -media type and version data using the corresponding ``SODAR_API_*`` settings. -For details on these, see :ref:`app_projectroles_settings`. +what is supported by the SODAR Core base classes. From SODAR Core v1.0 onwards, +each app is expected to use its own media type and API versioning, preferably +based on semantic versioning. For this, you should supply your own versioning +mixin to be used in your views. Example: -The base classes provide permission checks via SODAR Core project objects -similar to UI view mixins. +.. code-block:: python -Base REST API classes without a project context can also be used in site apps. + from rest_framework.renderers import JSONRenderer + from rest_framework.versioning import AcceptHeaderVersioning + from rest_framework.views import APIView + from projectroles.views_api import SODARAPIGenericProjectMixin + + YOURAPP_API_MEDIA_TYPE = application/vnd.yourorg.yoursite.yourapp+json + YOURAPP_API_DEFAULT_VERSION = '1.0' + YOURAPP_API_ALLOWED_VERSIONS = ['1.0'] + + class YourAPIVersioningMixin: + + class YourAPIRenderer(JSONRenderer): + media_type = YOURAPP_API_MEDIA_TYPE + + class YourAPIVersioning(AcceptHeaderVersioning): + allowed_versions = YOURAPP_API_ALLOWED_VERSIONS + default_version = YOURAPP_API_DEFAULT_VERSION + + render_classes = [YourAPIRenderer] + versioning_class = YourAPIVersioning + + class YourAPIView(YourAPIVersioningMixin, SODARAPIGenericProjectMixin, APIView): + # ... + +The base classes provide permission checks via SODAR Core project objects +similar to UI view mixins. Base REST API classes without a project context can +also be used in site apps. See the :ref:`base REST API class documentation ` for @@ -732,13 +765,14 @@ An example "hello world" REST API view for SODAR apps is available in .. note:: Internal SODAR Core REST API views, specifically ones used in apps provided - by the django-sodar-core package, use different media type and versioning - from views to be implemented on your site. This is to prevent version number - clashes and not require changes from your API when SODAR Core is updated. + by the django-sodar-core package, use different media types and versioning + from views to be implemented on your site. From SODAR Core v1.0 onwards, + each app is expected to provide their own versioning. For implementing your own API views, make sure to use the ``SODARAPI*`` base classes, **not** the ``CoreAPI`` classes. Similarly, in testing make - sure to use the base class helpers of the site API instead of the core API. + sure to use the base class helpers of the general API instead of the core + API. Ajax API Views -------------- @@ -871,8 +905,8 @@ with the name of your application as specified in its ``ProjectAppPlugin``. .. code-block:: python - from timeline.models import ProjectEvent - ProjectEvent.objects.filter(app='app_name').delete() + from timeline.models import TimelineEvent + TimelineEvent.objects.filter(app='app_name').delete() Next you should delete existing database objects defined by the models in your app. This is also most easily done via the Django shell. Example: diff --git a/docs/source/dev_resource.rst b/docs/source/dev_resource.rst index c0b1e8fe..ca91a530 100644 --- a/docs/source/dev_resource.rst +++ b/docs/source/dev_resource.rst @@ -306,9 +306,10 @@ The rest of the attributes are listed below: Default value for the setting. This is returned if no value has been set. Can alternatively be a callable with the signature ``callable(project=None, user=None)``. -``local`` - Boolean for allowing/disallowing editing in target sites for synchronized - projects. +``global`` + Boolean for allowing/disallowing editing in target sites for remote + projects. Relevant to ``SOURCE`` sites. If set ``True``, the value can not + be edited on target sites, the default value being ``False`` (optional). ``label`` Label string to be displayed in forms for ``PROJECT`` and ``USER`` scope settings (optional). @@ -341,7 +342,7 @@ docs, see from projectroles.app_settings import AppSettingAPI app_settings = AppSettingAPI() - app_settings.get('app_name', 'setting_name', project_object) # Etc.. + app_settings.get('plugin_name', 'setting_name', project_object) # Etc.. There is no need to separately generate settings for projects or users. If the setting object does not exist in the Django database when diff --git a/docs/source/dev_site_app.rst b/docs/source/dev_site_app.rst index d09943f1..f6dd0b86 100644 --- a/docs/source/dev_site_app.rst +++ b/docs/source/dev_site_app.rst @@ -119,7 +119,8 @@ Implementing the following is **optional**: Return statistics for the siteinfo app. See details in :ref:`the siteinfo documentation `. ``get_object_link()`` - Return object link for a Timeline event. + Return object link for a Timeline event. Expected to return a + ``PluginObjectLink`` object or ``None``. ``get_extra_data_link()`` Return extra data link for a Timeline event. diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst index c7704d46..a192bebf 100644 --- a/docs/source/getting_started.rst +++ b/docs/source/getting_started.rst @@ -17,7 +17,7 @@ the package is under active development and breaking changes are expected. .. code-block:: console - pip install django-sodar-core==0.13.4 + pip install django-sodar-core==1.0.0 Please note that the django-sodar-core package only installs :term:`Django apps`, which you need to include in a @@ -56,9 +56,9 @@ your Django site are listed below. For a complete requirement list, see the - Ubuntu (20.04 Focal recommended and supported) / CentOS 7 - System library requirements (see the ``utility`` directory and/or your own Django project) -- Python >=3.8 (**NOTE:** Python 3.7 no longer supported in SODAR Core v0.10.8+) -- Django 3.2 -- PostgreSQL >=11 and psycopg2-binary +- Python 3.9-3.11 (3.11 recommended) +- Django 4.2 +- PostgreSQL >=12 (16 recommended) and psycopg2-binary - Bootstrap 4.x - JQuery 3.3.x - Shepherd and Tether diff --git a/docs/source/index.rst b/docs/source/index.rst index d264491f..1f7be576 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -79,8 +79,8 @@ Python Programming any. Django Development - For learning about Django, head over to the `excellent documentation of the - Django Project `_. + For learning about Django, head over to the + `official Django documentation `_. HTML / Javascript / CSS / Bootstrap 4 Together with Django, SODAR Core provides a framework to plug in your own diff --git a/docs/source/major_changes.rst b/docs/source/major_changes.rst index 32e52cca..856957be 100644 --- a/docs/source/major_changes.rst +++ b/docs/source/major_changes.rst @@ -10,6 +10,314 @@ older SODAR Core version. For a complete list of changes in current and previous releases, see the :ref:`full changelog`. +v1.0.0 (2024-07-19) +******************* + +Release Highlights +================== + +- Upgrade to Django v4.2 and Postgres v16 +- Add Python v3.11 support +- Add OpenID Connect (OIDC) authentication support +- Add app specific and semantic REST API versioning +- Add REST API versioning independent from repo/site versions +- Add timeline REST API +- Add optional pagination for REST API list views +- 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 +- Add checkusers management command +- Add Ajax views for sidebar, project dropdown and user dropdown retrieval +- Add CC and BCC field support in sending generic emails +- Add is_set() helper in AppSettingAPI +- Rewrite sodarcache REST API views +- Rewrite user additional email storing +- Rename AppSettingAPI "app_name" arguments to "plugin_name" +- Plugin API return data updates and deprecations +- Rename timeline app models +- Rename base test classes +- Remove app setting max length limit +- Remove Python v3.8 support +- Remove SAML authentication support + +Breaking Changes +================ + +Django v4.2 Upgrade +------------------- + +This release updates SODAR Core from Django v3.2 to v4.2. This is a breaking +change which causes many updates and also requires updating several +dependencies. + +Please update the Django requirement along with your site's other Python +requirements to match ones in ``requirements/*.txt``. See +`Django deprecation documentation `_ +for details about what has been deprecated in Django and which changes are +mandatory. + +Common known issues: + +- Minimum version of PostgreSQL has been bumped to v12. +- Replace ``django.utils.translation.ugettext_lazy`` imports with + ``gettext_lazy``. +- Replace ``django.conf.urls.url`` imports with ``django.urls.re_path``. +- Calls for ``related_managers`` for unsaved objects raises ``ValueError``. This + can be handled by e.g. calling ``model.save(commit=False)`` before trying to + access foreign key relations. + +System Prerequisites +-------------------- + +PostgreSQL + The minimum required PostgreSQL version has been bumped to v12. We recommend + PostgreSQL v16 which is used in CI for this repo. However, any version from + v12 onwards should work with this release. We strongly recommended to make + backups of your production databases before upgrading. +Python v3.11 Support Added + Python v3.11 support has been officially added in this version. 3.11 is also + the recommended Python version. +Python v3.8 Support Dropped + This release no longer supports Python v3.8. +General Python Dependencies + Third party Python package dependencies have been upgraded. See the + ``requirements`` directory for up-to-date package versions and upgrade your + project. + +REST API Versioning Overhaul +---------------------------- + +The REST API versioning system has been overhauled. Before, we used two separate +accept header versioning systems: 1) API views in SODAR Core apps with +``CORE_API_MEDIA_TYPE``, and 2) API views of the site built on SODAR Core with +``SODAR_API_MEDIA_TYPE``. The version number has been expected to match the +SODAR Core or the site version number, respectively. + +In the new system there are two critical changes to this versioning scheme: + +1. Each app is expected to provide its own media type and API version number. +2. Each API should use a version number independent from the site version, + ideally following semantic versioning. Versions will start at ``1.0``. + +SODAR Core REST APIs are now be provided under the following media types, each +available initially under version ``1.0``: + +:ref:`Projectroles ` + ``application/vnd.bihealth.sodar-core.projectroles+json`` +Projectroles Remote Sync (only used internally by SODAR Core sites) + ``application/vnd.bihealth.sodar-core.projectroles.sync+json`` +:ref:`Filesfolders ` + ``application/vnd.bihealth.sodar-core.filesfolders+json`` +:ref:`Sodarcache ` + ``application/vnd.bihealth.sodar-core.sodarcache+json`` +:ref:`Timeline ` + ``application/vnd.bihealth.sodar-core.timeline+json`` + +If you have previously used the ``SODAR_API_*`` accept header versioning, you +should update your own views to the new scheme. To do this, you need to provide +your custom renderer and versioning class for each app providing a REST API. For +instructions and an example on how to do this, see +:ref:`dev_project_app_rest_api`. + +.. note:: + + Legacy ``SODAR_API_*`` is deprecated but will still be supported in this + release. Support will be removed in v1.1, after which you will be required + to provide your own versioning and rendering classes. Calls to updated API + views using legacy versioning will not work with SODAR Core v1.0. + +.. warning:: + + The legacy API versioning for SODAR Core API views (projectroles and + filesfolders) is no longer working as of v1.0. Make sure to update your + clients for the new version number and see the API changes below. After this + overhaul, we aim to provide backwards compatibility for old API versions + whereever possible. + +REST API Pagination Support +--------------------------- + +This release adds optional pagination support for REST API list views. To +paginate your results, provide the ``?page=1`` query string in your request. +If paginated, the results will correspond to the Django Rest Framework +``PageNumberPagination`` results. For more, see +`DRF documentation `_. + +Requests to the views without the pagination query string return full results +as a list as they did in previous releases. Hence, this change should not +equire breaking changes in clients using the REST API. + +To support REST API list view pagination on your site, it is recommended to add +the following in your Django settings: + +.. code-block:: python + + REST_FRAMEWORK = { + # ... + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': env.int('SODAR_API_PAGE_SIZE', 100), + } + +REST API View Changes +--------------------- + +The following breaking changes have been made into specific REST API endpoints +in this release: + +``ProjectRetrieveAPIView`` (``project/api/retrieve/``) + Add ``full_title`` field to return data. Also affects list view. +``ProjectSettingRetrieveAPIView`` (``project/api/settings/retrieve/``) + Rename ``app_name`` parameter to ``plugin_name``. +``ProjectSettingSetAPIView`` (``project/api/settings/set/``) + Rename ``app_name`` parameter to ``plugin_name``. +``UserSettingRetrieveAPIView`` (``project/api/settings/retrieve/user``) + Rename ``app_name`` parameter to ``plugin_name``. +``UserSettingSetAPIView`` (``project/api/settings/set/user``) + Rename ``app_name`` parameter to ``plugin_name``. +:ref:`Sodarcache REST API ` + The entire sodarcache REST API has been rewritten to conform to our general + REST API conventions regarding URLs and return data. See the API + documentation and refactor your client code accordingly. + +Timeline Models Renamed +----------------------- + +Models in the timeline app have been renamed from ``ProjectEvent*`` to +``TimelineEvent*``. This is done to better reflect the fact that events are not +necessarily tied to projects. + +If your site only accesses these models through ``TimelineAPI`` and +``TimelineAPI.get_model()``, which is the strongly recommended way, no changes +should be required. + +AppSettingAPI Plugin Name Arguments Updated +------------------------------------------- + +In ``AppSettingAPI``, all method arguments called ``app_name`` have been renamed +into ``plugin_name``. This is to remove confusion as the argument does in fact +refer specifically to a plugin, not the app name itself. If the argument is +provided as a kwarg, references to it should be renamed. + +App Settings "Local" Attribute Deprecated +----------------------------------------- + +The optional ``local`` attribute in app settings definitions has been +depreacted. You should instead use ``global`` and the inverse value of your +existing ``local`` settings. Support for ``local`` will be removed in SODAR Core +v1.1. This is only relevant to sites being deployed as ``SOURCE`` sites. + +Plugin API get_object_link() Changes +------------------------------------ + +Implementations of ``get_object_link()`` in app plugins are now expected to +return a ``PluginObjectLink`` object or ``None``. Returning a ``dict`` has been +deprecated and support for it will be removed in v1.1. + +Furthermore, the return data is now expected to return the object name in the +``name`` attribute, instead of ``label`` in the old implementation. + +Plugin API search() Changes +--------------------------- + +Similar to ``get_object_link()``, expected return data for ``search()`` has +changed. Implementations are now expected to return a list of +``PluginSearchResult`` objects. Returning a ``dict`` has been deprecated and +support for it will be removed in v1.1. + +Note that a dict using the ``category`` variable as a key will still be +provided for your app's search template. Hence, modifying the template should +not be required after updating the method. + +User Additional Email Changes +----------------------------- + +The ``user_email_additional`` app setting has been removed. Instead, additional +user email addresses can be accessed via the ``SODARUserAdditionalEmail`` model +if needed. Using ``email.get_user_addr()`` to retrieve all user email addresses +is strongly recommended. + +Remote Sync User Update Changes +------------------------------- + +UUIDs Updated to Match Source Site + User UUIDs on target sites are now correctly created and updated in remote + sync to match the UUIDs of similarly named users on the source site. This + should only be a breaking change in case you are storing existing user UUIDs + outside your site and using them in e.g. REST API queries. Otherwise this + change should require no actions. +Local User Details Updated on Target Site + If local users are enabled, local users are updated to match target site + users similar to what was before done for LDAP/AD users. Note that creation + of local users must still be done manually: they will not be automatically + created by remote sync. + +Django Settings Changed +----------------------- + +``AUTH_LDAP*_USER_SEARCH_BASE`` Added + The user search base values for primary and secondary LDAP servers have been + included as directly accessible Django settings. This is require for the + ``checkuser`` management command to work. It is recommended to update your + site's LDAP settings accordingly. +``PROJECTROLES_HIDE_APP_LINKS`` Removed + The ``PROJECTROLES_HIDE_APP_LINKS`` Django setting, which was deprecated in + v0.13, has been removed. Use ``PROJECTROLES_HIDE_PROJECT_APPS`` instead. + +SAML Authentication Support Removed +----------------------------------- + +SAML support has been removed and replaced with the possibility to set up OpenID +Connect (OIDC) authentication. The library previously used for SAML in SODAR +Core is incompatible with Django v4.x. We are unaware of SODAR Core based +projects requiring SAML at this time. If there are specific needs to use SAML on +a SODAR Core based site, we are happy to review pull requests to reintroduce it. +Please note the implementation has to support Django v4.2+. + +OpenID Connect (OIDC) Authentication Support Added +-------------------------------------------------- + +This version adds OIDC support using the ``social_django`` app. In order to +provide OIDC authentication access to your users, you need to add the app and +its URLs to your site config along with appropriate Django settings. See +:ref:`OIDC settings documentation ` for +instructions on how to to configure OIDC on your site. + +Login Template Updated +---------------------- + +The default login template ``login.html`` has been updated by adding OpenID +Connect (OIDC) controls and removing SAML controls. If you have overridden the +login template with your own and wish to use OIDC authentication, make sure to +update your template accordingly. + +Base Test Classes Renamed +------------------------- + +A number of base test classes in the :ref:`Projectroles app ` +have been renamed for consistency. If you use these base classes in your site's +tests, you will have to rename them accordingly. The changes are as follows: + +- ``projectroles.tests.test_permissions`` + * ``TestPermissionBase`` -> ``PermissionTestBase`` + * ``TestPermissionMixin`` -> ``PermissionTestMixin`` + * ``TestProjectPermissionBase`` -> ``ProjectPermissionTestBase`` + * ``TestSiteAppPermissionBase`` -> ``SiteAppPermissionTestBase`` +- ``projectroles.tests.test_permissions_api`` + * ``TestProjectAPIPermissionBase`` -> ``ProjectAPIPermissionTestBase`` +- ``projectroles.tests.test_templatetags`` + * ``TestTemplateTagsBase`` -> ``TemplateTagTestBase`` +- ``projectroles.tests.test_ui`` + * ``TestUIBase`` -> ``UITestBase`` +- ``projectroles.tests.test_views`` + * ``TestViewsBase`` -> ``ViewTestBase`` +- ``projectroles.tests.test_views_api`` + * ``TestAPIViewsBase`` -> ``APIViewTestBase`` + + v0.13.4 (2024-02-16) ******************** diff --git a/docs/source/overview.rst b/docs/source/overview.rst index 29759cfe..ed321944 100644 --- a/docs/source/overview.rst +++ b/docs/source/overview.rst @@ -53,8 +53,8 @@ develop a user friendly system for accessing, browsing and/or manipulating research data. The data may belong to several different projects, with different research groups or scientists working on it. This data may be confidential in nature, so access control is required, preferably using the organization's -existing LDAP/AD servers. The organization wants to provide a web-based GUI as -well as programmatic API views. +existing LDAP/AD servers or single sign-on logins via OpenID Connect (OIDC). The +organization wants to provide a web-based GUI as well as programmatic API views. Site Setup ---------- @@ -99,13 +99,14 @@ Using the Site -------------- The researchers will log in to the site on their web browser, in most cases -using the standard LDAP credentials provided by their organization. They will -see the projects they have been granted access to and can use whichever -applications have been enabled or developed for the site, according to their -assigned user rights. SODAR Core provides common navigation, overview and search -views for all enabled apps, including the one(s) developed by the organization. -The same user access management features are shared for all apps, along with -possible REST APIs developed by the organization. +using the standard LDAP credentials provided by their organization or a single +sign-on OIDC account. They will see the projects they have been granted access +to and can use whichever applications have been enabled or developed for the +site, according to their assigned user rights. SODAR Core provides common +navigation, overview and search views for all enabled apps, including the one(s) +developed by the organization. The same user access management features are +shared for all apps, along with possible REST APIs developed by the +organization. Next Steps diff --git a/env.example b/env.example index 0af13c25..815fcd4f 100644 --- a/env.example +++ b/env.example @@ -21,9 +21,6 @@ EMAIL_SUBJECT_PREFIX=[SODAR Core Dev] # LDAP settings ENABLE_LDAP=0 -# SAML settings -ENABLE_SAML=0 - # Projectroles settings PROJECTROLES_ENABLE_PROFILING=True PROJECTROLES_SITE_MODE=SOURCE diff --git a/example_project_app/plugins.py b/example_project_app/plugins.py index 749a6608..d6b7a4c4 100644 --- a/example_project_app/plugins.py +++ b/example_project_app/plugins.py @@ -103,7 +103,7 @@ class ProjectAppPlugin(ProjectModifyPluginMixin, ProjectAppPluginPoint): 'default': False, 'description': 'Example global boolean project setting', 'user_modifiable': True, - 'local': False, + 'global': True, }, 'project_json_setting': { 'scope': SODAR_CONSTANTS['APP_SETTING_SCOPE_PROJECT'], @@ -272,7 +272,7 @@ class ProjectAppPlugin(ProjectModifyPluginMixin, ProjectAppPluginPoint): 'options': get_example_setting_options, 'description': 'Example callable project user setting with options', }, - 'project_category_bool_setting': { + 'category_bool_setting': { 'scope': SODAR_CONSTANTS['APP_SETTING_SCOPE_PROJECT'], 'type': 'BOOLEAN', 'label': 'Category boolean setting', diff --git a/example_project_app/tests/test_permissions.py b/example_project_app/tests/test_permissions.py index 34eedc21..ad6908b1 100644 --- a/example_project_app/tests/test_permissions.py +++ b/example_project_app/tests/test_permissions.py @@ -4,14 +4,14 @@ from django.urls import reverse # Projectroles dependency -from projectroles.tests.test_permissions import TestProjectPermissionBase +from projectroles.tests.test_permissions import ProjectPermissionTestBase from projectroles.tests.test_models import AppSettingMixin # Filesfolders dependency from filesfolders.tests.test_models import FolderMixin -class TestExampleView(FolderMixin, AppSettingMixin, TestProjectPermissionBase): +class TestExampleView(FolderMixin, AppSettingMixin, ProjectPermissionTestBase): """Permission tests for ExampleView""" def test_get(self): diff --git a/example_project_app/tests/test_views.py b/example_project_app/tests/test_views.py index 1d5eafba..126e690b 100644 --- a/example_project_app/tests/test_views.py +++ b/example_project_app/tests/test_views.py @@ -5,7 +5,7 @@ # Projectroles dependency from projectroles.models import SODAR_CONSTANTS from projectroles.tests.test_models import ProjectMixin, RoleAssignmentMixin -from projectroles.tests.test_views import TestViewsBase +from projectroles.tests.test_views import ViewTestBase # Filesfolders dependency from filesfolders.tests.test_models import FolderMixin @@ -16,7 +16,7 @@ class TestExampleView( - FolderMixin, ProjectMixin, RoleAssignmentMixin, TestViewsBase + FolderMixin, ProjectMixin, RoleAssignmentMixin, ViewTestBase ): """Tests for the example view""" diff --git a/example_project_app/urls.py b/example_project_app/urls.py index 3f3e6d8c..e6778d01 100644 --- a/example_project_app/urls.py +++ b/example_project_app/urls.py @@ -1,5 +1,4 @@ -from django.conf.urls import url -from django.urls import path +from django.urls import path, re_path from example_project_app import views, views_api @@ -10,14 +9,14 @@ # NOTE: If referring to a model from another app, notation is "app__model" urls = [ - url( - regex=r'^(?P[0-9a-f-]+)$', + re_path( + r'^(?P[0-9a-f-]+)$', view=views.ExampleView.as_view(), name='example', ), # Example view with model from an external app - url( - regex=r'^ext/(?P[0-9a-f-]+)$', + re_path( + r'^ext/(?P[0-9a-f-]+)$', view=views.ExampleView.as_view(), name='example_ext_model', ), @@ -36,8 +35,8 @@ ] urls_api = [ - url( - regex=r'^api/hello/(?P[0-9a-f-]+)$', + re_path( + r'^api/hello/(?P[0-9a-f-]+)$', view=views_api.HelloExampleProjectAPIView.as_view(), name='example_api_hello', ) diff --git a/example_project_app/views_api.py b/example_project_app/views_api.py index 8a90be0c..0f660b24 100644 --- a/example_project_app/views_api.py +++ b/example_project_app/views_api.py @@ -1,16 +1,40 @@ """Example REST API views for SODAR Core""" from rest_framework import status +from rest_framework.renderers import JSONRenderer from rest_framework.response import Response +from rest_framework.versioning import AcceptHeaderVersioning from rest_framework.views import APIView # Projectroles dependency -from projectroles.views_api import ( - SODARAPIGenericProjectMixin, -) +from projectroles.views_api import SODARAPIGenericProjectMixin -class HelloExampleProjectAPIView(SODARAPIGenericProjectMixin, APIView): +EXAMPLE_API_MEDIA_TYPE = 'application/vnd.bihealth.sodar-core.example+json' +EXAMPLE_API_DEFAULT_VERSION = '1.0' +EXAMPLE_API_ALLOWED_VERSIONS = ['1.0'] + + +class ExampleAPIVersioningMixin: + """ + Example API view versioning mixin for overriding media type and accepted + versions. + """ + + class ExampleAPIRenderer(JSONRenderer): + media_type = EXAMPLE_API_MEDIA_TYPE + + class ExampleAPIVersioning(AcceptHeaderVersioning): + allowed_versions = EXAMPLE_API_DEFAULT_VERSION + default_version = EXAMPLE_API_ALLOWED_VERSIONS + + renderer_classes = [ExampleAPIRenderer] + versioning_class = ExampleAPIVersioning + + +class HelloExampleProjectAPIView( + ExampleAPIVersioningMixin, SODARAPIGenericProjectMixin, APIView +): """ Example API view with a project scope. diff --git a/example_site/templates/include/_login_extend.html b/example_site/templates/include/_login_extend.html index c79ddf82..1f696551 100644 --- a/example_site/templates/include/_login_extend.html +++ b/example_site/templates/include/_login_extend.html @@ -1,5 +1,5 @@ {# Place extended login view content into this template #} -
+
Extended login view content goes here. Add the template include/_login_extend.html to define extended content on your diff --git a/example_site/templates/include/_login_oidc.html b/example_site/templates/include/_login_oidc.html new file mode 100644 index 00000000..e2bb3b2c --- /dev/null +++ b/example_site/templates/include/_login_oidc.html @@ -0,0 +1,7 @@ +{# Place custom OpenIDC login link and/or info into this template #} + + + OpenID Connect Login + diff --git a/filesfolders/migrations/0001_squashed_0005_alter_foreignkey_fields.py b/filesfolders/migrations/0001_squashed_0005_alter_foreignkey_fields.py new file mode 100644 index 00000000..b637a15c --- /dev/null +++ b/filesfolders/migrations/0001_squashed_0005_alter_foreignkey_fields.py @@ -0,0 +1,328 @@ +# Generated by Django 4.2.14 on 2024-07-16 09:26 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + replaces = [ + ('filesfolders', '0001_initial'), + ('filesfolders', '0002_auto_20180411_1758'), + ('filesfolders', '0003_rename_uuid'), + ('filesfolders', '0004_update_uuid'), + ('filesfolders', '0005_alter_foreignkey_fields'), + ] + + initial = True + + dependencies = [ + ('projectroles', '0028_populate_finder_role'), + ('projectroles', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='FileData', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ('bytes', models.TextField()), + ('file_name', models.CharField(max_length=255)), + ('content_type', models.CharField(max_length=255)), + ], + ), + migrations.CreateModel( + name='Folder', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'name', + models.CharField(help_text='Name for the object', max_length=255), + ), + ( + 'date_modified', + models.DateTimeField( + auto_now=True, help_text='DateTime of last modification' + ), + ), + ( + 'flag', + models.CharField( + blank=True, + choices=[ + ('FLAG', 'Flagged'), + ('FLAG_HEART', 'Flagged (Heart)'), + ('IMPORTANT', 'Important'), + ('REVOKED', 'Revoked'), + ('SUPERSEDED', 'Superseded'), + ], + help_text='Flag for highlighting the item (optional)', + max_length=64, + null=True, + ), + ), + ( + 'description', + models.CharField( + blank=True, help_text='Description (optional)', max_length=255 + ), + ), + ( + 'sodar_uuid', + models.UUIDField( + default=uuid.uuid4, + help_text='Filesfolders SODAR UUID', + unique=True, + ), + ), + ( + 'folder', + models.ForeignKey( + blank=True, + help_text='Folder under which object exists (null if root folder)', + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='%(app_label)s_%(class)s_children', + to='filesfolders.folder', + ), + ), + ( + 'owner', + models.ForeignKey( + help_text='User who owns the object', + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + 'project', + models.ForeignKey( + help_text='Project in which the object belongs', + on_delete=django.db.models.deletion.CASCADE, + related_name='%(app_label)s_%(class)s_objects', + to='projectroles.project', + ), + ), + ], + options={ + 'ordering': ['project', 'name'], + 'unique_together': {('project', 'folder', 'name')}, + }, + ), + migrations.CreateModel( + name='File', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'name', + models.CharField(help_text='Name for the object', max_length=255), + ), + ( + 'date_modified', + models.DateTimeField( + auto_now=True, help_text='DateTime of last modification' + ), + ), + ( + 'flag', + models.CharField( + blank=True, + choices=[ + ('FLAG', 'Flagged'), + ('FLAG_HEART', 'Flagged (Heart)'), + ('IMPORTANT', 'Important'), + ('REVOKED', 'Revoked'), + ('SUPERSEDED', 'Superseded'), + ], + help_text='Flag for highlighting the item (optional)', + max_length=64, + null=True, + ), + ), + ( + 'description', + models.CharField( + blank=True, help_text='Description (optional)', max_length=255 + ), + ), + ( + 'sodar_uuid', + models.UUIDField( + default=uuid.uuid4, + help_text='Filesfolders SODAR UUID', + unique=True, + ), + ), + ( + 'file', + models.FileField( + blank=True, + help_text='Uploaded file', + null=True, + upload_to='filesfolders.FileData/bytes/file_name/content_type', + ), + ), + ( + 'public_url', + models.BooleanField( + default=False, + help_text='Allow providing a public URL for the file', + ), + ), + ( + 'secret', + models.CharField( + help_text='Secret string for creating public URL', + max_length=255, + unique=True, + ), + ), + ( + 'folder', + models.ForeignKey( + blank=True, + help_text='Folder under which object exists (null if root folder)', + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='%(app_label)s_%(class)s_children', + to='filesfolders.folder', + ), + ), + ( + 'owner', + models.ForeignKey( + help_text='User who owns the object', + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + 'project', + models.ForeignKey( + help_text='Project in which the object belongs', + on_delete=django.db.models.deletion.CASCADE, + related_name='%(app_label)s_%(class)s_objects', + to='projectroles.project', + ), + ), + ], + options={ + 'ordering': ['folder', 'name'], + 'unique_together': {('project', 'folder', 'name')}, + }, + ), + migrations.CreateModel( + name='HyperLink', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'name', + models.CharField(help_text='Name for the object', max_length=255), + ), + ( + 'date_modified', + models.DateTimeField( + auto_now=True, help_text='DateTime of last modification' + ), + ), + ( + 'flag', + models.CharField( + blank=True, + choices=[ + ('FLAG', 'Flagged'), + ('FLAG_HEART', 'Flagged (Heart)'), + ('IMPORTANT', 'Important'), + ('REVOKED', 'Revoked'), + ('SUPERSEDED', 'Superseded'), + ], + help_text='Flag for highlighting the item (optional)', + max_length=64, + null=True, + ), + ), + ( + 'description', + models.CharField( + blank=True, help_text='Description (optional)', max_length=255 + ), + ), + ( + 'sodar_uuid', + models.UUIDField( + default=uuid.uuid4, + help_text='Filesfolders SODAR UUID', + unique=True, + ), + ), + ('url', models.URLField(help_text='URL for the link', max_length=2000)), + ( + 'folder', + models.ForeignKey( + blank=True, + help_text='Folder under which object exists (null if root folder)', + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='%(app_label)s_%(class)s_children', + to='filesfolders.folder', + ), + ), + ( + 'owner', + models.ForeignKey( + help_text='User who owns the object', + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + 'project', + models.ForeignKey( + help_text='Project in which the object belongs', + on_delete=django.db.models.deletion.CASCADE, + related_name='%(app_label)s_%(class)s_objects', + to='projectroles.project', + ), + ), + ], + options={ + 'ordering': ['folder', 'name'], + 'unique_together': {('project', 'folder', 'name')}, + }, + ), + ] diff --git a/filesfolders/migrations/0005_alter_foreignkey_fields.py b/filesfolders/migrations/0005_alter_foreignkey_fields.py new file mode 100644 index 00000000..42df5aaf --- /dev/null +++ b/filesfolders/migrations/0005_alter_foreignkey_fields.py @@ -0,0 +1,81 @@ +# Generated by Django 4.2.11 on 2024-03-21 12:29 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("projectroles", "0028_populate_finder_role"), + ("filesfolders", "0004_update_uuid"), + ] + + operations = [ + migrations.AlterField( + model_name="file", + name="folder", + field=models.ForeignKey( + blank=True, + help_text="Folder under which object exists (null if root folder)", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(app_label)s_%(class)s_children", + to="filesfolders.folder", + ), + ), + migrations.AlterField( + model_name="file", + name="project", + field=models.ForeignKey( + help_text="Project in which the object belongs", + on_delete=django.db.models.deletion.CASCADE, + related_name="%(app_label)s_%(class)s_objects", + to="projectroles.project", + ), + ), + migrations.AlterField( + model_name="folder", + name="folder", + field=models.ForeignKey( + blank=True, + help_text="Folder under which object exists (null if root folder)", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(app_label)s_%(class)s_children", + to="filesfolders.folder", + ), + ), + migrations.AlterField( + model_name="folder", + name="project", + field=models.ForeignKey( + help_text="Project in which the object belongs", + on_delete=django.db.models.deletion.CASCADE, + related_name="%(app_label)s_%(class)s_objects", + to="projectroles.project", + ), + ), + migrations.AlterField( + model_name="hyperlink", + name="folder", + field=models.ForeignKey( + blank=True, + help_text="Folder under which object exists (null if root folder)", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(app_label)s_%(class)s_children", + to="filesfolders.folder", + ), + ), + migrations.AlterField( + model_name="hyperlink", + name="project", + field=models.ForeignKey( + help_text="Project in which the object belongs", + on_delete=django.db.models.deletion.CASCADE, + related_name="%(app_label)s_%(class)s_objects", + to="projectroles.project", + ), + ), + ] diff --git a/filesfolders/plugins.py b/filesfolders/plugins.py index 26167e08..2398e9f4 100644 --- a/filesfolders/plugins.py +++ b/filesfolders/plugins.py @@ -3,7 +3,11 @@ # Projectroles dependency from projectroles.models import SODAR_CONSTANTS -from projectroles.plugins import ProjectAppPluginPoint +from projectroles.plugins import ( + ProjectAppPluginPoint, + PluginObjectLink, + PluginSearchResult, +) from filesfolders.models import File, Folder, HyperLink from filesfolders.urls import urlpatterns @@ -106,34 +110,34 @@ class ProjectAppPlugin(ProjectAppPluginPoint): def get_object_link(self, model_str, uuid): """ - Return the URL for referring to a object used by the app, along with a - label to be shown to the user for linking. + Return URL referring to an object used by the app, along with a name to + be shown to the user for linking. :param model_str: Object class (string) :param uuid: sodar_uuid of the referred object - :return: Dict or None if not found + :return: PluginObjectLink or None if not found """ obj = self.get_object(eval(model_str), uuid) if not obj: return None elif obj.__class__ == File: - return { - 'url': reverse( + return PluginObjectLink( + url=reverse( 'filesfolders:file_serve', kwargs={'file': obj.sodar_uuid, 'file_name': obj.name}, ), - 'label': obj.name, - 'blank': True, - } + name=obj.name, + blank=True, + ) elif obj.__class__ == Folder: - return { - 'url': reverse( + return PluginObjectLink( + url=reverse( 'filesfolders:list', kwargs={'folder': obj.sodar_uuid} ), - 'label': obj.name, - } + name=obj.name, + ) elif obj.__class__ == HyperLink: - return {'url': obj.url, 'label': obj.name, 'blank': True} + return PluginObjectLink(url=obj.url, name=obj.name, blank=True) return None def search(self, search_terms, user, search_type=None, keywords=None): @@ -146,10 +150,9 @@ def search(self, search_terms, user, search_type=None, keywords=None): :param user: User object for user initiating the search :param search_type: String :param keywords: List (optional) - :return: Dict + :return: List of PluginSearchResult objects """ items = [] - if not search_type: files = File.objects.find(search_terms, keywords) folders = Folder.objects.find(search_terms, keywords) @@ -164,21 +167,19 @@ def search(self, search_terms, user, search_type=None, keywords=None): items = HyperLink.objects.find(search_terms, keywords).order_by( 'name' ) - if items: items = [ x for x in items if user.has_perm('filesfolders.view_data', x.project) ] - - return { - 'all': { - 'title': 'Files, Folders and Links', - 'search_types': ['file', 'folder', 'link'], - 'items': items, - } - } + ret = PluginSearchResult( + category='all', + title='Files, Folders and Links', + search_types=['file', 'folder', 'link'], + items=items, + ) + return [ret] def get_statistics(self): return { diff --git a/filesfolders/templates/filesfolders/_search_results.html b/filesfolders/templates/filesfolders/_search_results.html index 7da53da0..6c06ddd0 100644 --- a/filesfolders/templates/filesfolders/_search_results.html +++ b/filesfolders/templates/filesfolders/_search_results.html @@ -47,8 +47,8 @@ {% if search_results.all.items|length > 0 %} {% include 'projectroles/_search_header.html' with search_title=search_results.all.title result_count=search_results.all.items|length %} - - +
@@ -66,6 +66,5 @@ {% endfor %}
Name
- {% include 'projectroles/_search_footer.html' %} {% endif %} diff --git a/filesfolders/tests/test_models.py b/filesfolders/tests/test_models.py index 84f32a4a..b644172d 100644 --- a/filesfolders/tests/test_models.py +++ b/filesfolders/tests/test_models.py @@ -102,7 +102,7 @@ def make_hyperlink( class TestFolder(FolderMixin, ProjectMixin, HyperLinkMixin, TestCase): - """Tests for model.Folder""" + """Tests for Folder""" def setUp(self): # Make owner user @@ -234,7 +234,7 @@ def test_has_in_path_false(self): class TestFile(FileMixin, FolderMixin, ProjectMixin, TestCase): - """Tests for model.File""" + """Tests for File""" def setUp(self): # Make owner user @@ -332,7 +332,7 @@ def test_file_deletion(self): class TestHyperLink( FileMixin, FolderMixin, ProjectMixin, HyperLinkMixin, TestCase ): - """Tests for model.File""" + """Tests for HyperLink""" def setUp(self): # Make owner user diff --git a/filesfolders/tests/test_permissions.py b/filesfolders/tests/test_permissions.py index 7da5cc5f..cabb47ed 100644 --- a/filesfolders/tests/test_permissions.py +++ b/filesfolders/tests/test_permissions.py @@ -6,7 +6,7 @@ # Projectroles dependency from projectroles.app_settings import AppSettingAPI from projectroles.models import SODAR_CONSTANTS -from projectroles.tests.test_permissions import TestProjectPermissionBase +from projectroles.tests.test_permissions import ProjectPermissionTestBase from filesfolders.tests.test_models import ( FileMixin, @@ -78,7 +78,7 @@ def make_test_link(self): class TestProjectFileViewPermissions( - FilesfoldersPermissionTestMixin, TestProjectPermissionBase + FilesfoldersPermissionTestMixin, ProjectPermissionTestBase ): """Tests for ProjectFileView permissions""" @@ -138,7 +138,7 @@ def test_get_archive(self): class TestFolderCreateView( - FilesfoldersPermissionTestMixin, TestProjectPermissionBase + FilesfoldersPermissionTestMixin, ProjectPermissionTestBase ): """Tests for FolderCreateView view permissions""" @@ -224,7 +224,7 @@ def test_get_category(self): class TestFolderUpdateView( - FilesfoldersPermissionTestMixin, TestProjectPermissionBase + FilesfoldersPermissionTestMixin, ProjectPermissionTestBase ): """Tests for FolderUpdateView permissions""" @@ -289,7 +289,7 @@ def test_get_archive(self): class TestFolderDeleteView( - FilesfoldersPermissionTestMixin, TestProjectPermissionBase + FilesfoldersPermissionTestMixin, ProjectPermissionTestBase ): """Tests for FolderDeleteView permissions""" @@ -354,9 +354,9 @@ def test_get_archive(self): class TestFileCreateView( - FilesfoldersPermissionTestMixin, TestProjectPermissionBase + FilesfoldersPermissionTestMixin, ProjectPermissionTestBase ): - """Tests FileCreateView permissions""" + """Tests for FileCreateView permissions""" def setUp(self): super().setUp() @@ -440,7 +440,7 @@ def test_get_category(self): class TestFileUpdateView( - FilesfoldersPermissionTestMixin, TestProjectPermissionBase + FilesfoldersPermissionTestMixin, ProjectPermissionTestBase ): """Tests for FileUpdateView permissions""" @@ -504,7 +504,7 @@ def test_get_archive(self): class TestFileDeleteView( - FilesfoldersPermissionTestMixin, TestProjectPermissionBase + FilesfoldersPermissionTestMixin, ProjectPermissionTestBase ): """Tests for FileDeleteView permissions""" @@ -568,7 +568,7 @@ def test_get_archive(self): class TestFilePublicLinkView( - FilesfoldersPermissionTestMixin, TestProjectPermissionBase + FilesfoldersPermissionTestMixin, ProjectPermissionTestBase ): """Tests for FilePublicLinkView permissions""" @@ -635,7 +635,7 @@ def test_get_archive(self): class TestFileServeView( - FilesfoldersPermissionTestMixin, TestProjectPermissionBase + FilesfoldersPermissionTestMixin, ProjectPermissionTestBase ): """Tests for FileServeView permissions""" @@ -696,7 +696,7 @@ def test_get_archive(self): class TestFileServePublicView( - FilesfoldersPermissionTestMixin, TestProjectPermissionBase + FilesfoldersPermissionTestMixin, ProjectPermissionTestBase ): """Tests for FileServePublicView permissions""" @@ -782,7 +782,7 @@ def test_get_disabled(self): class TestHyperLinkCreateView( - FilesfoldersPermissionTestMixin, TestProjectPermissionBase + FilesfoldersPermissionTestMixin, ProjectPermissionTestBase ): """Tests for HyperLinkCreateView permissions""" @@ -868,7 +868,7 @@ def test_get_category(self): class TestHyperLinkUpdateView( - FilesfoldersPermissionTestMixin, TestProjectPermissionBase + FilesfoldersPermissionTestMixin, ProjectPermissionTestBase ): """Tests for HyperLinkUpdateView permissions""" @@ -933,7 +933,7 @@ def test_get_archive(self): class TestHyperLinkDeleteView( - FilesfoldersPermissionTestMixin, TestProjectPermissionBase + FilesfoldersPermissionTestMixin, ProjectPermissionTestBase ): """Tests for HyperLinkDeleteView permissions""" @@ -998,7 +998,7 @@ def test_get_archive(self): class TestBatchEditView( - FilesfoldersPermissionTestMixin, TestProjectPermissionBase + FilesfoldersPermissionTestMixin, ProjectPermissionTestBase ): """Tests for BatchEditView permissions""" diff --git a/filesfolders/tests/test_permissions_api.py b/filesfolders/tests/test_permissions_api.py index 0e902bd7..5d0f4098 100644 --- a/filesfolders/tests/test_permissions_api.py +++ b/filesfolders/tests/test_permissions_api.py @@ -7,13 +7,14 @@ from django.urls import reverse # Projectroles dependency -from projectroles.tests.test_permissions_api import ( - TestCoreProjectAPIPermissionBase, -) +from projectroles.tests.test_permissions_api import ProjectAPIPermissionTestBase from filesfolders.models import File, HyperLink - from filesfolders.tests.test_permissions import FilesfoldersPermissionTestMixin +from filesfolders.views_api import ( + FILESFOLDERS_API_MEDIA_TYPE, + FILESFOLDERS_API_DEFAULT_VERSION, +) # Local constants @@ -23,10 +24,17 @@ OBJ_UUID = uuid.uuid4() -class TestFolderListCreateAPIView( - FilesfoldersPermissionTestMixin, TestCoreProjectAPIPermissionBase +class FilesfoldersAPIPermissionTestBase( + FilesfoldersPermissionTestMixin, ProjectAPIPermissionTestBase ): - """Tests FolderListCreateAPIView permissions""" + """Base class for filesfolders REST API view permission tests""" + + media_type = FILESFOLDERS_API_MEDIA_TYPE + api_version = FILESFOLDERS_API_DEFAULT_VERSION + + +class TestFolderListCreateAPIView(FilesfoldersAPIPermissionTestBase): + """Tests for FolderListCreateAPIView permissions""" def setUp(self): super().setUp() @@ -183,10 +191,8 @@ def test_post_archive(self): ) -class TestFolderRetrieveUpdateDestroyAPIView( - FilesfoldersPermissionTestMixin, TestCoreProjectAPIPermissionBase -): - """Tests FolderRetrieveUpdateDestroyAPIView permissions""" +class TestFolderRetrieveUpdateDestroyAPIView(FilesfoldersAPIPermissionTestBase): + """Tests for FolderRetrieveUpdateDestroyAPIView permissions""" def _make_folder(self): folder = self.make_test_folder() @@ -494,10 +500,8 @@ def test_delete_archive(self): ) -class TestFileListCreateAPIView( - FilesfoldersPermissionTestMixin, TestCoreProjectAPIPermissionBase -): - """Tests FileListCreateAPIView permissions""" +class TestFileListCreateAPIView(FilesfoldersAPIPermissionTestBase): + """Tests for FileListCreateAPIView permissions""" def _make_post_data(self): self.post_data = { @@ -724,10 +728,8 @@ def test_post_archive(self): ) -class TestFileRetrieveUpdateDestroyAPIView( - FilesfoldersPermissionTestMixin, TestCoreProjectAPIPermissionBase -): - """Tests FileRetrieveUpdateDestroyAPIView permissions""" +class TestFileRetrieveUpdateDestroyAPIView(FilesfoldersAPIPermissionTestBase): + """Tests for FileRetrieveUpdateDestroyAPIView permissions""" def _make_file(self): file = self.make_test_file() @@ -1142,10 +1144,8 @@ def test_delete_archive(self): ) -class TestFileServeAPIView( - FilesfoldersPermissionTestMixin, TestCoreProjectAPIPermissionBase -): - """Tests FileServeAPIView permissions""" +class TestFileServeAPIView(FilesfoldersAPIPermissionTestBase): + """Tests for FileServeAPIView permissions""" def setUp(self): super().setUp() @@ -1204,10 +1204,8 @@ def test_get_archive(self): self.assert_response_api(self.url, self.user_no_roles, 200) -class TestHyperLinkListCreateAPIView( - FilesfoldersPermissionTestMixin, TestCoreProjectAPIPermissionBase -): - """Tests HyperLinkListCreateAPIView permissions""" +class TestHyperLinkListCreateAPIView(FilesfoldersAPIPermissionTestBase): + """Tests for HyperLinkListCreateAPIView permissions""" @classmethod def _cleanup(cls): @@ -1383,9 +1381,9 @@ def test_post_archive(self): class TestHyperLinkRetrieveUpdateDestroyAPIView( - FilesfoldersPermissionTestMixin, TestCoreProjectAPIPermissionBase + FilesfoldersAPIPermissionTestBase ): - """Tests HyperLinkRetrieveUpdateDestroyAPIView permissions""" + """Tests for HyperLinkRetrieveUpdateDestroyAPIView permissions""" def _make_link(self): link = self.make_test_link() diff --git a/filesfolders/tests/test_plugins.py b/filesfolders/tests/test_plugins.py index 81f93c73..a727d7b1 100644 --- a/filesfolders/tests/test_plugins.py +++ b/filesfolders/tests/test_plugins.py @@ -3,11 +3,12 @@ import uuid from django.urls import reverse + from test_plus.test import TestCase # Projectroles dependency from projectroles.models import SODAR_CONSTANTS -from projectroles.plugins import ProjectAppPluginPoint +from projectroles.plugins import ProjectAppPluginPoint, PluginObjectLink from projectroles.tests.test_models import ( ProjectMixin, RoleMixin, @@ -47,7 +48,7 @@ class TestPlugins( RoleAssignmentMixin, TestCase, ): - """Test filesfolders plugin""" + """Tests for filesfolders project plugin""" def setUp(self): # Init superuser @@ -118,9 +119,10 @@ def test_get_object_link_file(self): kwargs={'file': self.file.sodar_uuid, 'file_name': self.file.name}, ) ret = plugin.get_object_link('File', self.file.sodar_uuid) - self.assertEqual(ret['url'], url) - self.assertEqual(ret['label'], self.file.name) - self.assertEqual(ret['blank'], True) + self.assertIsInstance(ret, PluginObjectLink) + self.assertEqual(ret.url, url) + self.assertEqual(ret.name, self.file.name) + self.assertEqual(ret.blank, True) def test_get_object_link_folder(self): """Test get_object_link() for a Folder object""" @@ -129,16 +131,17 @@ def test_get_object_link_folder(self): 'filesfolders:list', kwargs={'folder': self.folder.sodar_uuid} ) ret = plugin.get_object_link('Folder', self.folder.sodar_uuid) - self.assertEqual(ret['url'], url) - self.assertEqual(ret['label'], self.folder.name) + self.assertEqual(ret.url, url) + self.assertEqual(ret.name, self.folder.name) + self.assertEqual(ret.blank, False) def test_get_object_link_hyperlink(self): """Test get_object_link() for a HyperLink object""" plugin = ProjectAppPluginPoint.get_plugin(PLUGIN_NAME) ret = plugin.get_object_link('HyperLink', self.hyperlink.sodar_uuid) - self.assertEqual(ret['url'], self.hyperlink.url) - self.assertEqual(ret['label'], self.hyperlink.name) - self.assertEqual(ret['blank'], True) + self.assertEqual(ret.url, self.hyperlink.url) + self.assertEqual(ret.name, self.hyperlink.name) + self.assertEqual(ret.blank, True) def test_get_object_link_fail(self): """Test get_object_link() with a non-existent object""" diff --git a/filesfolders/tests/test_ui.py b/filesfolders/tests/test_ui.py index 1411b268..6e408943 100644 --- a/filesfolders/tests/test_ui.py +++ b/filesfolders/tests/test_ui.py @@ -7,7 +7,7 @@ # Projectroles dependency from projectroles.app_settings import AppSettingAPI from projectroles.models import AppSetting, SODAR_CONSTANTS -from projectroles.tests.test_ui import TestUIBase +from projectroles.tests.test_ui import UITestBase from projectroles.utils import build_secret from filesfolders.tests.test_models import ( @@ -32,8 +32,8 @@ APP_NAME = 'filesfolders' -class TestListView(FolderMixin, FileMixin, HyperLinkMixin, TestUIBase): - """Tests for filesfolders main file list view UI""" +class TestProjectFileView(FolderMixin, FileMixin, HyperLinkMixin, UITestBase): + """Tests for ProjectFileView UI""" def setUp(self): super().setUp() @@ -388,7 +388,7 @@ def test_item_flags(self): ) -class TestSearch(FolderMixin, FileMixin, HyperLinkMixin, TestUIBase): +class TestSearch(FolderMixin, FileMixin, HyperLinkMixin, UITestBase): """Tests for the project search UI functionalities""" def setUp(self): @@ -561,7 +561,7 @@ def test_search_type_nonexisting(self): self.assert_element_count(expected, url, 'sodar-ff-search-item') -class TestHomeView(TestUIBase): +class TestHomeView(UITestBase): """Tests for appearance of filesfolders specific data in the home view""" def test_project_list(self): diff --git a/filesfolders/tests/test_views.py b/filesfolders/tests/test_views.py index 044f0e77..f109ce56 100644 --- a/filesfolders/tests/test_views.py +++ b/filesfolders/tests/test_views.py @@ -44,9 +44,10 @@ ZIP_PATH = TEST_DATA_PATH + 'unpack_test.zip' ZIP_PATH_NO_FILES = TEST_DATA_PATH + 'no_files.zip' INVALID_UUID = '11111111-1111-1111-1111-111111111111' +EMPTY_FILE_MSG = 'The submitted file is empty.' -class TestViewsBaseMixin( +class FilesfoldersViewTestMixin( ProjectMixin, RoleMixin, RoleAssignmentMixin, @@ -111,18 +112,18 @@ def setUp(self): ) -class TestViewsBase(TestViewsBaseMixin, TestCase): - """Base class for view testing""" +class ViewTestBase(FilesfoldersViewTestMixin, TestCase): + """Base class for filesfolders view testing""" -# List View -------------------------------------------------------------------- +# Project Files View ----------------------------------------------------------- -class TestListView(TestViewsBase): - """Tests for the file list view""" +class TestProjectFileView(ViewTestBase): + """Tests for ProjectFileView""" - def test_render(self): - """Test rendering project root view""" + def test_get_root(self): + """Test ProjectFileView GET in root""" with self.login(self.user): response = self.client.get( reverse( @@ -131,13 +132,13 @@ def test_render(self): ) ) self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['project'].pk, self.project.pk) + self.assertEqual(response.context['project'], self.project) self.assertIsNotNone(response.context['folders']) self.assertIsNotNone(response.context['files']) self.assertIsNotNone(response.context['links']) - def test_render_not_found(self): - """Test rendering with invalid project UUID""" + def test_get_invalid_uuid(self): + """Test GET with invalid project UUID""" with self.login(self.user): response = self.client.get( reverse( @@ -147,8 +148,8 @@ def test_render_not_found(self): ) self.assertEqual(response.status_code, 404) - def test_render_in_folder(self): - """Test rendering folder view within the project""" + def test_get_folder(self): + """Test GET under folder""" with self.login(self.user): response = self.client.get( reverse( @@ -157,13 +158,13 @@ def test_render_in_folder(self): ) ) self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['project'].pk, self.project.pk) + self.assertEqual(response.context['project'], self.project) self.assertIsNotNone(response.context['folder_breadcrumb']) self.assertIsNotNone(response.context['files']) self.assertIsNotNone(response.context['links']) - def test_render_with_readme_txt(self): - """Test rendering with a plaintext readme file""" + def test_get_readme_txt(self): + """Test GET with plaintext readme file""" self.readme_file = self.make_file( name='readme.txt', file_name='readme.txt', @@ -188,69 +189,219 @@ def test_render_with_readme_txt(self): self.assertEqual(response.context['readme_mime'], 'text/plain') -# File Views ------------------------------------------------------------------- +# Folder Views ----------------------------------------------------------------- -class TestFileCreateView(TestViewsBase): - """Tests for the File create view""" +class TestFolderCreateView(ViewTestBase): + """Tests for FolderCreateView""" + + def setUp(self): + super().setUp() + self.url = reverse( + 'filesfolders:folder_create', + kwargs={'project': self.project.sodar_uuid}, + ) + self.url_folder = reverse( + 'filesfolders:folder_create', + kwargs={'folder': self.folder.sodar_uuid}, + ) - def test_render(self): - """Test rendering File create view""" + def test_get(self): + """Test FolderCreateView GET""" with self.login(self.user): - response = self.client.get( - reverse( - 'filesfolders:file_create', - kwargs={'project': self.project.sodar_uuid}, - ) - ) + response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['project'].pk, self.project.pk) + self.assertEqual(response.context['project'], self.project) - def test_render_in_folder(self): - """Test rendering under a folder""" + def test_get_invalid_uuid(self): + """Test GET with invalid project UUID""" with self.login(self.user): response = self.client.get( reverse( - 'filesfolders:file_create', - kwargs={'folder': self.folder.sodar_uuid}, + 'filesfolders:folder_create', + kwargs={'project': INVALID_UUID}, ) ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['project'].pk, self.project.pk) - self.assertEqual(response.context['folder'].pk, self.folder.pk) + self.assertEqual(response.status_code, 404) + + def test_get_folder(self): + """Test GET under folder""" + with self.login(self.user): + response = self.client.get(self.url_folder) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['project'], self.project) + self.assertEqual(response.context['folder'], self.folder) + + def test_post(self): + """Test POST to create folder""" + self.assertEqual(Folder.objects.all().count(), 1) + post_data = { + 'name': 'new_folder', + 'folder': '', + 'description': '', + 'flag': '', + } + with self.login(self.user): + response = self.client.post(self.url, post_data) + + self.assertEqual(response.status_code, 302) + self.assertEqual( + response.url, + reverse( + 'filesfolders:list', + kwargs={'project': self.project.sodar_uuid}, + ), + ) + self.assertEqual(Folder.objects.all().count(), 2) + + def test_post_folder(self): + """Test POST under folder""" + self.assertEqual(Folder.objects.all().count(), 1) + post_data = { + 'name': 'new_folder', + 'folder': self.folder.sodar_uuid, + 'description': '', + 'flag': '', + } + with self.login(self.user): + response = self.client.post(self.url, post_data) + self.assertEqual(response.status_code, 302) + self.assertEqual( + response.url, + reverse( + 'filesfolders:list', + kwargs={'folder': self.folder.sodar_uuid}, + ), + ) + self.assertEqual(Folder.objects.all().count(), 2) - def test_render_not_found(self): - """Test rendering with invalid project UUID""" + def test_post_existing(self): + """Test POST with existing folder (should fail)""" + self.assertEqual(Folder.objects.all().count(), 1) + post_data = { + 'name': 'folder', + 'folder': '', + 'description': '', + 'flag': '', + } + with self.login(self.user): + response = self.client.post(self.url, post_data) + self.assertEqual(response.status_code, 200) + self.assertEqual(Folder.objects.all().count(), 1) + + +class TestFolderUpdateView(ViewTestBase): + """Tests for FolderUpdateView""" + + def setUp(self): + super().setUp() + self.url = reverse( + 'filesfolders:folder_update', + kwargs={'item': self.folder.sodar_uuid}, + ) + + def test_get(self): + """Test FolderUpdateView GET""" + with self.login(self.user): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['object'], self.folder) + + def test_get_invalid_uuid(self): + """Test GET with invalid folder UUID""" with self.login(self.user): response = self.client.get( reverse( - 'filesfolders:file_create', - kwargs={'project': INVALID_UUID}, + 'filesfolders:folder_update', + kwargs={'item': INVALID_UUID}, ) ) self.assertEqual(response.status_code, 404) - def test_create(self): - """Test file creation""" - self.assertEqual(File.objects.all().count(), 1) + def test_post(self): + """Test POST to update folder""" + self.assertEqual(Folder.objects.all().count(), 1) + post_data = { + 'name': 'renamed_folder', + 'folder': '', + 'description': 'updated description', + 'flag': '', + } + with self.login(self.user): + response = self.client.post(self.url, post_data) + + self.assertEqual(response.status_code, 302) + self.assertEqual( + response.url, + reverse( + 'filesfolders:list', + kwargs={'project': self.project.sodar_uuid}, + ), + ) + self.assertEqual(Folder.objects.all().count(), 1) + self.folder.refresh_from_db() + self.assertEqual(self.folder.name, 'renamed_folder') + self.assertEqual(self.folder.description, 'updated description') + + def test_post_existing(self): + """Test POST with existing folder name (should fail)""" + self.make_folder( + name='folder2', + project=self.project, + folder=None, + owner=self.user, + description='', + ) + self.assertEqual(Folder.objects.all().count(), 2) post_data = { - 'name': 'new_file.txt', - 'file': SimpleUploadedFile('new_file.txt', self.file_content), + 'name': 'folder2', 'folder': '', 'description': '', 'flag': '', - 'public_url': False, } with self.login(self.user): - response = self.client.post( + response = self.client.post(self.url, post_data) + + self.assertEqual(response.status_code, 200) + self.assertEqual(Folder.objects.all().count(), 2) + self.folder.refresh_from_db() + self.assertEqual(self.folder.name, 'folder') + + +class TestFolderDeleteView(ViewTestBase): + """Tests for FolderDeleteView""" + + def setUp(self): + super().setUp() + self.url = reverse( + 'filesfolders:folder_delete', + kwargs={'item': self.folder.sodar_uuid}, + ) + + def test_get(self): + """Test FolderDeleteView GET""" + with self.login(self.user): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['object'], self.folder) + + def test_get_invalid_uuid(self): + """Test GET with invalid folder UUID""" + with self.login(self.user): + response = self.client.get( reverse( - 'filesfolders:file_create', - kwargs={'project': self.project.sodar_uuid}, - ), - post_data, + 'filesfolders:folder_delete', + kwargs={'item': INVALID_UUID}, + ) ) + self.assertEqual(response.status_code, 404) + def test_post(self): + """Test POST to delete folder""" + self.assertEqual(Folder.objects.all().count(), 1) + with self.login(self.user): + response = self.client.post(self.url) self.assertEqual(response.status_code, 302) self.assertEqual( response.url, @@ -259,12 +410,81 @@ def test_create(self): kwargs={'project': self.project.sodar_uuid}, ), ) - self.assertEqual(File.objects.all().count(), 2) + self.assertEqual(Folder.objects.all().count(), 0) - def test_create_empty(self): - """Test empty file creation (should fail)""" + +# File Views ------------------------------------------------------------------- + + +class TestFileCreateView(ViewTestBase): + """Tests for FileCreateView""" + + def setUp(self): + super().setUp() + self.url = reverse( + 'filesfolders:file_create', + kwargs={'project': self.project.sodar_uuid}, + ) + self.url_folder = reverse( + 'filesfolders:file_create', + kwargs={'folder': self.folder.sodar_uuid}, + ) + self.url_list = reverse( + 'filesfolders:list', + kwargs={'project': self.project.sodar_uuid}, + ) + self.url_list_folder = reverse( + 'filesfolders:list', + kwargs={'folder': self.folder.sodar_uuid}, + ) + + def test_get(self): + """Test FileCreateView GET""" + with self.login(self.user): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['project'], self.project) + + def test_get_folder(self): + """Test GET under folder""" + with self.login(self.user): + response = self.client.get(self.url_folder) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['project'], self.project) + self.assertEqual(response.context['folder'], self.folder) + + def test_get_invalid_uuid(self): + """Test GET with invalid project UUID""" + with self.login(self.user): + response = self.client.get( + reverse( + 'filesfolders:file_create', + kwargs={'project': INVALID_UUID}, + ) + ) + self.assertEqual(response.status_code, 404) + + def test_post(self): + """Test POST to create file""" self.assertEqual(File.objects.all().count(), 1) + post_data = { + 'name': 'new_file.txt', + 'file': SimpleUploadedFile('new_file.txt', self.file_content), + 'folder': '', + 'description': '', + 'flag': '', + 'public_url': False, + } + with self.login(self.user): + response = self.client.post(self.url, post_data) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, self.url_list) + self.assertEqual(File.objects.all().count(), 2) + + def test_post_empty(self): + """Test POST with empty file (should fail)""" + self.assertEqual(File.objects.all().count(), 1) post_data = { 'name': 'new_file.txt', 'file': SimpleUploadedFile( @@ -276,28 +496,15 @@ def test_create_empty(self): 'public_url': False, } with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:file_create', - kwargs={'project': self.project.sodar_uuid}, - ), - post_data, - ) + response = self.client.post(self.url, post_data) self.assertEqual(response.status_code, 200) - self.assertTrue( - bytes( - '

' - 'The submitted file is empty.

'.encode('utf-8') - ) - in response.content - ) + self.assertIn(EMPTY_FILE_MSG, response.content.decode('utf-8')) self.assertEqual(File.objects.all().count(), 1) - def test_create_in_folder(self): - """Test file creation within a folder""" + def test_post_folder(self): + """Test POST under folder""" self.assertEqual(File.objects.all().count(), 1) - post_data = { 'name': 'new_file.txt', 'file': SimpleUploadedFile('new_file.txt', self.file_content), @@ -307,28 +514,15 @@ def test_create_in_folder(self): 'public_url': False, } with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:file_create', - kwargs={'project': self.project.sodar_uuid}, - ), - post_data, - ) + response = self.client.post(self.url_folder, post_data) self.assertEqual(response.status_code, 302) - self.assertEqual( - response.url, - reverse( - 'filesfolders:list', - kwargs={'folder': self.folder.sodar_uuid}, - ), - ) + self.assertEqual(response.url, self.url_list_folder) self.assertEqual(File.objects.all().count(), 2) - def test_create_existing(self): - """Test file create with an existing file name (should fail)""" + def test_post_existing(self): + """Test POST with existing file name (should fail)""" self.assertEqual(File.objects.all().count(), 1) - post_data = { 'name': 'file.txt', 'file': SimpleUploadedFile('file.txt', self.file_content_alt), @@ -338,18 +532,12 @@ def test_create_existing(self): 'public_url': False, } with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:file_create', - kwargs={'project': self.project.sodar_uuid}, - ), - post_data, - ) + response = self.client.post(self.url, post_data) self.assertEqual(response.status_code, 200) self.assertEqual(File.objects.all().count(), 1) - def test_unpack_archive(self): - """Test uploading a zip file to be unpacked""" + def test_post_unpack_archive(self): + """Test POST with zip archive to be unpacked""" self.assertEqual(File.objects.all().count(), 1) self.assertEqual(Folder.objects.all().count(), 1) @@ -364,22 +552,10 @@ def test_unpack_archive(self): 'unpack_archive': True, } with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:file_create', - kwargs={'project': self.project.sodar_uuid}, - ), - post_data, - ) + response = self.client.post(self.url, post_data) self.assertEqual(response.status_code, 302) - self.assertEqual( - response.url, - reverse( - 'filesfolders:list', - kwargs={'project': self.project.sodar_uuid}, - ), - ) + self.assertEqual(response.url, self.url_list) self.assertEqual(File.objects.all().count(), 3) self.assertEqual(Folder.objects.all().count(), 3) @@ -391,8 +567,8 @@ def test_unpack_archive(self): self.assertEqual(new_file2.folder, new_folder2) self.assertEqual(new_folder2.folder, new_folder1) - def test_unpack_archive_overwrite(self): - """Test unpacking a zip file with existing file (should fail)""" + def test_post_unpack_archive_overwrite(self): + """Test POST to unpack archive with existing file (should fail)""" ow_folder = self.make_folder( name='dir1', project=self.project, @@ -425,20 +601,14 @@ def test_unpack_archive_overwrite(self): 'unpack_archive': True, } with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:file_create', - kwargs={'project': self.project.sodar_uuid}, - ), - post_data, - ) + response = self.client.post(self.url, post_data) self.assertEqual(response.status_code, 200) self.assertEqual(File.objects.all().count(), 2) self.assertEqual(Folder.objects.all().count(), 2) - def test_unpack_archive_empty(self): - """Test unpacking a zip file with an empty archive (should fail)""" + def test_post_unpack_archive_empty(self): + """Test POST with empty archive (should fail)""" with open(ZIP_PATH_NO_FILES, 'rb') as zip_file: post_data = { 'name': 'no_files.zip', @@ -450,17 +620,11 @@ def test_unpack_archive_empty(self): 'unpack_archive': True, } with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:file_create', - kwargs={'project': self.project.sodar_uuid}, - ), - post_data, - ) + response = self.client.post(self.url, post_data) self.assertEqual(response.status_code, 200) - def test_upload_archive_existing(self): - """Test uploading a zip file with existing file (no unpack)""" + def test_post_archive_existing(self): + """Test POST to upload archive with existing file (no unpack)""" ow_folder = self.make_folder( name='dir1', project=self.project, @@ -493,36 +657,40 @@ def test_upload_archive_existing(self): 'unpack_archive': False, } with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:file_create', - kwargs={'project': self.project.sodar_uuid}, - ), - post_data, - ) + response = self.client.post(self.url, post_data) self.assertEqual(response.status_code, 302) self.assertEqual(File.objects.all().count(), 3) self.assertEqual(Folder.objects.all().count(), 2) -class TestFileUpdateView(TestViewsBase): - """Tests for the File update view""" +class TestFileUpdateView(ViewTestBase): + """Tests for FileUpdateView""" + + def setUp(self): + super().setUp() + self.url = reverse( + 'filesfolders:file_update', + kwargs={'item': self.file.sodar_uuid}, + ) + self.url_list = reverse( + 'filesfolders:list', + kwargs={'project': self.project.sodar_uuid}, + ) + self.url_list_folder = reverse( + 'filesfolders:list', + kwargs={'folder': self.folder.sodar_uuid}, + ) - def test_render(self): - """Test rendering of the File update view""" + def test_get(self): + """Test FileUpdateView GET""" with self.login(self.user): - response = self.client.get( - reverse( - 'filesfolders:file_update', - kwargs={'item': self.file.sodar_uuid}, - ) - ) + response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['object'].pk, self.file.pk) + self.assertEqual(response.context['object'], self.file) - def test_render_not_found(self): - """Test rendering with invalid file UUID""" + def test_get_invalid_uuid(self): + """Test GET with invalid file UUID""" with self.login(self.user): response = self.client.get( reverse( @@ -532,8 +700,8 @@ def test_render_not_found(self): ) self.assertEqual(response.status_code, 404) - def test_update(self): - """Test file update with different content""" + def test_post(self): + """Test POST to update file""" self.assertEqual(File.objects.all().count(), 1) self.assertEqual(self.file.file.read(), self.file_content) @@ -546,28 +714,16 @@ def test_update(self): 'public_url': False, } with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:file_update', - kwargs={'item': self.file.sodar_uuid}, - ), - post_data, - ) + response = self.client.post(self.url, post_data) self.assertEqual(response.status_code, 302) - self.assertEqual( - response.url, - reverse( - 'filesfolders:list', - kwargs={'project': self.project.sodar_uuid}, - ), - ) + self.assertEqual(response.url, self.url_list) self.assertEqual(File.objects.all().count(), 1) self.file.refresh_from_db() self.assertEqual(self.file.file.read(), self.file_content_alt) - def test_update_empty(self): - """Test file update with empty content""" + def test_post_empty(self): + """Test POST with empty file content""" self.assertEqual(File.objects.all().count(), 1) self.assertEqual(self.file.file.read(), self.file_content) @@ -580,28 +736,16 @@ def test_update_empty(self): 'public_url': False, } with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:file_update', - kwargs={'item': self.file.sodar_uuid}, - ), - post_data, - ) + response = self.client.post(self.url, post_data) self.assertEqual(response.status_code, 200) - self.assertTrue( - bytes( - '

' - 'The submitted file is empty.

'.encode('utf-8') - ) - in response.content - ) + self.assertIn(EMPTY_FILE_MSG, response.content.decode('utf-8')) self.assertEqual(File.objects.all().count(), 1) self.file.refresh_from_db() self.assertEqual(self.file.file.read(), self.file_content) - def test_update_existing(self): - """Test file update with file name that already exists (should fail)""" + def test_post_existing(self): + """Test POST with existing file name (should fail)""" self.make_file( name='file2.txt', file_name='file2.txt', @@ -624,21 +768,15 @@ def test_update_existing(self): 'public_url': False, } with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:file_update', - kwargs={'item': self.file.sodar_uuid}, - ), - post_data, - ) + response = self.client.post(self.url, post_data) self.assertEqual(response.status_code, 200) self.assertEqual(File.objects.all().count(), 2) self.file.refresh_from_db() self.assertEqual(self.file.file.read(), self.file_content) - def test_update_folder(self): - """Test moving file to a different folder""" + def test_post_move_folder(self): + """Test POST to move file to another folder""" post_data = { 'name': 'file.txt', 'folder': self.folder.sodar_uuid, @@ -647,27 +785,15 @@ def test_update_folder(self): 'public_url': False, } with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:file_update', - kwargs={'item': self.file.sodar_uuid}, - ), - post_data, - ) + response = self.client.post(self.url, post_data) self.assertEqual(response.status_code, 302) - self.assertEqual( - response.url, - reverse( - 'filesfolders:list', - kwargs={'folder': self.folder.sodar_uuid}, - ), - ) + self.assertEqual(response.url, self.url_list_folder) self.file.refresh_from_db() self.assertEqual(self.file.folder, self.folder) - def test_update_folder_existing(self): - """Test overwriting file in a different folder (should fail)""" + def test_post_move_folder_existing(self): + """Test POST to overwrite file in another folder (should fail)""" # Create file with same name in the target folder self.make_file( name='file.txt', @@ -689,13 +815,7 @@ def test_update_folder_existing(self): 'public_url': False, } with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:file_update', - kwargs={'item': self.file.sodar_uuid}, - ), - post_data, - ) + response = self.client.post(self.url, post_data) self.assertEqual(response.status_code, 200) self.assertEqual(File.objects.all().count(), 2) @@ -703,480 +823,203 @@ def test_update_folder_existing(self): self.assertEqual(self.file.folder, None) -class TestFileDeleteView(TestViewsBase): - """Tests for the File delete view""" - - def test_render(self): - """Test rendering of the File delete view""" - with self.login(self.user): - response = self.client.get( - reverse( - 'filesfolders:file_delete', - kwargs={'item': self.file.sodar_uuid}, - ) - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['object'].pk, self.file.pk) - - def test_render_not_found(self): - """Test rendering with invalid file UUID""" - with self.login(self.user): - response = self.client.get( - reverse( - 'filesfolders:file_delete', - kwargs={'item': INVALID_UUID}, - ) - ) - self.assertEqual(response.status_code, 404) - - def test_post(self): - """Test deleting a File""" - self.assertEqual(File.objects.all().count(), 1) - with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:file_delete', - kwargs={'item': self.file.sodar_uuid}, - ) - ) - self.assertEqual(response.status_code, 302) - self.assertEqual( - response.url, - reverse( - 'filesfolders:list', - kwargs={'project': self.project.sodar_uuid}, - ), - ) - self.assertEqual(File.objects.all().count(), 0) - - -class TestFileServeView(TestViewsBase): - """Tests for the File serving view""" - - def test_render(self): - """Test rendering of the File serving view""" - with self.login(self.user): - response = self.client.get( - reverse( - 'filesfolders:file_serve', - kwargs={ - 'file': self.file.sodar_uuid, - 'file_name': self.file.name, - }, - ) - ) - self.assertEqual(response.status_code, 200) - - def test_render_not_found(self): - """Test rendering of the File serving view""" - with self.login(self.user): - response = self.client.get( - reverse( - 'filesfolders:file_serve', - kwargs={ - 'file': INVALID_UUID, - 'file_name': self.file.name, - }, - ) - ) - self.assertEqual(response.status_code, 404) - - -class TestFileServePublicView(TestViewsBase): - """Tests for the File public serving view""" - - def test_render(self): - """Test rendering of the File public serving view""" - with self.login(self.user): - response = self.client.get( - reverse( - 'filesfolders:file_serve_public', - kwargs={'secret': SECRET, 'file_name': self.file.name}, - ) - ) - self.assertEqual(response.status_code, 200) - - def test_bad_request_setting(self): - """Test bad request response if public linking is disabled""" - app_settings.set( - APP_NAME, 'allow_public_links', False, project=self.project - ) - with self.login(self.user): - response = self.client.get( - reverse( - 'filesfolders:file_serve_public', - kwargs={'secret': SECRET, 'file_name': self.file.name}, - ) - ) - self.assertEqual(response.status_code, 400) - - def test_bad_request_file_flag(self): - """Test bad request response if file can not be served publicly""" - self.file.public_url = False - self.file.save() - with self.login(self.user): - response = self.client.get( - reverse( - 'filesfolders:file_serve_public', - kwargs={'secret': SECRET, 'file_name': self.file.name}, - ) - ) - self.assertEqual(response.status_code, 400) - - def test_bad_request_no_file(self): - """Test bad request response if file has been deleted""" - file_name = self.file.name - self.file.delete() - with self.login(self.user): - response = self.client.get( - reverse( - 'filesfolders:file_serve_public', - kwargs={'secret': SECRET, 'file_name': file_name}, - ) - ) - self.assertEqual(response.status_code, 400) - - -class TestFilePublicLinkView(TestViewsBase): - """Tests for the File public link view""" - - def test_render(self): - """Test rendering File public link view""" - with self.login(self.user): - response = self.client.get( - reverse( - 'filesfolders:file_public_link', - kwargs={'file': self.file.sodar_uuid}, - ) - ) - self.assertEqual(response.status_code, 200) - self.assertEqual( - response.context['public_url'], - build_public_url( - self.file, - self.req_factory.get( - 'file_public_link', - kwargs={'file': self.file.sodar_uuid}, - ), - ), - ) - - def test_render_not_found(self): - """Test rendering with invalid file UUID""" - with self.login(self.user): - response = self.client.get( - reverse( - 'filesfolders:file_public_link', - kwargs={'file': INVALID_UUID}, - ) - ) - self.assertEqual(response.status_code, 404) - - def test_redirect_setting(self): - """Test redirecting if public linking is disabled via settings""" - app_settings.set( - APP_NAME, 'allow_public_links', False, project=self.project - ) - with self.login(self.user): - response = self.client.get( - reverse( - 'filesfolders:file_public_link', - kwargs={'file': self.file.sodar_uuid}, - ) - ) - self.assertEqual(response.status_code, 302) - - def test_render_no_file(self): - """Test rendering if the file has been deleted""" - file_uuid = self.file.sodar_uuid - self.file.delete() - with self.login(self.user): - response = self.client.get( - reverse( - 'filesfolders:file_public_link', kwargs={'file': file_uuid} - ) - ) - self.assertEqual(response.status_code, 404) - - -# Folder Views ----------------------------------------------------------------- - - -class TestFolderCreateView(TestViewsBase): - """Tests for the File create view""" - - def test_render(self): - """Test rendering Folder create view""" - with self.login(self.user): - response = self.client.get( - reverse( - 'filesfolders:folder_create', - kwargs={'project': self.project.sodar_uuid}, - ) - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['project'].pk, self.project.pk) - - def test_render_not_found(self): - """Test rendering with invalid project UUID""" - with self.login(self.user): - response = self.client.get( - reverse( - 'filesfolders:folder_create', - kwargs={'project': INVALID_UUID}, - ) - ) - self.assertEqual(response.status_code, 404) - - def test_render_in_folder(self): - """Test rendering Folder create view under a folder""" - with self.login(self.user): - response = self.client.get( - reverse( - 'filesfolders:folder_create', - kwargs={'folder': self.folder.sodar_uuid}, - ) - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['project'].pk, self.project.pk) - self.assertEqual(response.context['folder'].pk, self.folder.pk) - - def test_create(self): - """Test folder creation""" - self.assertEqual(Folder.objects.all().count(), 1) - - post_data = { - 'name': 'new_folder', - 'folder': '', - 'description': '', - 'flag': '', - } - with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:folder_create', - kwargs={'project': self.project.sodar_uuid}, - ), - post_data, - ) +class TestFileDeleteView(ViewTestBase): + """Tests for FileDeleteView""" - self.assertEqual(response.status_code, 302) - self.assertEqual( - response.url, - reverse( - 'filesfolders:list', - kwargs={'project': self.project.sodar_uuid}, - ), + def setUp(self): + super().setUp() + self.url = reverse( + 'filesfolders:file_delete', + kwargs={'item': self.file.sodar_uuid}, ) - self.assertEqual(Folder.objects.all().count(), 2) - def test_create_in_folder(self): - """Test folder creation within a folder""" - self.assertEqual(Folder.objects.all().count(), 1) + def test_get(self): + """Test FileDeleteView GET""" + with self.login(self.user): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['object'], self.file) - post_data = { - 'name': 'new_folder', - 'folder': self.folder.sodar_uuid, - 'description': '', - 'flag': '', - } + def test_get_invalid_uuid(self): + """Test GET with invalid file UUID""" with self.login(self.user): - response = self.client.post( + response = self.client.get( reverse( - 'filesfolders:folder_create', - kwargs={'project': self.project.sodar_uuid}, - ), - post_data, + 'filesfolders:file_delete', + kwargs={'item': INVALID_UUID}, + ) ) + self.assertEqual(response.status_code, 404) + def test_post(self): + """Test POST to delete file""" + self.assertEqual(File.objects.all().count(), 1) + with self.login(self.user): + response = self.client.post(self.url) self.assertEqual(response.status_code, 302) self.assertEqual( response.url, reverse( 'filesfolders:list', - kwargs={'folder': self.folder.sodar_uuid}, + kwargs={'project': self.project.sodar_uuid}, ), ) - self.assertEqual(Folder.objects.all().count(), 2) - - def test_create_existing(self): - """Test folder creation with existing folder (should fail)""" - self.assertEqual(Folder.objects.all().count(), 1) - post_data = { - 'name': 'folder', - 'folder': '', - 'description': '', - 'flag': '', - } - with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:folder_create', - kwargs={'project': self.project.sodar_uuid}, - ), - post_data, - ) - self.assertEqual(response.status_code, 200) - self.assertEqual(Folder.objects.all().count(), 1) + self.assertEqual(File.objects.all().count(), 0) -class TestFolderUpdateView(TestViewsBase): - """Tests for the Folder update view""" +class TestFileServeView(ViewTestBase): + """Tests for FileServeView""" - def test_render(self): - """Test rendering Folder update view""" + def test_get(self): + """Test FileServeView GET""" with self.login(self.user): response = self.client.get( reverse( - 'filesfolders:folder_update', - kwargs={'item': self.folder.sodar_uuid}, + 'filesfolders:file_serve', + kwargs={ + 'file': self.file.sodar_uuid, + 'file_name': self.file.name, + }, ) ) self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['object'].pk, self.folder.pk) - def test_render_not_found(self): - """Test rendering with invalid folder UUID""" + def test_get_invalid_uuid(self): + """Test GET with invalid UUID""" with self.login(self.user): response = self.client.get( reverse( - 'filesfolders:folder_update', - kwargs={'item': INVALID_UUID}, + 'filesfolders:file_serve', + kwargs={ + 'file': INVALID_UUID, + 'file_name': self.file.name, + }, ) ) self.assertEqual(response.status_code, 404) - def test_update(self): - """Test folder update""" - self.assertEqual(Folder.objects.all().count(), 1) - post_data = { - 'name': 'renamed_folder', - 'folder': '', - 'description': 'updated description', - 'flag': '', - } - with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:folder_update', - kwargs={'item': self.folder.sodar_uuid}, - ), - post_data, - ) +class TestFileServePublicView(ViewTestBase): + """Tests for FileServePublicView""" - self.assertEqual(response.status_code, 302) - self.assertEqual( - response.url, - reverse( - 'filesfolders:list', - kwargs={'project': self.project.sodar_uuid}, - ), + def setUp(self): + super().setUp() + self.url = reverse( + 'filesfolders:file_serve_public', + kwargs={'secret': SECRET, 'file_name': self.file.name}, ) - self.assertEqual(Folder.objects.all().count(), 1) - self.folder.refresh_from_db() - self.assertEqual(self.folder.name, 'renamed_folder') - self.assertEqual(self.folder.description, 'updated description') - def test_update_existing(self): - """Test folder update with name that already exists (should fail)""" - self.make_folder( - name='folder2', - project=self.project, - folder=None, - owner=self.user, - description='', + def test_get(self): + """Test FileServePublicView GET""" + with self.login(self.user): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + def test_get_link_disabled(self): + """Test GET with disabled public link setting (should fail)""" + app_settings.set( + APP_NAME, 'allow_public_links', False, project=self.project ) - self.assertEqual(Folder.objects.all().count(), 2) + with self.login(self.user): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 400) - post_data = { - 'name': 'folder2', - 'folder': '', - 'description': '', - 'flag': '', - } + def test_get_public_url_disabled(self): + """Test GET with file public_url set to False (should fail)""" + self.file.public_url = False + self.file.save() with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:folder_update', - kwargs={'item': self.folder.sodar_uuid}, - ), - post_data, - ) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 400) - self.assertEqual(response.status_code, 200) - self.assertEqual(Folder.objects.all().count(), 2) - self.folder.refresh_from_db() - self.assertEqual(self.folder.name, 'folder') + def test_get_deleted_file(self): + """Test GET with deleted file (should fail)""" + self.file.delete() + with self.login(self.user): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 400) -class TestFolderDeleteView(TestViewsBase): - """Tests for the File delete view""" +class TestFilePublicLinkView(ViewTestBase): + """Tests for FilePublicLinkView""" - def test_render(self): - """Test rendering Folder delete view""" + def setUp(self): + super().setUp() + self.url = reverse( + 'filesfolders:file_public_link', + kwargs={'file': self.file.sodar_uuid}, + ) + + def test_get(self): + """Test FilePublicLinkView GET""" with self.login(self.user): - response = self.client.get( - reverse( - 'filesfolders:folder_delete', - kwargs={'item': self.folder.sodar_uuid}, - ) - ) + response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['object'].pk, self.folder.pk) + self.assertEqual( + response.context['public_url'], + build_public_url( + self.file, + self.req_factory.get( + 'file_public_link', + kwargs={'file': self.file.sodar_uuid}, + ), + ), + ) - def test_render_not_found(self): - """Test rendering Folder delete view""" + def test_get_invalid_uuid(self): + """Test GET with invalid file UUID""" with self.login(self.user): response = self.client.get( reverse( - 'filesfolders:folder_delete', - kwargs={'item': INVALID_UUID}, + 'filesfolders:file_public_link', + kwargs={'file': INVALID_UUID}, ) ) self.assertEqual(response.status_code, 404) - def test_post(self): - """Test deleting a Folder""" - self.assertEqual(Folder.objects.all().count(), 1) + def test_get_public_links_disabled(self): + """Test GET with disabled public linking (should fail)""" + app_settings.set( + APP_NAME, 'allow_public_links', False, project=self.project + ) with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:folder_delete', - kwargs={'item': self.folder.sodar_uuid}, - ) - ) + response = self.client.get(self.url) self.assertEqual(response.status_code, 302) - self.assertEqual( - response.url, - reverse( - 'filesfolders:list', - kwargs={'project': self.project.sodar_uuid}, - ), - ) - self.assertEqual(Folder.objects.all().count(), 0) + + def test_get_deleted_file(self): + """Test GET with deleted file (should fail)""" + self.file.delete() + with self.login(self.user): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 404) # HyperLink Views -------------------------------------------------------------- -class TestHyperLinkCreateView(TestViewsBase): - """Tests for the HyperLink create view""" +class TestHyperLinkCreateView(ViewTestBase): + """Tests for HyperLinkCreateView""" + + def setUp(self): + super().setUp() + self.url = reverse( + 'filesfolders:hyperlink_create', + kwargs={'project': self.project.sodar_uuid}, + ) + self.url_folder = reverse( + 'filesfolders:hyperlink_create', + kwargs={'folder': self.folder.sodar_uuid}, + ) - def test_render(self): - """Test rendering HyperLink create view""" + def test_get(self): + """Test HyperLinkCreateView GET""" with self.login(self.user): - response = self.client.get( - reverse( - 'filesfolders:hyperlink_create', - kwargs={'project': self.project.sodar_uuid}, - ) - ) + response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['project'].pk, self.project.pk) + self.assertEqual(response.context['project'], self.project) - def test_render_not_found(self): - """Test rendering with invalid project UUID""" + def test_get_invalid_uuid(self): + """Test GET with invalid project UUID""" with self.login(self.user): response = self.client.get( reverse( @@ -1186,21 +1029,16 @@ def test_render_not_found(self): ) self.assertEqual(response.status_code, 404) - def test_render_in_folder(self): - """Test rendering under a folder""" + def test_get_folder(self): + """Test GET under folder""" with self.login(self.user): - response = self.client.get( - reverse( - 'filesfolders:hyperlink_create', - kwargs={'folder': self.folder.sodar_uuid}, - ) - ) + response = self.client.get(self.url_folder) self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['project'].pk, self.project.pk) - self.assertEqual(response.context['folder'].pk, self.folder.pk) + self.assertEqual(response.context['project'], self.project) + self.assertEqual(response.context['folder'], self.folder) - def test_create(self): - """Test hyperlink creation""" + def test_post(self): + """Test POST to create hyperlink""" self.assertEqual(HyperLink.objects.all().count(), 1) post_data = { 'name': 'new link', @@ -1210,13 +1048,7 @@ def test_create(self): 'flag': '', } with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:hyperlink_create', - kwargs={'project': self.project.sodar_uuid}, - ), - post_data, - ) + response = self.client.post(self.url, post_data) self.assertEqual(response.status_code, 302) self.assertEqual( response.url, @@ -1227,8 +1059,8 @@ def test_create(self): ) self.assertEqual(HyperLink.objects.all().count(), 2) - def test_create_in_folder(self): - """Test folder creation within a folder""" + def test_post_folder(self): + """Test POST under folder""" self.assertEqual(HyperLink.objects.all().count(), 1) post_data = { 'name': 'new link', @@ -1238,13 +1070,7 @@ def test_create_in_folder(self): 'flag': '', } with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:hyperlink_create', - kwargs={'project': self.project.sodar_uuid}, - ), - post_data, - ) + response = self.client.post(self.url_folder, post_data) self.assertEqual(response.status_code, 302) self.assertEqual( response.url, @@ -1255,8 +1081,8 @@ def test_create_in_folder(self): ) self.assertEqual(HyperLink.objects.all().count(), 2) - def test_create_existing(self): - """Test hyperlink creation with an existing file (should fail)""" + def test_post_existing(self): + """Test POST with existing file (should fail)""" self.assertEqual(HyperLink.objects.all().count(), 1) post_data = { 'name': 'Link', @@ -1266,34 +1092,30 @@ def test_create_existing(self): 'flag': '', } with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:hyperlink_create', - kwargs={'project': self.project.sodar_uuid}, - ), - post_data, - ) + response = self.client.post(self.url, post_data) self.assertEqual(response.status_code, 200) self.assertEqual(HyperLink.objects.all().count(), 1) -class TestHyperLinkUpdateView(TestViewsBase): - """Tests for the HyperLink update view""" +class TestHyperLinkUpdateView(ViewTestBase): + """Tests for HyperLinkUpdateView(""" + + def setUp(self): + super().setUp() + self.url = reverse( + 'filesfolders:hyperlink_update', + kwargs={'item': self.hyperlink.sodar_uuid}, + ) - def test_render(self): - """Test rendering HyperLink update view""" + def test_get(self): + """Test HyperLinkUpdateView GET""" with self.login(self.user): - response = self.client.get( - reverse( - 'filesfolders:hyperlink_update', - kwargs={'item': self.hyperlink.sodar_uuid}, - ) - ) + response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['object'].pk, self.hyperlink.pk) + self.assertEqual(response.context['object'], self.hyperlink) - def test_render_not_found(self): - """Test rendering with invalid UUID""" + def test_get_invalid_uuid(self): + """Test GET with invalid UUID""" with self.login(self.user): response = self.client.get( reverse( @@ -1303,8 +1125,8 @@ def test_render_not_found(self): ) self.assertEqual(response.status_code, 404) - def test_update(self): - """Test hyperlink update""" + def test_post(self): + """Test POST to update hyperlink""" self.assertEqual(HyperLink.objects.all().count(), 1) post_data = { 'name': 'Renamed Link', @@ -1314,13 +1136,7 @@ def test_update(self): 'flag': '', } with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:hyperlink_update', - kwargs={'item': self.hyperlink.sodar_uuid}, - ), - post_data, - ) + response = self.client.post(self.url, post_data) self.assertEqual(response.status_code, 302) self.assertEqual( response.url, @@ -1335,8 +1151,8 @@ def test_update(self): self.assertEqual(self.hyperlink.url, 'http://updated.com') self.assertEqual(self.hyperlink.description, 'updated description') - def test_update_existing(self): - """Test hyperlink update with a name that already exists (should fail)""" + def test_post_existing(self): + """Test POST with existing name (should fail)""" self.make_hyperlink( name='Link2', url='http://url2.com', @@ -1355,13 +1171,7 @@ def test_update_existing(self): 'flag': '', } with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:hyperlink_update', - kwargs={'item': self.hyperlink.sodar_uuid}, - ), - post_data, - ) + response = self.client.post(self.url, post_data) self.assertEqual(response.status_code, 200) self.assertEqual(HyperLink.objects.all().count(), 2) @@ -1369,23 +1179,25 @@ def test_update_existing(self): self.assertEqual(self.hyperlink.name, 'Link') -class TestHyperLinkDeleteView(TestViewsBase): - """Tests for the HyperLink delete view""" +class TestHyperLinkDeleteView(ViewTestBase): + """Tests for HyperLinkDeleteView""" - def test_render(self): - """Test rendering File delete view""" + def setUp(self): + super().setUp() + self.url = reverse( + 'filesfolders:hyperlink_delete', + kwargs={'item': self.hyperlink.sodar_uuid}, + ) + + def test_get(self): + """Test HyperLinkDeleteView GET""" with self.login(self.user): - response = self.client.get( - reverse( - 'filesfolders:hyperlink_delete', - kwargs={'item': self.hyperlink.sodar_uuid}, - ) - ) + response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - self.assertEqual(response.context['object'].pk, self.hyperlink.pk) + self.assertEqual(response.context['object'], self.hyperlink) - def test_render_not_found(self): - """Test rendering with invalid UUID""" + def test_get_invalid_uuid(self): + """Test GET with invalid UUID""" with self.login(self.user): response = self.client.get( reverse( @@ -1396,15 +1208,10 @@ def test_render_not_found(self): self.assertEqual(response.status_code, 404) def test_post(self): - """Test deleting a HyperLink""" + """Test POST to delete hyperlink""" self.assertEqual(HyperLink.objects.all().count(), 1) with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:hyperlink_delete', - kwargs={'item': self.hyperlink.sodar_uuid}, - ) - ) + response = self.client.post(self.url) self.assertEqual(response.status_code, 302) self.assertEqual( response.url, @@ -1419,11 +1226,18 @@ def test_post(self): # Batch Editing View ----------------------------------------------------------- -class TestBatchEditView(TestViewsBase): - """Tests for the batch editing view""" +class TestBatchEditView(ViewTestBase): + """Tests for BatchEditView""" - def test_render_delete(self): - """Test rendering of the batch editing view when deleting""" + def setUp(self): + super().setUp() + self.url = reverse( + 'filesfolders:batch_edit', + kwargs={'project': self.project.sodar_uuid}, + ) + + def test_get_delete(self): + """Test BatchEditView GET with deleting""" post_data = {'batch-action': 'delete', 'user-confirmed': '0'} post_data['batch_item_File_{}'.format(self.file.sodar_uuid)] = 1 post_data['batch_item_Folder_{}'.format(self.folder.sodar_uuid)] = 1 @@ -1431,34 +1245,22 @@ def test_render_delete(self): 'batch_item_HyperLink_{}'.format(self.hyperlink.sodar_uuid) ] = 1 with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:batch_edit', - kwargs={'project': self.project.sodar_uuid}, - ), - post_data, - ) + response = self.client.post(self.url, post_data) self.assertEqual(response.status_code, 200) - def test_render_move(self): - """Test rendering of the batch editing view when moving""" + def test_get_move(self): + """Test GET when moving""" post_data = {'batch-action': 'move', 'user-confirmed': '0'} post_data['batch_item_File_{}'.format(self.file.sodar_uuid)] = 1 post_data[ 'batch_item_HyperLink_{}'.format(self.hyperlink.sodar_uuid) ] = 1 with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:batch_edit', - kwargs={'project': self.project.sodar_uuid}, - ), - post_data, - ) + response = self.client.post(self.url, post_data) self.assertEqual(response.status_code, 200) - def test_deletion(self): - """Test batch object deletion""" + def test_post_delete(self): + """Test POST for batch object deletion""" self.assertEqual(File.objects.all().count(), 1) self.assertEqual(Folder.objects.all().count(), 1) self.assertEqual(HyperLink.objects.all().count(), 1) @@ -1469,23 +1271,16 @@ def test_deletion(self): post_data[ 'batch_item_HyperLink_{}'.format(self.hyperlink.sodar_uuid) ] = 1 - with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:batch_edit', - kwargs={'project': self.project.sodar_uuid}, - ), - post_data, - ) + response = self.client.post(self.url, post_data) self.assertEqual(response.status_code, 302) self.assertEqual(File.objects.all().count(), 0) self.assertEqual(Folder.objects.all().count(), 0) self.assertEqual(HyperLink.objects.all().count(), 0) - def test_deletion_non_empty_folder(self): - """Test batch deletion with non-empty folder (should not be deleted)""" + def test_post_delete_non_empty_folder(self): + """Test POST for deletion with non-empty folder (should not be deleted)""" new_folder = self.make_folder( 'new_folder', self.project, None, self.user, '' ) @@ -1507,23 +1302,16 @@ def test_deletion_non_empty_folder(self): post_data['batch_item_File_{}'.format(self.file.sodar_uuid)] = 1 post_data['batch_item_Folder_{}'.format(self.folder.sodar_uuid)] = 1 post_data['batch_item_Folder_{}'.format(new_folder.sodar_uuid)] = 1 - with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:batch_edit', - kwargs={'project': self.project.sodar_uuid}, - ), - post_data, - ) + response = self.client.post(self.url, post_data) self.assertEqual(response.status_code, 302) # The new folder and file should be left self.assertEqual(File.objects.all().count(), 1) self.assertEqual(Folder.objects.all().count(), 1) - def test_moving(self): - """Test batch object moving""" + def test_post_move(self): + """Test POST for batch object moving""" target_folder = self.make_folder( 'target_folder', self.project, None, self.user, '' ) @@ -1537,31 +1325,22 @@ def test_moving(self): post_data[ 'batch_item_HyperLink_{}'.format(self.hyperlink.sodar_uuid) ] = 1 - with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:batch_edit', - kwargs={'project': self.project.sodar_uuid}, - ), - post_data, - ) + response = self.client.post(self.url, post_data) self.assertEqual(response.status_code, 302) self.assertEqual( - File.objects.get(pk=self.file.pk).folder.pk, target_folder.pk + File.objects.get(pk=self.file.pk).folder, target_folder ) self.assertEqual( - Folder.objects.get(pk=self.folder.pk).folder.pk, - target_folder.pk, + Folder.objects.get(pk=self.folder.pk).folder, target_folder ) self.assertEqual( - HyperLink.objects.get(pk=self.hyperlink.pk).folder.pk, - target_folder.pk, + HyperLink.objects.get(pk=self.hyperlink.pk).folder, target_folder ) - def test_moving_name_exists(self): - """Test batch moving with name existing in target (should not be moved)""" + def test_post_move_name_exists(self): + """Test POST for moving with name existing in target (should not be moved)""" target_folder = self.make_folder( 'target_folder', self.project, None, self.user, '' ) @@ -1587,25 +1366,15 @@ def test_moving_name_exists(self): post_data[ 'batch_item_HyperLink_{}'.format(self.hyperlink.sodar_uuid) ] = 1 - with self.login(self.user): - response = self.client.post( - reverse( - 'filesfolders:batch_edit', - kwargs={'project': self.project.sodar_uuid}, - ), - post_data, - ) + response = self.client.post(self.url, post_data) self.assertEqual(response.status_code, 302) + # Not moved + self.assertEqual(File.objects.get(pk=self.file.pk).folder, None) self.assertEqual( - File.objects.get(pk=self.file.pk).folder, None - ) # Not moved - self.assertEqual( - Folder.objects.get(pk=self.folder.pk).folder.pk, - target_folder.pk, + Folder.objects.get(pk=self.folder.pk).folder, target_folder ) self.assertEqual( - HyperLink.objects.get(pk=self.hyperlink.pk).folder.pk, - target_folder.pk, + HyperLink.objects.get(pk=self.hyperlink.pk).folder, target_folder ) diff --git a/filesfolders/tests/test_views_api.py b/filesfolders/tests/test_views_api.py index b07a2838..717146a7 100644 --- a/filesfolders/tests/test_views_api.py +++ b/filesfolders/tests/test_views_api.py @@ -9,14 +9,17 @@ # Projectroles dependency from projectroles.models import SODAR_CONSTANTS from projectroles.tests.test_views_api import SODARAPIViewTestMixin -from projectroles.views_api import ( - CORE_API_MEDIA_TYPE, - CORE_API_DEFAULT_VERSION, - INVALID_PROJECT_TYPE_MSG, -) +from projectroles.views_api import INVALID_PROJECT_TYPE_MSG -from filesfolders.tests.test_views import ZIP_PATH_NO_FILES, TestViewsBaseMixin +from filesfolders.tests.test_views import ( + ZIP_PATH_NO_FILES, + FilesfoldersViewTestMixin, +) from filesfolders.models import Folder, File, HyperLink +from filesfolders.views_api import ( + FILESFOLDERS_API_MEDIA_TYPE, + FILESFOLDERS_API_DEFAULT_VERSION, +) # SODAR constants @@ -26,13 +29,13 @@ INVALID_UUID = '11111111-1111-1111-1111-111111111111' -class TestFilesfoldersAPIViewsBase( - TestViewsBaseMixin, SODARAPIViewTestMixin, APITestCase +class FilesfoldersAPIViewTestBase( + FilesfoldersViewTestMixin, SODARAPIViewTestMixin, APITestCase ): """Base class for filesfolders API tests""" - media_type = CORE_API_MEDIA_TYPE - api_version = CORE_API_DEFAULT_VERSION + media_type = FILESFOLDERS_API_MEDIA_TYPE + api_version = FILESFOLDERS_API_DEFAULT_VERSION def setUp(self): super().setUp() @@ -40,8 +43,8 @@ def setUp(self): self.knox_token = self.get_token(self.user) -class TestFolderListCreateAPIView(TestFilesfoldersAPIViewsBase): - """Tests for the FolderListCreateAPIView class""" +class TestFolderListCreateAPIView(FilesfoldersAPIViewTestBase): + """Tests for FolderListCreateAPIView""" def setUp(self): super().setUp() @@ -50,49 +53,63 @@ def setUp(self): 'flag': 'IMPORTANT', 'description': 'Folder\'s description', } - - def test_list_superuser(self): - """Test GET request listing folders""" - response = self.request_knox( - reverse( - 'filesfolders:api_folder_list_create', - kwargs={'project': self.project.sodar_uuid}, - ) + self.url = reverse( + 'filesfolders:api_folder_list_create', + kwargs={'project': self.project.sodar_uuid}, ) + + def test_get_list(self): + """Test FolderListCreateAPIView GET to list folders""" + response = self.request_knox(self.url) + self.assertEqual(response.status_code, 200, msg=response.data) + expected = { + 'name': self.folder.name, + 'folder': None, + 'owner': self.get_serialized_user(self.folder.owner), + 'project': str(self.folder.project.sodar_uuid), + 'flag': self.folder.flag, + 'description': self.folder.description, + 'date_modified': self.get_drf_datetime(self.folder.date_modified), + 'sodar_uuid': str(self.folder.sodar_uuid), + } + self.assertEqual(json.loads(response.content), [expected]) + + def test_get_pagination(self): + """Test GET with pagination""" + url = self.url + '?page=1' + response = self.request_knox(url) self.assertEqual(response.status_code, 200, msg=response.data) - expected = [ - { - 'name': self.folder.name, - 'folder': None, - 'owner': self.get_serialized_user(self.folder.owner), - 'project': str(self.folder.project.sodar_uuid), - 'flag': self.folder.flag, - 'description': self.folder.description, - 'date_modified': self.get_drf_datetime( - self.folder.date_modified - ), - 'sodar_uuid': str(self.folder.sodar_uuid), - } - ] + expected = { + 'count': 1, + 'next': None, + 'previous': None, + 'results': [ + { + 'name': self.folder.name, + 'folder': None, + 'owner': self.get_serialized_user(self.folder.owner), + 'project': str(self.folder.project.sodar_uuid), + 'flag': self.folder.flag, + 'description': self.folder.description, + 'date_modified': self.get_drf_datetime( + self.folder.date_modified + ), + 'sodar_uuid': str(self.folder.sodar_uuid), + } + ], + } self.assertEqual(json.loads(response.content), expected) - def test_create_in_root(self): - """Test creation of new folder in root""" + def test_post_create_root(self): + """Test POST to create folder in root""" response = self.request_knox( - reverse( - 'filesfolders:api_folder_list_create', - kwargs={'project': self.project.sodar_uuid}, - ), - method='POST', - data=self.folder_data, + self.url, method='POST', data=self.folder_data ) - self.assertEqual(response.status_code, 201, msg=response.data) new_folder = Folder.objects.filter( sodar_uuid=response.data['sodar_uuid'] ).first() self.assertIsNotNone(new_folder) - expected = { **self.folder_data, 'folder': None, @@ -103,27 +120,18 @@ def test_create_in_root(self): } self.assertEqual(json.loads(response.content), expected) - def test_create_in_folder(self): - """Test creation of new folder below other""" + def test_post_create_folder(self): + """Test POST to create folder under folder""" folder_data = { **self.folder_data, 'folder': str(self.folder.sodar_uuid), } - response = self.request_knox( - reverse( - 'filesfolders:api_folder_list_create', - kwargs={'project': self.project.sodar_uuid}, - ), - method='POST', - data=folder_data, - ) - + response = self.request_knox(self.url, method='POST', data=folder_data) self.assertEqual(response.status_code, 201, msg=response.data) new_folder = Folder.objects.filter( sodar_uuid=response.data['sodar_uuid'] ).first() self.assertIsNotNone(new_folder) - expected = { **folder_data, 'owner': self.get_serialized_user(self.user), @@ -133,8 +141,8 @@ def test_create_in_folder(self): } self.assertEqual(json.loads(response.content), expected) - def test_create_in_category(self): - """Test creation of new folder in a category (should fail)""" + def test_post_create_category(self): + """Test POST to create folder in category (should fail)""" category = self.make_project( 'TestCategory', PROJECT_TYPE_CATEGORY, None ) @@ -154,17 +162,19 @@ def test_create_in_category(self): ) -class TestFolderRetrieveUpdateDestroyAPIView(TestFilesfoldersAPIViewsBase): - """Tests for the FolderRetrieveUpdateDestroyAPIView class""" +class TestFolderRetrieveUpdateDestroyAPIView(FilesfoldersAPIViewTestBase): + """Tests for FolderRetrieveUpdateDestroyAPIView""" - def test_retrieve(self): - """Test retrieval of Folder model through API""" - response = self.request_knox( - reverse( - 'filesfolders:api_folder_retrieve_update_destroy', - kwargs={'folder': self.folder.sodar_uuid}, - ) + def setUp(self): + super().setUp() + self.url = reverse( + 'filesfolders:api_folder_retrieve_update_destroy', + kwargs={'folder': self.folder.sodar_uuid}, ) + + def test_get_retrieve(self): + """Test FolderRetrieveUpdateDestroyAPIView GET to retrieve folder""" + response = self.request_knox(self.url) self.assertEqual(response.status_code, 200, msg=response.data) expected = { 'name': self.folder.name, @@ -178,8 +188,8 @@ def test_retrieve(self): } self.assertEqual(json.loads(response.content), expected) - def test_retrieve_not_found(self): - """Test retrieval of Folder with invalid UUID""" + def test_get_invalid_uuid(self): + """Test GET with invalid UUID""" response = self.request_knox( reverse( 'filesfolders:api_folder_retrieve_update_destroy', @@ -188,21 +198,14 @@ def test_retrieve_not_found(self): ) self.assertEqual(response.status_code, 404) - def test_update(self): - """Test update of Folder model through API""" + def test_put_update(self): + """Test PUT to update folder""" folder_data = { 'name': 'UPDATED Folder', 'flag': 'FLAG', 'description': 'UPDATED Description', } - response = self.request_knox( - reverse( - 'filesfolders:api_folder_retrieve_update_destroy', - kwargs={'folder': self.folder.sodar_uuid}, - ), - method='PUT', - data=folder_data, - ) + response = self.request_knox(self.url, method='PUT', data=folder_data) self.assertEqual(response.status_code, 200, msg=response.data) self.folder.refresh_from_db() expected = { @@ -215,15 +218,9 @@ def test_update(self): } self.assertEqual(json.loads(response.content), expected) - def test_destroy(self): - """Test destruction of Folder model through API""" - response = self.request_knox( - reverse( - 'filesfolders:api_folder_retrieve_update_destroy', - kwargs={'folder': self.folder.sodar_uuid}, - ), - method='DELETE', - ) + def test_delete(self): + """Test DELETE to remove folder""" + response = self.request_knox(self.url, method='DELETE') self.assertEqual(response.status_code, 204, msg=response.data) self.assertIsNone(response.data) with self.assertRaises(Folder.DoesNotExist): @@ -232,8 +229,8 @@ def test_destroy(self): ) -class TestFileListCreateAPIView(TestFilesfoldersAPIViewsBase): - """Tests for the FileListCreateAPIView class""" +class TestFileListCreateAPIView(FilesfoldersAPIViewTestBase): + """Tests for FileListCreateAPIView""" def setUp(self): super().setUp() @@ -245,55 +242,72 @@ def setUp(self): 'public_url': True, 'file': open(ZIP_PATH_NO_FILES, 'rb'), } + self.url = reverse( + 'filesfolders:api_file_list_create', + kwargs={'project': self.project.sodar_uuid}, + ) def tearDown(self): self.file_data['file'].close() super().tearDown() - def test_list_superuser(self): - """Test GET request listing files""" - response = self.request_knox( - reverse( - 'filesfolders:api_file_list_create', - kwargs={'project': self.project.sodar_uuid}, - ) - ) + def test_get_list(self): + """Test FileListCreateAPIView GET to list files""" + response = self.request_knox(self.url) + self.assertEqual(response.status_code, 200, msg=response.data) + expected = { + 'name': self.file.name, + 'folder': None, + 'owner': self.get_serialized_user(self.file.owner), + 'project': str(self.file.project.sodar_uuid), + 'flag': self.file.flag, + 'description': self.file.description, + 'secret': self.file.secret, + 'public_url': self.file.public_url, + 'date_modified': self.get_drf_datetime(self.file.date_modified), + 'sodar_uuid': str(self.file.sodar_uuid), + } + self.assertEqual(json.loads(response.content), [expected]) + + def test_get_pagination(self): + """Test GET with pagination""" + url = self.url + '?page=1' + response = self.request_knox(url) self.assertEqual(response.status_code, 200, msg=response.data) - expected = [ - { - 'name': self.file.name, - 'folder': None, - 'owner': self.get_serialized_user(self.file.owner), - 'project': str(self.file.project.sodar_uuid), - 'flag': self.file.flag, - 'description': self.file.description, - 'secret': self.file.secret, - 'public_url': self.file.public_url, - 'date_modified': self.get_drf_datetime(self.file.date_modified), - 'sodar_uuid': str(self.file.sodar_uuid), - } - ] + expected = { + 'count': 1, + 'next': None, + 'previous': None, + 'results': [ + { + 'name': self.file.name, + 'folder': None, + 'owner': self.get_serialized_user(self.file.owner), + 'project': str(self.file.project.sodar_uuid), + 'flag': self.file.flag, + 'description': self.file.description, + 'secret': self.file.secret, + 'public_url': self.file.public_url, + 'date_modified': self.get_drf_datetime( + self.file.date_modified + ), + 'sodar_uuid': str(self.file.sodar_uuid), + } + ], + } self.assertEqual(json.loads(response.content), expected) - def test_create_in_root(self): - """Test creation of new file in root""" + def test_post_create_root(self): + """Test POST to create file in root""" response = self.request_knox( - reverse( - 'filesfolders:api_file_list_create', - kwargs={'project': self.project.sodar_uuid}, - ), - method='POST', - format='multipart', - data=self.file_data, + self.url, method='POST', format='multipart', data=self.file_data ) - self.assertEqual(response.status_code, 201, msg=response.data) new_file = File.objects.filter( sodar_uuid=response.data['sodar_uuid'] ).first() self.assertIsNotNone(new_file) self.assertNotEqual(new_file.file.file.size, 0) - expected = { **self.file_data, 'folder': None, @@ -307,17 +321,11 @@ def test_create_in_root(self): expected.pop('file') self.assertEqual(json.loads(response.content), expected) - def test_create_in_folder(self): - """Test creation of a file inside a folder""" + def test_post_create_folder(self): + """Test POST to create file under folder""" file_data = {**self.file_data, 'folder': str(self.folder.sodar_uuid)} response = self.request_knox( - reverse( - 'filesfolders:api_file_list_create', - kwargs={'project': self.project.sodar_uuid}, - ), - method='POST', - format='multipart', - data=file_data, + self.url, method='POST', format='multipart', data=file_data ) self.assertEqual(response.status_code, 201, msg=response.data) new_file = File.objects.filter( @@ -337,8 +345,8 @@ def test_create_in_folder(self): expected.pop('file') self.assertEqual(json.loads(response.content), expected) - def test_create_in_category(self): - """Test creation of new file in a category (should fail)""" + def test_post_create_category(self): + """Test POST to create file in category (should fail)""" category = self.make_project( 'TestCategory', PROJECT_TYPE_CATEGORY, None ) @@ -359,8 +367,8 @@ def test_create_in_category(self): ) -class TestFileRetrieveUpdateDestroyAPIView(TestFilesfoldersAPIViewsBase): - """Tests for the FileRetrieveUpdateDestroyAPIView class""" +class TestFileRetrieveUpdateDestroyAPIView(FilesfoldersAPIViewTestBase): + """Tests for FileRetrieveUpdateDestroyAPIView""" def setUp(self): super().setUp() @@ -372,19 +380,18 @@ def setUp(self): 'public_url': False, 'file': open(ZIP_PATH_NO_FILES, 'rb'), } + self.url = reverse( + 'filesfolders:api_file_retrieve_update_destroy', + kwargs={'file': self.file.sodar_uuid}, + ) def tearDown(self): self.file_data['file'].close() super().tearDown() - def test_retrieve(self): - """Test retrieval of File model through API""" - response = self.request_knox( - reverse( - 'filesfolders:api_file_retrieve_update_destroy', - kwargs={'file': self.file.sodar_uuid}, - ) - ) + def test_get_retrieve(self): + """Test FileRetrieveUpdateDestroyAPIView GET to retrieve file""" + response = self.request_knox(self.url) self.assertEqual(response.status_code, 200, msg=response.data) expected = { 'name': self.file.name, @@ -400,8 +407,8 @@ def test_retrieve(self): } self.assertEqual(json.loads(response.content), expected) - def test_retrieve_not_found(self): - """Test retrieval of File with invalid UUID""" + def test_get_invalid_uuid(self): + """Test GET with invalid UUID""" response = self.request_knox( reverse( 'filesfolders:api_file_retrieve_update_destroy', @@ -410,18 +417,11 @@ def test_retrieve_not_found(self): ) self.assertEqual(response.status_code, 404) - def test_update(self): - """Test update of File model through API""" + def test_put_update(self): + """Test PUT to update file""" response = self.request_knox( - reverse( - 'filesfolders:api_file_retrieve_update_destroy', - kwargs={'file': self.file.sodar_uuid}, - ), - method='PUT', - format='multipart', - data=self.file_data, + self.url, method='PUT', format='multipart', data=self.file_data ) - self.assertEqual(response.status_code, 200, msg=response.data) old_secret = self.file.secret self.file.refresh_from_db() @@ -433,7 +433,6 @@ def test_update(self): old_secret, msg='Secret should change when public_url flag changes', ) - expected = { **self.file_data, 'folder': None, @@ -447,15 +446,9 @@ def test_update(self): expected.pop('file') self.assertEqual(json.loads(response.content), expected) - def test_destroy(self): - """Test destruction of File model through API""" - response = self.request_knox( - reverse( - 'filesfolders:api_file_retrieve_update_destroy', - kwargs={'file': self.file.sodar_uuid}, - ), - method='DELETE', - ) + def test_delete(self): + """Test DELETE""" + response = self.request_knox(self.url, method='DELETE') self.assertEqual(response.status_code, 204, msg=response.data) self.assertIsNone(response.data) with self.assertRaises(File.DoesNotExist): @@ -464,11 +457,11 @@ def test_destroy(self): ) -class TestFileServeAPIView(TestFilesfoldersAPIViewsBase): - """Tests for the FileServeAPIView class""" +class TestFileServeAPIView(FilesfoldersAPIViewTestBase): + """Tests for FileServeAPIView""" def test_get(self): - """Test download of file content""" + """Test FileServeAPIView GET to download file""" response = self.request_knox( reverse( 'filesfolders:api_file_serve', @@ -480,7 +473,7 @@ def test_get(self): self.assertEqual(response.content, expected) def test_get_not_found(self): - """Test download with invalid UUID""" + """Test GET with invalid UUID""" response = self.request_knox( reverse( 'filesfolders:api_file_serve', @@ -490,8 +483,8 @@ def test_get_not_found(self): self.assertEqual(response.status_code, 404) -class TestHyperLinkListCreateAPIView(TestFilesfoldersAPIViewsBase): - """Tests for the HyperLinkListCreateAPIView class""" +class TestHyperLinkListCreateAPIView(FilesfoldersAPIViewTestBase): + """Tests for HyperLinkListCreateAPIView""" def setUp(self): super().setUp() @@ -501,50 +494,67 @@ def setUp(self): 'description': 'HyperLink\'s description', 'url': 'http://www.cubi.bihealth.org', } - - def test_list_superuser(self): - """Test GET request listing hyperlinks""" - response = self.request_knox( - reverse( - 'filesfolders:api_hyperlink_list_create', - kwargs={'project': self.project.sodar_uuid}, - ) + self.url = reverse( + 'filesfolders:api_hyperlink_list_create', + kwargs={'project': self.project.sodar_uuid}, ) + + def test_get_list(self): + """Test HyperLinkListCreateAPIView GET to list hyperlinks""" + response = self.request_knox(self.url) + self.assertEqual(response.status_code, 200, msg=response.data) + expected = { + 'name': self.hyperlink.name, + 'folder': None, + 'owner': self.get_serialized_user(self.hyperlink.owner), + 'project': str(self.hyperlink.project.sodar_uuid), + 'flag': self.hyperlink.flag, + 'description': self.hyperlink.description, + 'url': self.hyperlink.url, + 'date_modified': self.get_drf_datetime( + self.hyperlink.date_modified + ), + 'sodar_uuid': str(self.hyperlink.sodar_uuid), + } + self.assertEqual(json.loads(response.content), [expected]) + + def test_get_pagination(self): + """Test GET with pagination""" + url = self.url + '?page=1' + response = self.request_knox(url) self.assertEqual(response.status_code, 200, msg=response.data) - expected = [ - { - 'name': self.hyperlink.name, - 'folder': None, - 'owner': self.get_serialized_user(self.hyperlink.owner), - 'project': str(self.hyperlink.project.sodar_uuid), - 'flag': self.hyperlink.flag, - 'description': self.hyperlink.description, - 'url': self.hyperlink.url, - 'date_modified': self.get_drf_datetime( - self.hyperlink.date_modified - ), - 'sodar_uuid': str(self.hyperlink.sodar_uuid), - } - ] + expected = { + 'count': 1, + 'next': None, + 'previous': None, + 'results': [ + { + 'name': self.hyperlink.name, + 'folder': None, + 'owner': self.get_serialized_user(self.hyperlink.owner), + 'project': str(self.hyperlink.project.sodar_uuid), + 'flag': self.hyperlink.flag, + 'description': self.hyperlink.description, + 'url': self.hyperlink.url, + 'date_modified': self.get_drf_datetime( + self.hyperlink.date_modified + ), + 'sodar_uuid': str(self.hyperlink.sodar_uuid), + } + ], + } self.assertEqual(json.loads(response.content), expected) - def test_create_in_root(self): - """Test creation of new hyperlink in root""" + def test_post_root(self): + """Test POST to create hyperlink in root""" response = self.request_knox( - reverse( - 'filesfolders:api_hyperlink_list_create', - kwargs={'project': self.project.sodar_uuid}, - ), - method='POST', - data=self.hyperlink_data, + self.url, method='POST', data=self.hyperlink_data ) - self.assertEqual(response.status_code, 201, msg=response.data) new_link = HyperLink.objects.filter( sodar_uuid=response.data['sodar_uuid'] ).first() self.assertIsNotNone(new_link) - expected = { **self.hyperlink_data, 'folder': None, @@ -555,19 +565,14 @@ def test_create_in_root(self): } self.assertEqual(json.loads(response.content), expected) - def test_create_in_folder(self): - """Test creation of new hyperlink below another""" + def test_post_folder(self): + """Test POST to create hyperlink under folder""" hyperlink_data = { **self.hyperlink_data, 'folder': str(self.folder.sodar_uuid), } response = self.request_knox( - reverse( - 'filesfolders:api_hyperlink_list_create', - kwargs={'project': self.project.sodar_uuid}, - ), - method='POST', - data=hyperlink_data, + self.url, method='POST', data=hyperlink_data ) self.assertEqual(response.status_code, 201, msg=response.data) new_link = HyperLink.objects.filter( @@ -583,8 +588,8 @@ def test_create_in_folder(self): } self.assertEqual(json.loads(response.content), expected) - def test_create_in_category(self): - """Test creation of new hyperlink in a category (should fail)""" + def test_post_category(self): + """Test POST to create hyperlink in category (should fail)""" category = self.make_project( 'TestCategory', PROJECT_TYPE_CATEGORY, None ) @@ -604,17 +609,19 @@ def test_create_in_category(self): ) -class TestHyperLinkRetrieveUpdateDestroyAPIView(TestFilesfoldersAPIViewsBase): - """Tests for the HyperLinkRetrieveUpdateDestroyAPIView class""" +class TestHyperLinkRetrieveUpdateDestroyAPIView(FilesfoldersAPIViewTestBase): + """Tests for HyperLinkRetrieveUpdateDestroyAPIView""" - def test_retrieve(self): - """Test retrieval of HyperLink model through API""" - response = self.request_knox( - reverse( - 'filesfolders:api_hyperlink_retrieve_update_destroy', - kwargs={'hyperlink': self.hyperlink.sodar_uuid}, - ) + def setUp(self): + super().setUp() + self.url = reverse( + 'filesfolders:api_hyperlink_retrieve_update_destroy', + kwargs={'hyperlink': self.hyperlink.sodar_uuid}, ) + + def test_get_retrieve(self): + """Test HyperLinkRetrieveUpdateDestroyAPIView to retrieve hyperlink""" + response = self.request_knox(self.url) self.assertEqual(response.status_code, 200, msg=response.data) expected = { 'name': self.hyperlink.name, @@ -631,8 +638,8 @@ def test_retrieve(self): } self.assertEqual(json.loads(response.content), expected) - def test_retrieve_not_found(self): - """Test retrieval of HyperLink with invalid UUID""" + def test_get_invalid_uuid(self): + """Test GET with invalid UUID""" response = self.request_knox( reverse( 'filesfolders:api_hyperlink_retrieve_update_destroy', @@ -641,8 +648,8 @@ def test_retrieve_not_found(self): ) self.assertEqual(response.status_code, 404) - def test_update(self): - """Test update of HyperLink model through API""" + def test_put_update(self): + """Test PUT to update hyperlink""" hyperlink_data = { 'name': 'UPDATED HyperLink', 'flag': 'FLAG', @@ -650,12 +657,7 @@ def test_update(self): 'url': 'http://www.bihealth.org', } response = self.request_knox( - reverse( - 'filesfolders:api_hyperlink_retrieve_update_destroy', - kwargs={'hyperlink': self.hyperlink.sodar_uuid}, - ), - method='PUT', - data=hyperlink_data, + self.url, method='PUT', data=hyperlink_data ) self.assertEqual(response.status_code, 200, msg=response.data) self.hyperlink.refresh_from_db() @@ -676,14 +678,8 @@ def test_update(self): } self.assertEqual(json.loads(response.content), expected) - def test_destroy(self): - """Test destruction of HyperLink model through API""" - response = self.request_knox( - reverse( - 'filesfolders:api_hyperlink_retrieve_update_destroy', - kwargs={'hyperlink': self.hyperlink.sodar_uuid}, - ), - method='DELETE', - ) + def test_delete(self): + """Test DELETE""" + response = self.request_knox(self.url, method='DELETE') self.assertEqual(response.status_code, 204, msg=response.data) self.assertIsNone(response.data) diff --git a/filesfolders/views.py b/filesfolders/views.py index 55e6bea2..e894e9a0 100644 --- a/filesfolders/views.py +++ b/filesfolders/views.py @@ -132,7 +132,7 @@ def add_item_modify_event( event_name='{}_{}'.format(obj_type, view_action), description=tl_desc, extra_data=extra_data, - status_type='OK', + status_type=timeline.TL_STATUS_OK, ) tl_event.add_object( obj=obj, @@ -207,14 +207,16 @@ def get_success_url(self): user=self.request.user, event_name='{}_delete'.format(obj_type), description='delete {} {{{}}}'.format(obj_type, obj_type), - status_type='OK', + status_type=timeline.TL_STATUS_OK, ) tl_event.add_object( obj=self.object, label=obj_type, - name=self.object.get_path() - if isinstance(self.object, Folder) - else self.object.name, + name=( + self.object.get_path() + if isinstance(self.object, Folder) + else self.object.name + ), ) messages.success( @@ -281,7 +283,7 @@ def get(self, *args, **kwargs): event_name='file_serve', description='serve file {file}', classified=True, - status_type='INFO', + status_type=timeline.TL_STATUS_INFO, ) tl_event.add_object(file, 'file', file.name) return response @@ -546,7 +548,7 @@ def form_valid(self, form): 'new_folders': [f.name for f in new_folders], 'new_files': [f.name for f in new_files], }, - status_type='OK', + status_type=timeline.TL_STATUS_OK, ) messages.success( @@ -843,15 +845,23 @@ def _finalize_edit(self, edit_count, target_folder, **kwargs): self.batch_action, edit_count, edit_suffix, - '({} failed)'.format(len(self.failed)) - if len(self.failed) > 0 - else '', - 'to {target_folder}' - if self.batch_action == 'move' and target_folder - else '', + ( + '({} failed)'.format(len(self.failed)) + if len(self.failed) > 0 + else '' + ), + ( + 'to {target_folder}' + if self.batch_action == 'move' and target_folder + else '' + ), ), extra_data=extra_data, - status_type='OK' if edit_count > 0 else 'FAILED', + status_type=( + timeline.TL_STATUS_OK + if edit_count > 0 + else timeline.TL_STATUS_FAILED + ), ) if self.batch_action == 'move' and target_folder: tl_event.add_object( diff --git a/filesfolders/views_api.py b/filesfolders/views_api.py index bfc75175..4be42c01 100644 --- a/filesfolders/views_api.py +++ b/filesfolders/views_api.py @@ -5,11 +5,17 @@ RetrieveUpdateDestroyAPIView, GenericAPIView, ) +from rest_framework.renderers import JSONRenderer +from rest_framework.schemas.openapi import AutoSchema +from rest_framework.versioning import AcceptHeaderVersioning # Projectroles dependency from projectroles.models import SODAR_CONSTANTS from projectroles.plugins import get_backend_api -from projectroles.views_api import CoreAPIGenericProjectMixin +from projectroles.views_api import ( + CoreAPIGenericProjectMixin, + SODARPageNumberPagination, +) from filesfolders.models import Folder from filesfolders.serializers import ( @@ -28,10 +34,34 @@ # SODAR constants PROJECT_TYPE_PROJECT = SODAR_CONSTANTS['PROJECT_TYPE_PROJECT'] +# Local constants +FILESFOLDERS_API_MEDIA_TYPE = ( + 'application/vnd.bihealth.sodar-core.filesfolders+json' +) +FILESFOLDERS_API_DEFAULT_VERSION = '1.0' +FILESFOLDERS_API_ALLOWED_VERSIONS = ['1.0'] + # Base Classes and Mixins ------------------------------------------------------ +class FilesfoldersAPIVersioningMixin: + """ + Filesfolders API view versioning mixin for overriding media type and + accepted versions. + """ + + class FilesfoldersAPIRenderer(JSONRenderer): + media_type = FILESFOLDERS_API_MEDIA_TYPE + + class FilesfoldersAPIVersioning(AcceptHeaderVersioning): + allowed_versions = FILESFOLDERS_API_ALLOWED_VERSIONS + default_version = FILESFOLDERS_API_DEFAULT_VERSION + + renderer_classes = [FilesfoldersAPIRenderer] + versioning_class = FilesfoldersAPIVersioning + + class ListCreateAPITimelineMixin(FilesfoldersTimelineMixin): """ Mixin that ties ListCreateAPIView:s to the SODAR timeline for filesfolders. @@ -92,14 +122,16 @@ def perform_destroy(self, instance): user=self.request.user, event_name='{}_delete'.format(obj_type), description='delete {} {{{}}}'.format(obj_type, obj_type), - status_type='OK', + status_type=timeline.TL_STATUS_OK, ) tl_event.add_object( obj=instance, label=obj_type, - name=instance.get_path() - if isinstance(instance, Folder) - else instance.name, + name=( + instance.get_path() + if isinstance(instance, Folder) + else instance.name + ), ) @@ -136,16 +168,25 @@ def get_permission_required(self): class FolderListCreateAPIView( ListCreateAPITimelineMixin, ListCreatePermissionMixin, + FilesfoldersAPIVersioningMixin, CoreAPIGenericProjectMixin, ListCreateAPIView, ): """ List folders or create a folder. + Supports optional pagination for listing by providing the ``page`` query + string. This will return results in the Django Rest Framework + ``PageNumberPagination`` format. + **URL:** ``/files/api/folder/list-create/{Project.sodar_uuid}`` **Methods:** ``GET``, ``POST`` + **Parameters for GET:** + + - ``page``: Page number for paginated results (int, optional) + **Parameters for POST:** - ``name``: Folder name (string) @@ -155,13 +196,16 @@ class FolderListCreateAPIView( - ``description``: Folder description (string, optional) """ + pagination_class = SODARPageNumberPagination project_type = PROJECT_TYPE_PROJECT + schema = AutoSchema(operation_id_base='ListCreateFolder') serializer_class = FolderSerializer class FolderRetrieveUpdateDestroyAPIView( RetrieveUpdateDestroyAPITimelineMixin, RetrieveUpdateDestroyPermissionMixin, + FilesfoldersAPIVersioningMixin, CoreAPIGenericProjectMixin, RetrieveUpdateDestroyAPIView, ): @@ -184,12 +228,14 @@ class FolderRetrieveUpdateDestroyAPIView( lookup_field = 'sodar_uuid' lookup_url_kwarg = 'folder' project_type = PROJECT_TYPE_PROJECT + schema = AutoSchema(operation_id_base='UpdateDestroyFolder') serializer_class = FolderSerializer class FileListCreateAPIView( ListCreateAPITimelineMixin, ListCreatePermissionMixin, + FilesfoldersAPIVersioningMixin, CoreAPIGenericProjectMixin, ListCreateAPIView, ): @@ -197,10 +243,18 @@ class FileListCreateAPIView( List files or upload a file. For uploads, the request must be made in the ``multipart`` format. + Supports optional pagination for listing by providing the ``page`` query + string. This will return results in the Django Rest Framework + ``PageNumberPagination`` format. + **URL:** ``/files/api/file/list-create/{Project.sodar_uuid}`` **Methods:** ``GET``, ``POST`` + **Parameters for GET:** + + - ``page``: Page number for paginated results (int, optional) + **Parameters for POST:** - ``name``: Folder name (string) @@ -212,13 +266,16 @@ class FileListCreateAPIView( - ``file``: File to be uploaded """ + pagination_class = SODARPageNumberPagination project_type = PROJECT_TYPE_PROJECT + schema = AutoSchema(operation_id_base='ListCreateFile') serializer_class = FileSerializer class FileRetrieveUpdateDestroyAPIView( RetrieveUpdateDestroyAPITimelineMixin, RetrieveUpdateDestroyPermissionMixin, + FilesfoldersAPIVersioningMixin, CoreAPIGenericProjectMixin, RetrieveUpdateDestroyAPIView, ): @@ -243,11 +300,15 @@ class FileRetrieveUpdateDestroyAPIView( lookup_field = 'sodar_uuid' lookup_url_kwarg = 'file' project_type = PROJECT_TYPE_PROJECT + schema = AutoSchema(operation_id_base='UpdateDestroyFile') serializer_class = FileSerializer class FileServeAPIView( - CoreAPIGenericProjectMixin, FileServeMixin, GenericAPIView + FilesfoldersAPIVersioningMixin, + CoreAPIGenericProjectMixin, + FileServeMixin, + GenericAPIView, ): """ Serve the file content. @@ -260,21 +321,31 @@ class FileServeAPIView( lookup_field = 'sodar_uuid' lookup_url_kwarg = 'file' permission_required = 'filesfolders.view_data' + schema = None class HyperLinkListCreateAPIView( ListCreateAPITimelineMixin, ListCreatePermissionMixin, + FilesfoldersAPIVersioningMixin, CoreAPIGenericProjectMixin, ListCreateAPIView, ): """ List hyperlinks or create a hyperlink. + Supports optional pagination for listing by providing the ``page`` query + string. This will return results in the Django Rest Framework + ``PageNumberPagination`` format. + **URL:** ``/files/api/hyperlink/list-create/{Project.sodar_uuid}`` **Methods:** ``GET``, ``POST`` + **Parameters for GET:** + + - ``page``: Page number for paginated results (int, optional) + **Parameters for POST:** - ``name``: Folder name (string) @@ -285,13 +356,16 @@ class HyperLinkListCreateAPIView( - ``url``: URL for the link (string) """ + pagination_class = SODARPageNumberPagination project_type = PROJECT_TYPE_PROJECT + schema = AutoSchema(operation_id_base='ListCreateHyperLink') serializer_class = HyperLinkSerializer class HyperLinkRetrieveUpdateDestroyAPIView( RetrieveUpdateDestroyAPITimelineMixin, RetrieveUpdateDestroyPermissionMixin, + FilesfoldersAPIVersioningMixin, CoreAPIGenericProjectMixin, RetrieveUpdateDestroyAPIView, ): @@ -315,4 +389,5 @@ class HyperLinkRetrieveUpdateDestroyAPIView( lookup_field = 'sodar_uuid' lookup_url_kwarg = 'hyperlink' project_type = PROJECT_TYPE_PROJECT + schema = AutoSchema(operation_id_base='UpdateDestroyHyperLink') serializer_class = HyperLinkSerializer diff --git a/projectroles/_version.py b/projectroles/_version.py index af370468..d95d9576 100644 --- a/projectroles/_version.py +++ b/projectroles/_version.py @@ -5,7 +5,7 @@ # that just contains the computed version number. # This file is released into the public domain. -# Generated by versioneer-0.28 +# Generated by versioneer-0.29 # https://github.com/python-versioneer/python-versioneer """Git implementation of _version.py.""" @@ -15,11 +15,11 @@ import re import subprocess import sys -from typing import Callable, Dict +from typing import Any, Callable, Dict, List, Optional, Tuple import functools -def get_keywords(): +def get_keywords() -> Dict[str, str]: """Get the keywords needed to look up the version information.""" # these strings will be replaced by git during git-archive. # setup.py/versioneer.py will grep for the variable names, so they must @@ -35,8 +35,15 @@ def get_keywords(): class VersioneerConfig: """Container for Versioneer configuration parameters.""" + VCS: str + style: str + tag_prefix: str + parentdir_prefix: str + versionfile_source: str + verbose: bool -def get_config(): + +def get_config() -> VersioneerConfig: """Create, populate and return the VersioneerConfig() object.""" # these strings are filled in when 'setup.py versioneer' creates # _version.py @@ -58,10 +65,10 @@ class NotThisMethod(Exception): HANDLERS: Dict[str, Dict[str, Callable]] = {} -def register_vcs_handler(vcs, method): # decorator +def register_vcs_handler(vcs: str, method: str) -> Callable: # decorator """Create decorator to mark a method as the handler of a VCS.""" - def decorate(f): + def decorate(f: Callable) -> Callable: """Store f in HANDLERS[vcs][method].""" if vcs not in HANDLERS: HANDLERS[vcs] = {} @@ -72,13 +79,18 @@ def decorate(f): def run_command( - commands, args, cwd=None, verbose=False, hide_stderr=False, env=None -): + commands: List[str], + args: List[str], + cwd: Optional[str] = None, + verbose: bool = False, + hide_stderr: bool = False, + env: Optional[Dict[str, str]] = None, +) -> Tuple[Optional[str], Optional[int]]: """Call the given command(s).""" assert isinstance(commands, list) process = None - popen_kwargs = {} + popen_kwargs: Dict[str, Any] = {} if sys.platform == "win32": # This hides the console window if pythonw.exe is used startupinfo = subprocess.STARTUPINFO() @@ -98,8 +110,7 @@ def run_command( **popen_kwargs, ) break - except OSError: - e = sys.exc_info()[1] + except OSError as e: if e.errno == errno.ENOENT: continue if verbose: @@ -119,7 +130,11 @@ def run_command( return stdout, process.returncode -def versions_from_parentdir(parentdir_prefix, root, verbose): +def versions_from_parentdir( + parentdir_prefix: str, + root: str, + verbose: bool, +) -> Dict[str, Any]: """Try to determine the version from the parent directory name. Source tarballs conventionally unpack into a directory that includes both @@ -150,13 +165,13 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): @register_vcs_handler("git", "get_keywords") -def git_get_keywords(versionfile_abs): +def git_get_keywords(versionfile_abs: str) -> Dict[str, str]: """Extract version information from the given file.""" # the code embedded in _version.py can just fetch the value of these # keywords. When used from setup.py, we don't want to import _version.py, # so we do it with a regexp instead. This function is not used from # _version.py. - keywords = {} + keywords: Dict[str, str] = {} try: with open(versionfile_abs, "r") as fobj: for line in fobj: @@ -178,7 +193,11 @@ def git_get_keywords(versionfile_abs): @register_vcs_handler("git", "keywords") -def git_versions_from_keywords(keywords, tag_prefix, verbose): +def git_versions_from_keywords( + keywords: Dict[str, str], + tag_prefix: str, + verbose: bool, +) -> Dict[str, Any]: """Get version information from git keywords.""" if "refnames" not in keywords: raise NotThisMethod("Short version file found") @@ -249,7 +268,9 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): @register_vcs_handler("git", "pieces_from_vcs") -def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): +def git_pieces_from_vcs( + tag_prefix: str, root: str, verbose: bool, runner: Callable = run_command +) -> Dict[str, Any]: """Get version from 'git describe' in the root of the source tree. This only gets called if the git-archive 'subst' keywords were *not* @@ -299,7 +320,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): raise NotThisMethod("'git rev-parse' failed") full_out = full_out.strip() - pieces = {} + pieces: Dict[str, Any] = {} pieces["long"] = full_out pieces["short"] = full_out[:7] # maybe improved later pieces["error"] = None @@ -397,14 +418,14 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): return pieces -def plus_or_dot(pieces): +def plus_or_dot(pieces: Dict[str, Any]) -> str: """Return a + if we don't already have one, else return a .""" if "+" in pieces.get("closest-tag", ""): return "." return "+" -def render_pep440(pieces): +def render_pep440(pieces: Dict[str, Any]) -> str: """Build up version string, with post-release "local version identifier". Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you @@ -428,7 +449,7 @@ def render_pep440(pieces): return rendered -def render_pep440_branch(pieces): +def render_pep440_branch(pieces: Dict[str, Any]) -> str: """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] . The ".dev0" means not master branch. Note that .dev0 sorts backwards @@ -457,7 +478,7 @@ def render_pep440_branch(pieces): return rendered -def pep440_split_post(ver): +def pep440_split_post(ver: str) -> Tuple[str, Optional[int]]: """Split pep440 version string at the post-release segment. Returns the release segments before the post-release and the @@ -467,7 +488,7 @@ def pep440_split_post(ver): return vc[0], int(vc[1] or 0) if len(vc) == 2 else None -def render_pep440_pre(pieces): +def render_pep440_pre(pieces: Dict[str, Any]) -> str: """TAG[.postN.devDISTANCE] -- No -dirty. Exceptions: @@ -494,7 +515,7 @@ def render_pep440_pre(pieces): return rendered -def render_pep440_post(pieces): +def render_pep440_post(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]+gHEX] . The ".dev0" means dirty. Note that .dev0 sorts backwards @@ -521,7 +542,7 @@ def render_pep440_post(pieces): return rendered -def render_pep440_post_branch(pieces): +def render_pep440_post_branch(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] . The ".dev0" means not master branch. @@ -550,7 +571,7 @@ def render_pep440_post_branch(pieces): return rendered -def render_pep440_old(pieces): +def render_pep440_old(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]] . The ".dev0" means dirty. @@ -572,7 +593,7 @@ def render_pep440_old(pieces): return rendered -def render_git_describe(pieces): +def render_git_describe(pieces: Dict[str, Any]) -> str: """TAG[-DISTANCE-gHEX][-dirty]. Like 'git describe --tags --dirty --always'. @@ -592,7 +613,7 @@ def render_git_describe(pieces): return rendered -def render_git_describe_long(pieces): +def render_git_describe_long(pieces: Dict[str, Any]) -> str: """TAG-DISTANCE-gHEX[-dirty]. Like 'git describe --tags --dirty --always -long'. @@ -612,7 +633,7 @@ def render_git_describe_long(pieces): return rendered -def render(pieces, style): +def render(pieces: Dict[str, Any], style: str) -> Dict[str, Any]: """Render the given version pieces into the requested style.""" if pieces["error"]: return { @@ -654,7 +675,7 @@ def render(pieces, style): } -def get_versions(): +def get_versions() -> Dict[str, Any]: """Get version information or return default if unable to do so.""" # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have # __file__, we can work backwards from there to the root. Some diff --git a/projectroles/app_settings.py b/projectroles/app_settings.py index a6b50d14..6fe38362 100644 --- a/projectroles/app_settings.py +++ b/projectroles/app_settings.py @@ -7,6 +7,7 @@ from projectroles.models import AppSetting, APP_SETTING_TYPES, SODAR_CONSTANTS from projectroles.plugins import get_app_plugin, get_active_plugins +from projectroles.utils import get_display_name logger = logging.getLogger(__name__) @@ -22,9 +23,10 @@ PROJECT_TYPE_PROJECT = SODAR_CONSTANTS['PROJECT_TYPE_PROJECT'] PROJECT_TYPE_CATEGORY = SODAR_CONSTANTS['PROJECT_TYPE_CATEGORY'] SITE_MODE_SOURCE = SODAR_CONSTANTS['SITE_MODE_SOURCE'] +SITE_MODE_TARGET = SODAR_CONSTANTS['SITE_MODE_TARGET'] # Local constants -APP_SETTING_LOCAL_DEFAULT = True +APP_SETTING_GLOBAL_DEFAULT = False APP_SETTING_SCOPES = [ APP_SETTING_SCOPE_PROJECT, APP_SETTING_SCOPE_USER, @@ -44,7 +46,17 @@ APP_SETTING_SCOPE_SITE: {'project': False, 'user': False}, } DELETE_SCOPE_ERR_MSG = 'Argument "{arg}" must be set for {scope} scope setting' -OVERRIDE_ERR_MSG = 'Overriding global settings for remote projects not allowed' +GLOBAL_PROJECT_ERR_MSG = ( + 'Overriding global settings for remote projects not allowed' +) +GLOBAL_USER_ERR_MSG = ( + 'Overriding global user settings on target site not allowed' +) +# TODO: Remove in v1.1 (see #1394) +LOCAL_DEPRECATE_MSG = ( + 'The "local" argument for app settings has been deprecated and will be ' + 'removed in SODAR Core v1.1: use "global" instead' +) # Define App Settings for projectroles app @@ -63,7 +75,8 @@ #: 'options': ['example', 'example2'], # Optional, only for #: settings of type STRING or INTEGER #: 'user_modifiable': True, # Optional, show/hide in forms - #: 'local': False, # Allow editing in target site forms if True + #: 'global': True, # Only allow editing on target sites if False + #: # (optional, default True) #: 'project_types': [PROJECT_TYPE_PROJECT], # Optional, list may #: contain PROJECT_TYPE_CATEGORY and/or PROJECT_TYPE_PROJECT #: } @@ -74,7 +87,7 @@ 'label': 'IP restrict', 'description': 'Restrict project access by an allowed IP list', 'user_modifiable': True, - 'local': False, + 'global': True, }, 'ip_allowlist': { 'scope': APP_SETTING_SCOPE_PROJECT, @@ -83,27 +96,49 @@ 'label': 'IP allow list', 'description': 'List of allowed IPs for project access', 'user_modifiable': True, - 'local': False, - }, - 'user_email_additional': { - 'scope': APP_SETTING_SCOPE_USER, - 'type': 'STRING', - 'default': '', - 'placeholder': 'email1@example.com;email2@example.com', - 'label': 'Additional email', - 'description': 'Also send user emails to these addresses. Separate ' - 'multiple emails with semicolon.', - 'user_modifiable': True, - 'local': False, - 'project_types': [PROJECT_TYPE_PROJECT], + 'global': True, }, 'project_star': { 'scope': APP_SETTING_SCOPE_PROJECT_USER, 'type': 'BOOLEAN', 'default': False, - 'local': True, + 'global': False, 'project_types': [PROJECT_TYPE_PROJECT, PROJECT_TYPE_CATEGORY], }, + 'notify_email_project': { + 'scope': APP_SETTING_SCOPE_USER, + 'type': 'BOOLEAN', + 'default': True, + 'label': 'Receive email for {} updates'.format( + get_display_name(PROJECT_TYPE_PROJECT) + ), + 'description': ( + 'Receive email notifications for {} or {} creation, updating, ' + 'moving and archiving.'.format( + get_display_name(PROJECT_TYPE_CATEGORY), + get_display_name(PROJECT_TYPE_PROJECT), + ) + ), + 'user_modifiable': True, + 'global': True, + }, + 'notify_email_role': { + 'scope': APP_SETTING_SCOPE_USER, + 'type': 'BOOLEAN', + 'default': True, + 'label': 'Receive email for {} membership updates'.format( + get_display_name(PROJECT_TYPE_PROJECT) + ), + 'description': ( + 'Receive email notifications for {} or {} membership updates and ' + 'invitation activity.'.format( + get_display_name(PROJECT_TYPE_CATEGORY), + get_display_name(PROJECT_TYPE_PROJECT), + ) + ), + 'user_modifiable': True, + 'global': True, + }, } @@ -200,38 +235,50 @@ def _check_value_in_options( ) ) + # TODO: Remove in v1.1 (see #1394) @classmethod - def _get_app_plugin(cls, app_name): + def _check_local_attr(cls, setting_def): + """ + Warn if the deprecated "local" attribute is included in a settings + definition instead of the new "global" attribute. + + :param setting_def: Dict + """ + if 'local' in setting_def: + logger.warning(LOCAL_DEPRECATE_MSG) + + @classmethod + def _get_app_plugin(cls, plugin_name): """ Return app plugin by name. - :param app_name: Name of the app plugin (string or None) + :param plugin_name: Name of the app plugin (string) :return: App plugin object :raise: ValueError if plugin is not found with the name """ - plugin = get_app_plugin(app_name) + plugin = get_app_plugin(plugin_name) if not plugin: raise ValueError( - 'Plugin not found with app name "{}"'.format(app_name) + 'Plugin not found with name "{}"'.format(plugin_name) ) return plugin @classmethod - def _get_defs(cls, plugin=None, app_name=None): + def _get_defs(cls, plugin=None, plugin_name=None): """ Ensure valid argument values for a settings def query. :param plugin: Plugin object or None - :param app_name: Name of the app plugin (string or None) + :param plugin_name: Name of the app plugin (string or None) :return: Dict :raise: ValueError if args are not valid or plugin is not found """ - if not plugin and not app_name: - raise ValueError('Plugin and app name both unset') - if app_name == 'projectroles': + if not plugin and not plugin_name: + raise ValueError('Plugin object and name both unset') + if plugin_name == 'projectroles': return cls.get_projectroles_defs() if not plugin: - plugin = cls._get_app_plugin(app_name) + plugin = cls._get_app_plugin(plugin_name) return plugin.app_settings @classmethod @@ -250,9 +297,8 @@ def _get_json_value(cls, value): try: if isinstance(value, str): return json.loads(value) - else: - json.dumps(value) # Ensure this is valid - return value + json.dumps(value) # Ensure this is valid + return value except Exception: raise ValueError('Value is not valid JSON: {}'.format(value)) @@ -276,13 +322,13 @@ def _compare_value(cls, obj, input_value): @classmethod def _log_set_debug( - cls, action, app_name, setting_name, value, project, user + cls, action, plugin_name, setting_name, value, project, user ): """ Helper method for logging setting changes in set() method. :param action: Action string (string) - :param app_name: Plugin app name (string) + :param plugin_name: App plugin name (string) :param setting_name: Setting name (string) :param value: Setting value (string) :param project: Project object @@ -296,7 +342,7 @@ def _log_set_debug( logger.debug( '{} app setting: {}.{} = "{}"{}'.format( action, - app_name, + plugin_name, setting_name, value, ' ({})'.format('; '.join(extra_data)) if extra_data else '', @@ -305,12 +351,12 @@ def _log_set_debug( @classmethod def get_default( - cls, app_name, setting_name, project=None, user=None, post_safe=False + cls, plugin_name, setting_name, project=None, user=None, post_safe=False ): """ Get default setting value from an app plugin. - :param app_name: App name (string, must equal "name" in app plugin) + :param plugin_name: App plugin name (string, equals "name" in plugin) :param setting_name: Setting name (string) :param project: Project object (optional) :param user: User object (optional) @@ -319,55 +365,58 @@ def get_default( :raise: ValueError if app plugin is not found :raise: KeyError if nothing is found with setting_name """ - if app_name == 'projectroles': + if plugin_name == 'projectroles': app_settings = cls.get_projectroles_defs() else: - app_plugin = get_app_plugin(app_name) + app_plugin = get_app_plugin(plugin_name) if not app_plugin: - raise ValueError('App plugin not found: "{}"'.format(app_name)) + raise ValueError( + 'App plugin not found: "{}"'.format(plugin_name) + ) app_settings = app_plugin.app_settings + if setting_name not in app_settings: + raise KeyError( + 'Setting "{}" not found in app plugin "{}"'.format( + setting_name, plugin_name + ) + ) - if setting_name in app_settings: - if callable(app_settings[setting_name].get('default')): - try: - callable_setting = app_settings[setting_name]['default'] - return callable_setting(project, user) - except Exception: - logger.error( - 'Error in callable setting "{}" for app "{}"'.format( - setting_name, app_name - ) + # TODO: Remove _check_local_attr() in v1.1 (see #1394) + cls._check_local_attr(app_settings[setting_name]) + if callable(app_settings[setting_name].get('default')): + try: + callable_setting = app_settings[setting_name]['default'] + return callable_setting(project, user) + except Exception: + logger.error( + 'Error in callable setting "{}" for plugin "{}"'.format( + setting_name, plugin_name ) - return APP_SETTING_DEFAULT_VALUES[ - app_settings[setting_name]['type'] - ] - elif app_settings[setting_name]['type'] == 'JSON': - json_default = app_settings[setting_name].get('default') - if not json_default: - if isinstance(json_default, dict): - return {} - elif isinstance(json_default, list): - return [] + ) + return APP_SETTING_DEFAULT_VALUES[ + app_settings[setting_name]['type'] + ] + elif app_settings[setting_name]['type'] == 'JSON': + json_default = app_settings[setting_name].get('default') + if not json_default: + if isinstance(json_default, dict): return {} - if post_safe: - return json.dumps(app_settings[setting_name]['default']) - return app_settings[setting_name]['default'] - - raise KeyError( - 'Setting "{}" not found in app plugin "{}"'.format( - setting_name, app_name - ) - ) + elif isinstance(json_default, list): + return [] + return {} + if post_safe: + return json.dumps(app_settings[setting_name]['default']) + return app_settings[setting_name]['default'] @classmethod def get( - cls, app_name, setting_name, project=None, user=None, post_safe=False + cls, plugin_name, setting_name, project=None, user=None, post_safe=False ): """ Return app setting value for a project or a user. If not set, return default. - :param app_name: App name (string, must equal "name" in app plugin) + :param plugin_name: App plugin name (string, equals "name" in plugin) :param setting_name: Setting name (string) :param project: Project object (optional) :param user: User object (optional) @@ -378,11 +427,11 @@ def get( if not user or user.is_authenticated: try: val = AppSetting.objects.get_setting_value( - app_name, setting_name, project=project, user=user + plugin_name, setting_name, project=project, user=user ) except AppSetting.DoesNotExist: val = cls.get_default( - app_name, + plugin_name, setting_name, project=project, user=user, @@ -390,7 +439,7 @@ def get( ) else: # Anonymous user val = cls.get_default( - app_name, + plugin_name, setting_name, project=project, user=user, @@ -428,7 +477,7 @@ def get_all(cls, project=None, user=None, post_safe=False): ) p_settings = cls.get_definitions( - APP_SETTING_SCOPE_PROJECT, app_name='projectroles' + APP_SETTING_SCOPE_PROJECT, plugin_name='projectroles' ) for s_key in p_settings: ret['settings.{}.{}'.format('projectroles', s_key)] = cls.get( @@ -454,33 +503,33 @@ def get_defaults(cls, scope, project=None, user=None, post_safe=False): for plugin in app_plugins: p_settings = cls.get_definitions(scope, plugin=plugin) for s_key in p_settings: - ret[ - 'settings.{}.{}'.format(plugin.name, s_key) - ] = cls.get_default( - plugin.name, + ret['settings.{}.{}'.format(plugin.name, s_key)] = ( + cls.get_default( + plugin.name, + s_key, + project=project, + user=user, + post_safe=post_safe, + ) + ) + + p_settings = cls.get_definitions(scope, plugin_name='projectroles') + for s_key in p_settings: + ret['settings.{}.{}'.format('projectroles', s_key)] = ( + cls.get_default( + 'projectroles', s_key, project=project, user=user, post_safe=post_safe, ) - - p_settings = cls.get_definitions(scope, app_name='projectroles') - for s_key in p_settings: - ret[ - 'settings.{}.{}'.format('projectroles', s_key) - ] = cls.get_default( - 'projectroles', - s_key, - project=project, - user=user, - post_safe=post_safe, ) return ret @classmethod def set( cls, - app_name, + plugin_name, setting_name, value, project=None, @@ -491,7 +540,7 @@ def set( Set value of an existing project or user settings. Creates the object if not found. - :param app_name: App name (string, must equal "name" in app plugin) + :param plugin_name: App plugin name (string, equals "name" in plugin) :param setting_name: Setting name (string) :param value: Value to be set :param project: Project object (optional) @@ -503,7 +552,7 @@ def set( :raise: ValueError if neither project nor user are set :raise: KeyError if setting name is not found in plugin specification """ - s_def = cls.get_definition(name=setting_name, app_name=app_name) + s_def = cls.get_definition(name=setting_name, plugin_name=plugin_name) cls._check_scope(s_def.get('scope', None)) cls._check_project_and_user(s_def.get('scope', None), project, user) # Check project type @@ -520,19 +569,23 @@ def set( project.type, setting_name ) ) - # Prevent updating global setting on remote project - # TODO: Prevent editing global USER settings (#1329) - if ( - not s_def.get('local', APP_SETTING_LOCAL_DEFAULT) - and project - and project.is_remote() - ): - raise ValueError(OVERRIDE_ERR_MSG) + # Prevent updating global setting on target site + if cls.get_global_value(s_def): + if project and project.is_remote(): + raise ValueError(GLOBAL_PROJECT_ERR_MSG) + if ( + user + and not project + and settings.PROJECTROLES_SITE_MODE == SITE_MODE_TARGET + ): + raise ValueError(GLOBAL_USER_ERR_MSG) try: # Update existing setting q_kwargs = {'name': setting_name, 'project': project, 'user': user} - if not app_name == 'projectroles': - q_kwargs['app_plugin__name'] = app_name + if not plugin_name == 'projectroles': + q_kwargs['app_plugin__name'] = plugin_name + else: + q_kwargs['app_plugin'] = None setting = AppSetting.objects.get(**q_kwargs) if cls._compare_value(setting, value): return False @@ -550,16 +603,16 @@ def set( setting.value = value setting.save() cls._log_set_debug( - 'Set', app_name, setting_name, value, project, user + 'Set', plugin_name, setting_name, value, project, user ) return True except AppSetting.DoesNotExist: # Create new s_type = s_def['type'] - if app_name == 'projectroles': + if plugin_name == 'projectroles': app_plugin_model = None else: - app_plugin = get_app_plugin(app_name) + app_plugin = get_app_plugin(plugin_name) app_plugin_model = app_plugin.get_model() if validate: v = cls._get_json_value(value) if s_type == 'JSON' else value @@ -589,23 +642,46 @@ def set( s_vals['value'] = value AppSetting.objects.create(**s_vals) cls._log_set_debug( - 'Create', app_name, setting_name, value, project, user + 'Create', plugin_name, setting_name, value, project, user ) return True @classmethod - def delete(cls, app_name, setting_name, project=None, user=None): + def is_set(cls, plugin_name, setting_name, project=None, user=None): + """ + Return True if the setting has been set, instead of retrieving the + default value from the definition. + + NOTE: Also returns True if the current set value equals the default. + + :param plugin_name: App plugin name (string, equals "name" in plugin) + :param setting_name: Setting name (string) + :param project: Project object (optional) + :param user: User object (optional) + :return: Boolean + """ + s_def = cls.get_definition(name=setting_name, plugin_name=plugin_name) + cls._check_project_and_user(s_def.get('scope', None), project, user) + q_kwargs = {'name': setting_name, 'project': project, 'user': user} + if not plugin_name == 'projectroles': + q_kwargs['app_plugin__name'] = plugin_name + else: + q_kwargs['app_plugin'] = None + return AppSetting.objects.filter(**q_kwargs).exists() + + @classmethod + def delete(cls, plugin_name, setting_name, project=None, user=None): """ Delete one or more app setting objects. In case of a PROJECT_USER setting, can be used to delete all settings related to project. - :param app_name: App name (string, must equal "name" in app plugin) + :param plugin_name: App plugin name (string, equals "name" in plugin) :param setting_name: Setting name (string) :param project: Project object to delete setting from (optional) :param user: User object to delete setting from (optional) :raise: ValueError with invalid project/user args """ - setting_def = cls.get_definition(setting_name, app_name=app_name) + setting_def = cls.get_definition(setting_name, plugin_name=plugin_name) if setting_def['scope'] != APP_SETTING_SCOPE_PROJECT_USER: cls._check_project_and_user( setting_def.get('scope', None), project, user @@ -623,7 +699,7 @@ def delete(cls, app_name, setting_name, project=None, user=None): q_kwargs['project'] = project logger.debug( 'Delete app setting: {}.{} ({})'.format( - app_name, setting_name, '; '.join(q_kwargs) + plugin_name, setting_name, '; '.join(q_kwargs) ) ) app_settings = AppSetting.objects.filter(**q_kwargs) @@ -654,11 +730,11 @@ def delete_by_scope( raise ValueError('Scope must be set') cls._check_scope(scope) cls._check_project_and_user(scope, project, user) - for app_name, app_settings in cls.get_all_defs().items(): + for plugin_name, app_settings in cls.get_all_defs().items(): for setting_name, setting_def in app_settings.items(): if setting_def['scope'] == scope: cls.delete( - app_name, + plugin_name, setting_name, project=project, user=user, @@ -720,23 +796,23 @@ def validate( return True @classmethod - def get_definition(cls, name, plugin=None, app_name=None): + def get_definition(cls, name, plugin=None, plugin_name=None): """ Return definition for a single app setting, either based on an app name or the plugin object. :param name: Setting name :param plugin: Plugin object or None - :param app_name: Name of the app plugin (string or None) + :param plugin_name: Name of the app plugin (string or None) :return: Dict - :raise: ValueError if neither app_name nor plugin are set or if setting - is not found in plugin + :raise: ValueError if neither plugin_name nor plugin are set, or if + setting is not found in plugin """ - defs = cls._get_defs(plugin, app_name) + defs = cls._get_defs(plugin, plugin_name) if name not in defs: raise ValueError( - 'App setting not found in app "{}" with name "{}"'.format( - app_name or plugin.name, name + 'App setting not found in plugin "{}" with name "{}"'.format( + plugin_name or plugin.name, name ) ) ret = defs[name] @@ -749,7 +825,7 @@ def get_definitions( cls, scope, plugin=None, - app_name=None, + plugin_name=None, user_modifiable=False, ): """ @@ -757,15 +833,15 @@ def get_definitions( :param scope: PROJECT, USER or PROJECT_USER :param plugin: Plugin object or None - :param app_name: Name of the app plugin (string or None) + :param plugin_name: App plugin name (string, equals "name" in plugin) :param user_modifiable: Only return non-superuser modifiable settings if True (boolean) :return: Dict - :raise: ValueError if scope is invalid or if neither app_name nor + :raise: ValueError if scope is invalid or if neither plugin_name nor plugin are set """ cls._check_scope(scope) - defs = cls._get_defs(plugin, app_name) + defs = cls._get_defs(plugin, plugin_name) ret = { k: v for k, v in defs.items() @@ -802,12 +878,6 @@ def get_projectroles_defs(cls): ) except AttributeError: app_settings = PROJECTROLES_APP_SETTINGS - for k, v in app_settings.items(): - if 'local' not in v: - raise ValueError( - 'Attribute "local" is missing in projectroles app ' - 'setting definition "{}"'.format(k) - ) return app_settings @classmethod @@ -828,6 +898,20 @@ def get_all_defs(cls): ret[p.name] = p.app_settings return ret + @classmethod + def get_global_value(cls, setting_def): + """ + Get the "global" value of a settings definition. If the deprecated + "local" value is still used, return that and log a warning. + + :param setting_def: Dict + :return: Boolean + """ + if 'local' in setting_def: # TODO: Remove in v1.1 (see #1394) + logger.warning(LOCAL_DEPRECATE_MSG) + return not setting_def['local'] # Inverse value + return setting_def.get('global', APP_SETTING_GLOBAL_DEFAULT) + def get_example_setting_default(project=None, user=None): """ diff --git a/projectroles/apps.py b/projectroles/apps.py index 3118ab5d..01206489 100644 --- a/projectroles/apps.py +++ b/projectroles/apps.py @@ -5,4 +5,5 @@ class ProjectrolesConfig(AppConfig): name = 'projectroles' def ready(self): + import projectroles.checks # noqa import projectroles.signals # noqa diff --git a/projectroles/checks.py b/projectroles/checks.py new file mode 100644 index 00000000..c98ed831 --- /dev/null +++ b/projectroles/checks.py @@ -0,0 +1,31 @@ +"""Django checks for the projectroles app""" + +from django.conf import settings +from django.core.checks import Warning, register + + +# Local constants +W001_SETTINGS = [ + 'ENABLE_LDAP', + 'ENABLE_OIDC', + 'PROJECTROLES_ALLOW_ANONYMOUS', + 'PROJECTROLES_ALLOW_LOCAL_USERS', + 'PROJECTROLES_KIOSK_MODE', +] +W001_MSG = ( + 'No authentication methods enabled, only superusers can access the site. ' + 'Set one or more of the following: {}'.format(', '.join(W001_SETTINGS)) +) +W001 = Warning(W001_MSG, obj=settings, id='projectroles.W001') + + +@register() +def check_auth_methods(app_configs, **kwargs): + """ + Check for enabled authentication schemes. Raise error if no users other than + superusers are able to log in with the current settings). + """ + ret = [] + if not any([getattr(settings, a, False) for a in W001_SETTINGS]): + ret.append(W001) + return ret diff --git a/projectroles/constants.py b/projectroles/constants.py index def85f57..156a4d3e 100644 --- a/projectroles/constants.py +++ b/projectroles/constants.py @@ -1,6 +1,5 @@ """SODAR constants definition and helper functions""" - # Global SODAR constants SODAR_CONSTANTS = { # Project roles @@ -42,8 +41,13 @@ 'CATEGORY': {'default': 'category', 'plural': 'categories'}, 'PROJECT': {'default': 'project', 'plural': 'projects'}, }, + # User types + 'AUTH_TYPE_LOCAL': 'LOCAL', + 'AUTH_TYPE_LDAP': 'LDAP', + 'AUTH_TYPE_OIDC': 'OIDC', # System user group 'SYSTEM_USER_GROUP': 'system', + 'OIDC_USER_GROUP': 'oidc', # Project modification 'PROJECT_ACTION_CREATE': 'CREATE', 'PROJECT_ACTION_UPDATE': 'UPDATE', diff --git a/projectroles/context_processors.py b/projectroles/context_processors.py index eda947ea..2232faf0 100644 --- a/projectroles/context_processors.py +++ b/projectroles/context_processors.py @@ -6,6 +6,7 @@ from projectroles.plugins import get_active_plugins, get_backend_api from projectroles.urls import urlpatterns +from projectroles.utils import ROLE_URLS SIDEBAR_ICON_MIN_SIZE = 18 @@ -16,16 +17,7 @@ def urls_processor(request): """Context processor for providing projectroles URLs for the sidebar""" return { 'projectroles_urls': urlpatterns, - 'role_urls': [ - 'roles', - 'role_create', - 'role_update', - 'role_delete', - 'invites', - 'invite_create', - 'invite_resend', - 'invite_revoke', - ], + 'role_urls': ROLE_URLS, } diff --git a/projectroles/email.py b/projectroles/email.py index 69361055..8bfa7be0 100644 --- a/projectroles/email.py +++ b/projectroles/email.py @@ -10,6 +10,8 @@ from django.utils.timezone import localtime 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 @@ -25,6 +27,7 @@ SITE_TITLE = settings.SITE_INSTANCE_TITLE # Local constants +APP_NAME = 'projectroles' EMAIL_RE = re.compile(r'(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)') @@ -52,6 +55,11 @@ contact {admin_name} ({admin_email}). ''' +SETTINGS_LINK = r''' +You can manage receiving of automated emails in your user settings: +{url} +''' + # Role Change Template --------------------------------------------------------- @@ -275,23 +283,33 @@ def get_email_header(header): return getattr(settings, 'PROJECTROLES_EMAIL_HEADER', None) or header -def get_email_footer(): +def get_email_footer(request, settings_link=True): """ Return the email footer. + :param request: HttpRequest object + :param settings_link: Include link to user settings if True (optional) :return: string """ + footer = '' custom_footer = getattr(settings, 'PROJECTROLES_EMAIL_FOOTER', None) - if custom_footer: - return '\n' + custom_footer admin_recipient = settings.ADMINS[0] if settings.ADMINS else None - if admin_recipient: - return MESSAGE_FOOTER.format( + if custom_footer: + footer += '\n' + custom_footer + elif admin_recipient: + footer += MESSAGE_FOOTER.format( site_title=SITE_TITLE, admin_name=admin_recipient[0], admin_email=admin_recipient[1], ) - return '' + # Add user settings link if enabled and userprofile app is active + if request and settings_link and get_app_plugin('userprofile'): + footer += SETTINGS_LINK.format( + url=request.build_absolute_uri( + reverse('userprofile:settings_update') + ) + ) + return footer def get_invite_subject(project): @@ -329,7 +347,7 @@ def get_role_change_subject(change_type, project): def get_role_change_body( - change_type, project, user_name, role_name, issuer, project_url + change_type, project, user_name, role_name, issuer, request ): """ Return role change email body. @@ -339,9 +357,12 @@ def get_role_change_body( :param user_name: Name of target user :param role_name: Name of role as string :param issuer: User object for issuing user - :param project_url: URL for the project + :param request: HttpRequest object :return: String """ + project_url = request.build_absolute_uri( + reverse('projectroles:detail', kwargs={'project': project.sodar_uuid}) + ) body = get_email_header( MESSAGE_HEADER.format(recipient=user_name, site_title=SITE_TITLE) ) @@ -373,49 +394,48 @@ def get_role_change_body( ) if not issuer.email and not settings.PROJECTROLES_EMAIL_SENDER_REPLY: body += NO_REPLY_NOTE - body += get_email_footer() + body += get_email_footer(request) return body def get_user_addr(user): """ - Return all the email addresses for a user as a list. Emails set with - user_email_additional are included. If a user has no main email set but - additional emails exist, the latter are returned. + Return all the email addresses for a user as a list. Verified emails set as + SODARUserAdditionalEmail objects are included. If a user has no main email + set but additional emails exist, the latter are returned. :param user: User object :return: list """ - - def _validate(user, email): - if re.match(EMAIL_RE, email): - return True - logger.error( - 'Invalid email for user {}: {}'.format(user.username, email) - ) - ret = [] - if user.email and _validate(user, user.email): + if user.email: ret.append(user.email) - add_email = app_settings.get( - 'projectroles', 'user_email_additional', user=user - ) - if add_email: - for e in add_email.strip().split(';'): - if _validate(user, e): - ret.append(e) + for e in SODARUserAdditionalEmail.objects.filter( + user=user, verified=True + ).order_by('email'): + ret.append(e.email) return ret -def send_mail(subject, message, recipient_list, request=None, reply_to=None): +def send_mail( + subject, + message, + recipient_list, + request=None, + reply_to=None, + cc=None, + bcc=None, +): """ Wrapper for send_mail() with logging and error messaging. :param subject: Message subject (string) :param message: Message body (string) :param recipient_list: Recipients of email (list) - :param request: Request object (optional) + :param request: HttpRequest object (optional) :param reply_to: List of emails for the "reply-to" header (optional) + :param cc: List of emails for "cc" field (optional) + :param bcc: List of emails for "bcc" field (optional) :return: Amount of sent email (int) """ try: @@ -425,6 +445,8 @@ def send_mail(subject, message, recipient_list, request=None, reply_to=None): from_email=EMAIL_SENDER, to=recipient_list, reply_to=reply_to if isinstance(reply_to, list) else [], + cc=cc if isinstance(cc, list) else [], + bcc=bcc if isinstance(bcc, list) else [], ) ret = e.send(fail_silently=False) logger.debug( @@ -433,7 +455,6 @@ def send_mail(subject, message, recipient_list, request=None, reply_to=None): ) ) return ret - except Exception as ex: error_msg = 'Error sending email: {}'.format(str(ex)) logger.error(error_msg) @@ -455,12 +476,9 @@ def send_role_change_mail(change_type, project, user, role, request): :param project: Project object :param user: User object :param role: Role object (can be None for deletion) - :param request: HTTP request + :param request: HttpRequest object :return: Amount of sent email (int) """ - project_url = request.build_absolute_uri( - reverse('projectroles:detail', kwargs={'project': project.sodar_uuid}) - ) subject = get_role_change_subject(change_type, project) message = get_role_change_body( change_type=change_type, @@ -468,7 +486,7 @@ def send_role_change_mail(change_type, project, user, role, request): user_name=user.get_full_name(), role_name=role.name if role else '', issuer=request.user, - project_url=project_url, + request=request, ) issuer_emails = get_user_addr(request.user) return send_mail( @@ -481,7 +499,7 @@ def send_invite_mail(invite, request): Send an email invitation to user not yet registered in the system. :param invite: ProjectInvite object - :param request: HTTP request + :param request: HttpRequest object :return: Amount of sent email (int) """ invite_url = build_invite_url(invite, request) @@ -495,7 +513,7 @@ def send_invite_mail(invite, request): ), ) message += get_invite_message(invite.message) - message += get_email_footer() + message += get_email_footer(request, settings_link=False) subject = get_invite_subject(invite.project) issuer_emails = get_user_addr(invite.issuer) return send_mail(subject, message, [invite.email], request, issuer_emails) @@ -507,7 +525,7 @@ def send_accept_note(invite, request, user): accepts the invitation. :param invite: ProjectInvite object - :param request: HTTP request + :param request: HttpRequest object :param user: User invited :return: Amount of sent email (int) """ @@ -531,7 +549,7 @@ def send_accept_note(invite, request, user): if not settings.PROJECTROLES_EMAIL_SENDER_REPLY: message += NO_REPLY_NOTE - message += get_email_footer() + message += get_email_footer(request) return send_mail(subject, message, get_user_addr(invite.issuer), request) @@ -541,7 +559,7 @@ def send_expiry_note(invite, request, user_name): attempts to accept an expired invitation. :param invite: ProjectInvite object - :param request: HTTP request + :param request: HttpRequest object :param user_name: User name of invited user :return: Amount of sent email (int) """ @@ -565,7 +583,7 @@ def send_expiry_note(invite, request, user_name): if not settings.PROJECTROLES_EMAIL_SENDER_REPLY: message += NO_REPLY_NOTE - message += get_email_footer() + message += get_email_footer(request) return send_mail(subject, message, get_user_addr(invite.issuer), request) @@ -575,7 +593,7 @@ def send_project_create_mail(project, request): they are a different user than the project creator. :param project: Project object for the newly created project - :param request: Request object + :param request: HttpRequest object :return: Amount of sent email (int) """ parent = project.parent @@ -606,7 +624,7 @@ def send_project_create_mail(project, request): ) ), ) - message += get_email_footer() + message += get_email_footer(request) return send_mail( subject, message, @@ -622,7 +640,7 @@ def send_project_move_mail(project, request): they are a different user than the project creator. :param project: Project object for the newly created project - :param request: Request object + :param request: HttpRequest object :return: Amount of sent email (int) """ parent = project.parent @@ -653,7 +671,7 @@ def send_project_move_mail(project, request): ) ), ) - message += get_email_footer() + message += get_email_footer(request) return send_mail( subject, message, @@ -669,11 +687,16 @@ def send_project_archive_mail(project, action, request): :param project: Project object :param action: String ("archive" or "unarchive") - :param request: HTTP request + :param request: HttpRequest object :return: Amount of sent email (int) """ user = request.user - project_users = [a.user for a in project.get_roles() if a.user != user] + project_users = [ + a.user + for a in project.get_roles() + if a.user != user + and app_settings.get(APP_NAME, 'notify_email_project', user=a.user) + ] project_users = list(set(project_users)) # Remove possible dupes (see #710) if not project_users: return 0 @@ -711,23 +734,33 @@ def send_project_archive_mail(project, action, request): message += body_final if not settings.PROJECTROLES_EMAIL_SENDER_REPLY: message += NO_REPLY_NOTE - message += get_email_footer() + message += get_email_footer(request) mail_count += send_mail(subject, message, get_user_addr(user), request) return mail_count def send_generic_mail( - subject_body, message_body, recipient_list, request=None, reply_to=None + subject_body, + message_body, + recipient_list, + request=None, + reply_to=None, + cc=None, + bcc=None, + settings_link=True, ): """ - Send a notification email to the issuer of an invitation when a user - attempts to accept an expired invitation. + Send a generic mail with standard header and footer and no-reply + notifications. :param subject_body: Subject body without prefix (string) :param message_body: Message body before header or footer (string) :param recipient_list: Recipients (list of User objects or email strings) + :param request: HttpRequest object (optional) :param reply_to: List of emails for the "reply-to" header (optional) - :param request: Request object (optional) + :param cc: List of emails for "cc" field (optional) + :param bcc: List of emails for "bcc" field (optional) + :param settings_link: Include link to user settings if True (optional) :return: Amount of mail sent (int) """ subject = SUBJECT_PREFIX + subject_body @@ -745,6 +778,8 @@ def send_generic_mail( message += message_body if not reply_to and not settings.PROJECTROLES_EMAIL_SENDER_REPLY: message += NO_REPLY_NOTE - message += get_email_footer() - ret += send_mail(subject, message, recp_addr, request, reply_to) + message += get_email_footer(request, settings_link) + ret += send_mail( + subject, message, recp_addr, request, reply_to, cc, bcc + ) return ret diff --git a/projectroles/forms.py b/projectroles/forms.py index fcbd4ff6..e734076e 100644 --- a/projectroles/forms.py +++ b/projectroles/forms.py @@ -21,9 +21,9 @@ RoleAssignment, ProjectInvite, RemoteSite, + RemoteProject, SODAR_CONSTANTS, ROLE_RANKING, - APP_SETTING_VAL_MAXLENGTH, CAT_DELIMITER, CAT_DELIMITER_ERROR_MSG, ) @@ -34,7 +34,7 @@ get_user_display_name, build_secret, ) -from projectroles.app_settings import AppSettingAPI, APP_SETTING_LOCAL_DEFAULT +from projectroles.app_settings import AppSettingAPI User = auth.get_user_model() @@ -50,6 +50,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' @@ -62,10 +63,12 @@ ), (PROJECT_TYPE_PROJECT, get_display_name(PROJECT_TYPE_PROJECT, title=True)), ] -SETTING_CUSTOM_VALIDATE_ERROR = ( +SETTING_DISABLE_LABEL = '[DISABLED]' +SETTING_CUSTOM_VALIDATE_MSG = ( 'Exception in custom app setting validation for plugin "{plugin}": ' '{exception}' ) +SETTING_SOURCE_ONLY_MSG = '[Only editable on source site]' # Base Classes and Mixins ------------------------------------------------------ @@ -357,29 +360,113 @@ 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, app_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 app_name: App name + :param plugin_name: App plugin name :param s_field: Form field name :param s_key: Setting key :param s_val: Setting value """ s_widget_attrs = s_val.get('widget_attrs') or {} - - # Set project type s_project_types = s_val.get('project_types') or [PROJECT_TYPE_PROJECT] s_widget_attrs['data-project-types'] = ','.join(s_project_types).lower() - if 'placeholder' in s_val: s_widget_attrs['placeholder'] = s_val.get('placeholder') setting_kwargs = { 'required': False, - 'label': s_val.get('label') or '{}.{}'.format(app_name, s_key), + 'label': s_val.get('label') or '{}.{}'.format(plugin_name, s_key), 'help_text': s_val['description'], } - if s_val['type'] == 'JSON': + + # Option + if ( + s_val.get('options') + and callable(s_val['options']) + and self.instance.pk + ): + values = s_val['options'](project=self.instance) + self.fields[s_field] = forms.ChoiceField( + choices=[ + ( + (str(value[0]), str(value[1])) + if isinstance(value, tuple) + else (str(value), str(value)) + ) + for value in values + ], + **setting_kwargs + ) + elif ( + s_val.get('options') + and callable(s_val['options']) + and not self.instance.pk + ): + values = s_val['options'](project=None) + self.fields[s_field] = forms.ChoiceField( + choices=[ + ( + (str(value[0]), str(value[1])) + if isinstance(value, tuple) + else (str(value), str(value)) + ) + for value in values + ], + **setting_kwargs + ) + elif s_val.get('options'): + self.fields[s_field] = forms.ChoiceField( + choices=[ + ( + (int(option), int(option)) + if s_val['type'] == 'INTEGER' + else (option, option) + ) + for option in s_val['options'] + ], + **setting_kwargs + ) + # Other types + elif s_val['type'] == 'STRING': + self.fields[s_field] = forms.CharField( + widget=forms.TextInput(attrs=s_widget_attrs), **setting_kwargs + ) + elif s_val['type'] == 'INTEGER': + self.fields[s_field] = forms.IntegerField( + widget=forms.NumberInput(attrs=s_widget_attrs), **setting_kwargs + ) + elif s_val['type'] == 'BOOLEAN': + self.fields[s_field] = forms.BooleanField(**setting_kwargs) + # JSON + elif s_val['type'] == 'JSON': # NOTE: Attrs MUST be supplied here (#404) if 'class' in s_widget_attrs: s_widget_attrs['class'] += ' sodar-json-input' @@ -388,84 +475,19 @@ def _set_app_setting_widget(self, app_name, s_field, s_key, s_val): self.fields[s_field] = forms.CharField( widget=forms.Textarea(attrs=s_widget_attrs), **setting_kwargs ) - if self.instance.pk: - json_data = self.app_settings.get( - app_name=app_name, - setting_name=s_key, - project=self.instance, - ) - else: - json_data = self.app_settings.get_default( - app_name=app_name, - setting_name=s_key, - project=None, - ) - self.initial[s_field] = json.dumps(json_data) - else: - if s_val.get('options'): - if callable(s_val['options']) and self.instance.pk: - values = s_val['options'](project=self.instance) - self.fields[s_field] = forms.ChoiceField( - choices=[ - (str(value[0]), str(value[1])) - if isinstance(value, tuple) - else (str(value), str(value)) - for value in values - ], - **setting_kwargs - ) - elif callable(s_val['options']) and not self.instance.pk: - values = s_val['options'](project=None) - self.fields[s_field] = forms.ChoiceField( - choices=[ - (str(value[0]), str(value[1])) - if isinstance(value, tuple) - else (str(value), str(value)) - for value in values - ], - **setting_kwargs - ) - else: - self.fields[s_field] = forms.ChoiceField( - choices=[ - (int(option), int(option)) - if s_val['type'] == 'INTEGER' - else (option, option) - for option in s_val['options'] - ], - **setting_kwargs - ) - elif s_val['type'] == 'STRING': - self.fields[s_field] = forms.CharField( - max_length=APP_SETTING_VAL_MAXLENGTH, - widget=forms.TextInput(attrs=s_widget_attrs), - **setting_kwargs - ) - elif s_val['type'] == 'INTEGER': - self.fields[s_field] = forms.IntegerField( - widget=forms.NumberInput(attrs=s_widget_attrs), - **setting_kwargs - ) - elif s_val['type'] == 'BOOLEAN': - self.fields[s_field] = forms.BooleanField(**setting_kwargs) - # Add optional attributes from plugin (#404) - # NOTE: Experimental! Use at your own risk! - self.fields[s_field].widget.attrs.update(s_widget_attrs) - - # Set initial value - if self.instance.pk: - self.initial[s_field] = self.app_settings.get( - app_name=app_name, - setting_name=s_key, - project=self.instance, - ) - else: - self.initial[s_field] = self.app_settings.get_default( - app_name=app_name, - setting_name=s_key, - project=None, - ) + # Add optional attributes from plugin (#404) + # NOTE: Experimental! Use at your own risk! + self.fields[s_field].widget.attrs.update(s_widget_attrs) + # Set initial value + value = self.app_settings.get( + plugin_name=plugin_name, + setting_name=s_key, + project=self.instance if self.instance.pk else None, + ) + if s_val['type'] == 'JSON': + value = json.dumps(value) + self.initial[s_field] = value def _set_app_setting_notes(self, s_field, s_val, plugin): """ @@ -478,12 +500,10 @@ def _set_app_setting_notes(self, s_field, s_val, plugin): if s_val.get('user_modifiable') is False: self.fields[s_field].label += ' [HIDDEN]' self.fields[s_field].help_text += ' [HIDDEN FROM USERS]' - if s_val.get('local', APP_SETTING_LOCAL_DEFAULT) is False: + if self.app_settings.get_global_value(s_val): if self.instance.is_remote(): - self.fields[s_field].label += ' [DISABLED]' - self.fields[ - s_field - ].help_text += ' [Only editable on source site]' + self.fields[s_field].label += ' ' + SETTING_DISABLE_LABEL + self.fields[s_field].help_text += ' ' + SETTING_SOURCE_ONLY_MSG self.fields[s_field].disabled = True else: self.fields[ @@ -494,6 +514,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 @@ -503,21 +526,21 @@ def _init_app_settings(self): self.app_plugins = sorted(get_active_plugins(), key=lambda x: x.name) for plugin in self.app_plugins + [None]: # Projectroles has no plugin if plugin: - app_name = plugin.name + plugin_name = plugin.name p_settings = self.app_settings.get_definitions( APP_SETTING_SCOPE_PROJECT, plugin=plugin, **self.p_kwargs ) else: - app_name = APP_NAME + plugin_name = APP_NAME p_settings = self.app_settings.get_definitions( APP_SETTING_SCOPE_PROJECT, - app_name=app_name, + plugin_name=plugin_name, **self.p_kwargs ) for s_key, s_val in p_settings.items(): - s_field = 'settings.{}.{}'.format(app_name, s_key) - # Set widget and value - self._set_app_setting_widget(app_name, s_field, s_key, s_val) + s_field = 'settings.{}.{}'.format(plugin_name, s_key) + # 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) @@ -538,7 +561,7 @@ def _validate_app_settings( def_kwarg = {'plugin': plugin} else: p_name = 'projectroles' - def_kwarg = {'app_name': p_name} + def_kwarg = {'plugin_name': p_name} p_defs = app_settings.get_definitions( APP_SETTING_SCOPE_PROJECT, **{**p_kwargs, **def_kwarg} ) @@ -580,7 +603,7 @@ def _validate_app_settings( f_name = '.'.join(['settings', p_name, field]) errors.append((f_name, error)) except Exception as ex: - err_msg = SETTING_CUSTOM_VALIDATE_ERROR.format( + err_msg = SETTING_CUSTOM_VALIDATE_MSG.format( plugin=p_name, exception=ex ) errors.append((None, err_msg)) @@ -595,12 +618,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' @@ -662,9 +691,9 @@ def __init__(self, project=None, current_user=None, *args, **kwargs): self.initial['owner'] = parent_project.get_owner().user else: self.initial['owner'] = self.current_user - self.fields[ - 'owner' - ].label_from_instance = lambda x: x.get_form_label(email=True) + self.fields['owner'].label_from_instance = ( + lambda x: x.get_form_label(email=True) + ) # Hide owner select widget for regular users if not self.current_user.is_superuser: self.fields['owner'].widget = forms.HiddenInput() @@ -697,8 +726,8 @@ def __init__(self, project=None, current_user=None, *args, **kwargs): def clean(self): """Function for custom form validation and cleanup""" self.instance_owner_as = ( - self.instance.get_owner() if self.instance else None - ) + self.instance.get_owner() if self.instance.pk else None + ) # Must check pk, get_owner() with unsaved model fails in Django v4+ disable_categories = getattr( settings, 'PROJECTROLES_DISABLE_CATEGORIES', False ) @@ -771,7 +800,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, @@ -1115,7 +1144,7 @@ def clean(self): ] if ( not settings.PROJECTROLES_ALLOW_LOCAL_USERS - and not settings.ENABLE_SAML + and not settings.ENABLE_OIDC and domain not in [ x.lower() for x in getattr(settings, 'LDAP_ALT_DOMAINS', []) @@ -1125,8 +1154,8 @@ def clean(self): ): self.add_error( 'email', - 'Local users not allowed, email domain {} not recognized ' - 'for LDAP users'.format(domain), + 'Local/OIDC users not allowed, email domain {} not' + 'recognized for LDAP users'.format(domain), ) # Delegate checks @@ -1176,7 +1205,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""" @@ -1194,8 +1230,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 3ba233d7..066c276a 100644 --- a/projectroles/management/commands/addremotesite.py +++ b/projectroles/management/commands/addremotesite.py @@ -6,9 +6,9 @@ import re import sys +from django.conf import settings from django.contrib import auth from django.core.management.base import BaseCommand -from django.db import transaction from projectroles.management.logging import ManagementCommandLogger from projectroles.models import RemoteSite, SODAR_CONSTANTS @@ -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', @@ -93,10 +103,12 @@ def add_arguments(self, parser): ) def handle(self, *args, **options): + timeline = get_backend_api('timeline_backend') logger.info('Creating remote site..') name = options['name'] url = options['url'] - # Validate url + + # Validate URL if not url.startswith('http://') and not url.startswith('https://'): url = ''.join(['http://', url]) pattern = re.compile(r'(http|https)://.*\..*') @@ -104,54 +116,54 @@ def handle(self, *args, **options): logger.error('Invalid URL "{}"'.format(url)) sys.exit(1) - mode = options['mode'].upper() - # Validate mode - if mode not in [SITE_MODE_SOURCE, SITE_MODE_TARGET]: - if mode in [SITE_MODE_PEER]: - logger.error('Creating PEER sites is not allowed') - else: - logger.error('Unkown mode "{}"'.format(mode)) + # Validate site mode + site_mode = options['mode'].upper() + host_mode = settings.PROJECTROLES_SITE_MODE + if site_mode not in [SITE_MODE_SOURCE, SITE_MODE_TARGET]: + logger.error('Invalid mode "{}"'.format(site_mode)) + sys.exit(1) + if site_mode == host_mode: + logger.error('Attempting to create site with the same mode as host') sys.exit(1) - - description = options['description'] - secret = options['secret'] - user_diplsay = options['user_display'] - suppress_error = options['suppress_error'] # Validate whether site exists - name_exists = bool(len(RemoteSite.objects.filter(name=name))) - url_exists = bool(len(RemoteSite.objects.filter(url=url))) + name_exists = RemoteSite.objects.filter(name=name).count() + url_exists = RemoteSite.objects.filter(url=url).count() if name_exists or url_exists: err_msg = 'Remote site exists with {} "{}"'.format( 'name' if name_exists else 'URL', name if name_exists else url ) - if not suppress_error: + if not options['suppress_error']: logger.error(err_msg) sys.exit(1) else: logger.info(err_msg) sys.exit(0) - with transaction.atomic(): - create_values = { - 'name': name, - 'url': url, - 'mode': mode, - 'description': description, - 'secret': secret, - 'user_display': user_diplsay, - } - site = RemoteSite.objects.create(**create_values) - - timeline = get_backend_api('timeline_backend') - timeline.add_event( - project=None, - app_name='projectroles', - event_name='remote_site_create', - description=description, - classified=True, - status_type='OK', - ) + create_kw = { + 'name': name, + 'url': url, + 'mode': site_mode, + 'description': options['description'], + 'secret': options['secret'], + 'user_display': options['user_display'], + 'owner_modifiable': options['owner_modifiable'], + } + site = RemoteSite.objects.create(**create_kw) + + if timeline: + tl_event = timeline.add_event( + project=None, + app_name='projectroles', + user=None, + event_name='{}_site_create'.format(site_mode.lower()), + description='create {} remote site {{{}}} via management ' + 'command'.format(site_mode.lower(), 'remote_site'), + classified=True, + status_type=timeline.TL_STATUS_OK, + extra_data=create_kw, + ) + tl_event.add_object(obj=site, label='remote_site', name=site.name) logger.info( 'Created remote site "{}" with mode {}'.format(site.name, site.mode) ) diff --git a/projectroles/management/commands/batchupdateroles.py b/projectroles/management/commands/batchupdateroles.py index cc73434a..b16ec99c 100644 --- a/projectroles/management/commands/batchupdateroles.py +++ b/projectroles/management/commands/batchupdateroles.py @@ -36,7 +36,7 @@ class MockRequest(HttpRequest): def mock_scheme(self, host): self.scheme = host.scheme - def scheme(self): + def scheme(self): # noqa return self.scheme diff --git a/projectroles/management/commands/checkusers.py b/projectroles/management/commands/checkusers.py new file mode 100644 index 00000000..0b0dfeaf --- /dev/null +++ b/projectroles/management/commands/checkusers.py @@ -0,0 +1,191 @@ +""" +Checkusers management command for checking user status and reporting disabled or +removed users. +""" + +import ldap +import sys + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand +from django.db.models import Q + +from projectroles.management.logging import ManagementCommandLogger + + +logger = ManagementCommandLogger(__name__) +User = get_user_model() + + +# Local constants +# UserAccountControl flags for disabled accounts +# TODO: Can we get these from python-ldap and print out exact status? +UAC_DISABLED_VALUES = [ + 2, + 514, + 546, + 66050, + 66082, + 262658, + 262690, + 328194, + 328226, +] +UAC_LOCKED_VALUE = 16 +# Messages +USER_DISABLED_MSG = 'Disabled' +USER_LOCKED_MSG = 'Locked' +USER_NOT_FOUND_MSG = 'Not found' +USER_OK_MSG = 'OK' + + +class Command(BaseCommand): + help = ( + 'Check user status and report disabled or removed users. Prints out ' + 'user name, real name, email and status as semicolon-separated values.' + ) + + @classmethod + def _print_result(cls, django_user, msg): + print( + '{};{};{};{}'.format( + django_user.username, + django_user.get_full_name(), + django_user.email, + msg, + ) + ) + + @classmethod + def _get_setting_prefix(cls, primary): + return 'AUTH_LDAP{}_'.format('' if primary else '2') + + def _check_search_base_setting(self, primary): + pf = self._get_setting_prefix(primary) + s_name = pf + 'USER_SEARCH_BASE' + if not hasattr(settings, s_name): + logger.error(s_name + ' not in Django settings') + sys.exit(1) + + def _check_ldap_users(self, users, primary, all_users): + """ + Check and print out user status for a specific LDAP server. + + :param users: QuerySet of SODARUser objects + :param primary: Whether to check for primary or secondary server (bool) + :param all_users: Display status for all users (bool) + """ + + def _get_s(name): + pf = self._get_setting_prefix(primary) + return getattr(settings, pf + name) + + domain = _get_s('USERNAME_DOMAIN') + domain_users = users.filter(username__endswith='@' + domain.upper()) + server_uri = _get_s('SERVER_URI') + server_str = '{} LDAP server at "{}"'.format( + 'primary' if primary else 'secondary', server_uri + ) + if not domain_users: + logger.debug('No users found for {}, skipping'.format(server_str)) + return + + bind_dn = _get_s('BIND_DN') + bind_pw = _get_s('BIND_PASSWORD') + start_tls = _get_s('START_TLS') + options = _get_s('CONNECTION_OPTIONS') + user_filter = _get_s('USER_FILTER') + search_base = _get_s('USER_SEARCH_BASE') + + # Enable debug if set in env + if settings.LDAP_DEBUG: + ldap.set_option(ldap.OPT_DEBUG_LEVEL, 255) + + # Connect to LDAP + lc = ldap.initialize(server_uri) + for k, v in options.items(): + lc.set_option(k, v) + if start_tls: + lc.protocol_version = 3 + lc.start_tls_s() + try: + lc.simple_bind_s(bind_dn, bind_pw) + except Exception as ex: + logger.error( + 'Exception connecting to {}: {}'.format(server_str, ex) + ) + return + + for d_user in domain_users: + r = lc.search( + search_base, + ldap.SCOPE_SUBTREE, + user_filter.replace('%(user)s', d_user.username.split('@')[0]), + ) + _, l_user = lc.result(r, 60) + if len(l_user) > 0: + name, attrs = l_user[0] + user_ok = True + # logger.debug('Result: {}; {}'.format(name, attrs)) + if ( + 'userAccountControl' in attrs + and len(attrs['userAccountControl']) > 0 + ): + val = int(attrs['userAccountControl'][0].decode('utf-8')) + if val in UAC_DISABLED_VALUES: + self._print_result(d_user, USER_DISABLED_MSG) + user_ok = False + elif val == UAC_LOCKED_VALUE: + self._print_result(d_user, USER_LOCKED_MSG) + user_ok = False + if all_users and user_ok: + self._print_result(d_user, USER_OK_MSG) + else: # Not found + self._print_result(d_user, USER_NOT_FOUND_MSG) + + def add_arguments(self, parser): + parser.add_argument( + '-a', + '--all', + dest='all', + action='store_true', + required=False, + help='Display results for all users even if status is OK', + ) + parser.add_argument( + '-l', + '--limit', + dest='limit', + required=False, + help='Limit search to "ldap1" or "ldap2".', + ) + + def handle(self, *args, **options): + if not settings.ENABLE_LDAP: + logger.info('LDAP not enabled, nothing to do') + return + self._check_search_base_setting(primary=True) + self._check_search_base_setting(primary=False) + u_query = Q( + username__endswith='@{}'.format( + settings.AUTH_LDAP_USERNAME_DOMAIN.upper() + ) + ) + if settings.ENABLE_LDAP_SECONDARY: + q_secondary = Q( + username__endswith='@{}'.format( + settings.AUTH_LDAP2_USERNAME_DOMAIN.upper() + ) + ) + u_query.add(q_secondary, Q.OR) + users = User.objects.filter(u_query).order_by('username') + limit = options.get('limit') + if not limit or limit == 'ldap1': + self._check_ldap_users( + users, primary=True, all_users=options.get('all', False) + ) + if settings.ENABLE_LDAP_SECONDARY and (not limit or limit == 'ldap2'): + self._check_ldap_users( + users, primary=False, all_users=options.get('all', False) + ) diff --git a/projectroles/management/commands/cleanappsettings.py b/projectroles/management/commands/cleanappsettings.py index 3f3c5ba9..f1f1a3f0 100644 --- a/projectroles/management/commands/cleanappsettings.py +++ b/projectroles/management/commands/cleanappsettings.py @@ -28,9 +28,11 @@ def get_setting_str(db_setting): return '.'.join( [ 'settings', - 'projectroles' - if db_setting.app_plugin is None - else db_setting.app_plugin.name, + ( + 'projectroles' + if db_setting.app_plugin is None + else db_setting.app_plugin.name + ), db_setting.name, ] ) @@ -50,7 +52,7 @@ def handle(self, *args, **options): if s.app_plugin: def_kwargs['plugin'] = s.app_plugin.get_plugin() else: - def_kwargs['app_name'] = 'projectroles' + def_kwargs['plugin_name'] = 'projectroles' try: definition = app_settings.get_definition(**def_kwargs) except ValueError: diff --git a/projectroles/management/commands/createdevusers.py b/projectroles/management/commands/createdevusers.py index c56f697b..297354fa 100644 --- a/projectroles/management/commands/createdevusers.py +++ b/projectroles/management/commands/createdevusers.py @@ -20,23 +20,36 @@ DEV_USER_NAMES = ['alice', 'bob', 'carol', 'dan', 'erin'] LAST_NAME = 'Example' EMAIL_DOMAIN = 'example.com' -PASSWORD = 'password' +DEFAULT_PASSWORD = 'sodarpass' class Command(BaseCommand): - help = ( - 'Create fictitious local user accounts for development. The password ' - 'for each user will be set as "password".' - ) + help = 'Create fictitious local user accounts for development.' + + def add_arguments(self, parser): + parser.add_argument( + '-p', + '--password', + dest='password', + type=str, + required=False, + help='Password to use for created dev users. If not given, the ' + 'default password "{}" will be used'.format(DEFAULT_PASSWORD), + ) def handle(self, *args, **options): if not settings.DEBUG: logger.error( 'DEBUG not enabled, cancelling. Are you attempting to create ' - 'development users on a production site?' + 'development users on a production instance?' ) sys.exit(1) - password = make_password(PASSWORD) + pw = ( + DEFAULT_PASSWORD + if not options.get('password') + else options['password'] + ) + password = make_password(pw) for u in DEV_USER_NAMES: if User.objects.filter(username=u).first(): logger.info('User "{}" already exists'.format(u)) diff --git a/projectroles/management/commands/syncgroups.py b/projectroles/management/commands/syncgroups.py index e3db91d6..8503add8 100644 --- a/projectroles/management/commands/syncgroups.py +++ b/projectroles/management/commands/syncgroups.py @@ -2,7 +2,6 @@ from django.contrib import auth from django.core.management.base import BaseCommand -from django.db import transaction from projectroles.management.logging import ManagementCommandLogger @@ -14,24 +13,17 @@ class Command(BaseCommand): help = 'Synchronizes user groups based on user name' - def add_arguments(self, parser): - pass - def handle(self, *args, **options): logger.info('Synchronizing user groups..') - with transaction.atomic(): - for user in User.objects.all(): - user.groups.clear() - user.save() # Group is updated during save - - if user.groups.count() > 0: - logger.info( - 'Group set: {} -> {}'.format( - user.username, user.groups.first().name - ) + for user in User.objects.all().order_by('username'): + user.groups.clear() + user.save() # Group is updated during save + if user.groups.count() > 0: + logger.info( + 'Group set: {} -> {}'.format( + user.username, user.groups.first().name ) + ) logger.info( - 'Synchronized groups for {} users'.format( - User.objects.all().count() - ) + 'Synchronized groups for {} users'.format(User.objects.count()) ) diff --git a/projectroles/migrations/0001_squashed_0032_alter_appsetting_value.py b/projectroles/migrations/0001_squashed_0032_alter_appsetting_value.py new file mode 100644 index 00000000..77b7bf79 --- /dev/null +++ b/projectroles/migrations/0001_squashed_0032_alter_appsetting_value.py @@ -0,0 +1,1224 @@ +# Generated by Django 4.2.14 on 2024-07-15 14:46 + +from django.conf import settings +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion +import markupfield.fields +import projectroles.models +import random +import string +import uuid + + +def populate_roles(apps, schema_editor): + """Populate Role objects""" + Role = apps.get_model('projectroles', 'Role') + + def save_role(name, description): + role = Role(name=name, description=description) + role.save() + + # Owner + save_role( + 'project owner', + 'The project owner has full access to project data; rights to add, ' + 'modify and remove project members; right to assign a project ' + 'delegate. Each project must have exactly one owner.', + ) + # Delegate + save_role( + 'project delegate', + 'The project delegate has all the rights of a project owner with the ' + 'exception of assigning a delegate. A maximum of one delegate can be ' + 'set per project. A delegate role can be set by project owner.', + ) + # Contributor + save_role( + 'project contributor', + 'A project member with ability to view and add project data. Can edit ' + 'their own data.', + ) + # Guest + save_role( + 'project guest', + 'Read-only access to a project. Can view data in project, can not add ' + 'or edit.', + ) + + +def fix_remote_project_keys(apps, schema_editor): + """Fix missing RemoteProject.project foreign keys (issue #197)""" + RemoteProject = apps.get_model('projectroles', 'RemoteProject') + Project = apps.get_model('projectroles', 'Project') + for rp in RemoteProject.objects.all(): + if not rp.project: + rp.project = Project.objects.filter( + sodar_uuid=rp.project_uuid + ).first() + rp.save() + + +def populate_project_full_title(apps, schema_editor): + """Populate the new full_title field in the Project model""" + + def get_project_parents(obj): + if not obj.parent: + return None + ret = [] + parent = obj.parent + while parent: + ret.append(parent) + parent = parent.parent + return reversed(ret) + + def get_project_full_title(obj): + parents = get_project_parents(obj) + ret = ' / '.join([p.title for p in parents]) + ' / ' if parents else '' + ret += obj.title + return ret + + Project = apps.get_model('projectroles', 'Project') + for project in Project.objects.all(): + project.full_title = get_project_full_title(project) + project.save() + + +def populate_public_children(apps, schema_editor): + """Populate the new has_public_children field in the Project model""" + + def get_public_children(obj): + for child in obj.children.all(): + if child.public_guest_access: + return True + ret = get_public_children(child) + if ret: + return True + return False + + Project = apps.get_model('projectroles', 'Project') + for project in Project.objects.all(): + project.has_public_children = get_public_children(project) + project.save() + + +def set_role_ranks(apps, schema_editor): + """Set rank values for existing roles""" + role_ranking = { + 'project owner': 10, + 'project delegate': 20, + 'project contributor': 30, + 'project guest': 40, + } + Role = apps.get_model('projectroles', 'Role') + for role in Role.objects.all(): + if role.name in role_ranking: + role.rank = role_ranking[role.name] + role.save() + + +def migrate_project_stars(apps, schema_editor): + """Create project_star AppSettings from ProjectUserTag objects""" + ProjectUserTag = apps.get_model('projectroles', 'ProjectUserTag') + AppSetting = apps.get_model('projectroles', 'AppSetting') + for tag in ProjectUserTag.objects.filter(name='STARRED'): + AppSetting.objects.get_or_create( + project=tag.project, + user=tag.user, + value=True, + name='project_star', + ) + + +def delete_category_app_settings(apps, schema_editor): + """Delete app settings assigned to categories""" + from projectroles.app_settings import AppSettingAPI + + app_settings = AppSettingAPI() + AppSetting = apps.get_model('projectroles', 'AppSetting') + # Find all app settings whith a project + pr_settings = AppSetting.objects.exclude(project=None) + for app_setting in pr_settings: + try: + plugin_name = ( + app_setting.app_plugin.name + if app_setting.app_plugin + else 'projectroles' + ) + setting_def = app_settings.get_definition( + plugin_name=plugin_name, name=app_setting.name + ) + except ValueError: + app_setting.delete() + continue + if app_setting.project.type not in setting_def.get( + 'project_types', ['PROJECT'] + ): + # Delete app setting if it is not restricted to any project types + app_setting.delete() + + +def populate_finder_role(apps, schema_editor): + """Populate project finder role""" + Role = apps.get_model('projectroles', 'Role') + role = Role( + name='project finder', + rank=50, + project_types=['CATEGORY'], + description='The project finder is able to see certain details of all ' + 'categories and projects under a category for this role is given. ' + 'They will not have access to the apps or data of those subprojects or ' + 'categories until assigned a higher role. This role can only be ' + 'assigned for categories.', + ) + role.save() + + +def reverse_finder_role(apps, schema_editor): + """Reverse code for project finder role populating""" + Role = apps.get_model('projectroles', 'Role') + role = Role.objects.filter(name='project finder').first() + if role: + role.delete() + + +def populate_additional_email_model(apps, schema_editor): + """ + Move existing additional email app settings into the + SODARUserAdditionalEmail model as verified emais. Delete the settings + objects. + """ + AppSetting = apps.get_model('projectroles', 'AppSetting') + SODARUserAdditionalEmail = apps.get_model( + 'projectroles', 'SODARUserAdditionalEmail' + ) + for a in AppSetting.objects.filter( + app_plugin=None, name='user_email_additional' + ): + for v in a.value.split(';'): + secret = ''.join( + random.SystemRandom().choice( + string.ascii_lowercase + string.digits + ) + for _ in range(32) + ) + try: + SODARUserAdditionalEmail.objects.create( + user=a.user, email=v, verified=True, secret=secret + ) + except Exception: + pass + a.delete() + + +class Migration(migrations.Migration): + + replaces = [ + ('projectroles', '0001_initial'), + ('projectroles', '0002_auto_20180411_1758'), + ('projectroles', '0003_populate_roles'), + ('projectroles', '0004_rename_uuid'), + ('projectroles', '0005_update_uuid'), + ('projectroles', '0006_add_remote_projects'), + ('projectroles', '0007_fix_remoteproject_foreign_key'), + ('projectroles', '0008_auto_20190426_0606'), + ('projectroles', '0009_rename_projectsetting'), + ('projectroles', '0010_update_appsetting'), + ('projectroles', '0011_remove_indexes'), + ('projectroles', '0012_update_remotesite'), + ('projectroles', '0013_update_appsetting_type'), + ('projectroles', '0014_update_appsetting_value_json'), + ('projectroles', '0015_fix_appsetting_constraint'), + ('projectroles', '0016_app_plugin_field_none'), + ('projectroles', '0017_project_full_title'), + ('projectroles', '0018_update_jsonfield'), + ('projectroles', '0019_project_public_guest_access'), + ('projectroles', '0020_project_has_public_children'), + ('projectroles', '0021_remove_project_submit_status'), + ('projectroles', '0022_role_rank'), + ('projectroles', '0023_project_archive'), + ('projectroles', '0024_project_star_app_setting'), + ('projectroles', '0025_delete_projectusertag'), + ('projectroles', '0026_delete_category_settings'), + ('projectroles', '0027_role_project_type'), + ('projectroles', '0028_populate_finder_role'), + ('projectroles', '0029_sodaruseradditionalemail'), + ('projectroles', '0030_populate_sodaruseradditionalemail'), + ('projectroles', '0031_remotesite_owner_modifiable'), + ('projectroles', '0032_alter_appsetting_value'), + ] + + initial = True + + dependencies = [ + ('djangoplugins', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Project', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'title', + models.CharField(help_text='Project title', max_length=255), + ), + ( + 'type', + models.CharField( + choices=[ + ('CATEGORY', 'Category'), + ('PROJECT', 'Project'), + ], + default='PROJECT', + help_text='Type of project ("CATEGORY", "PROJECT")', + max_length=64, + ), + ), + ( + 'description', + models.CharField( + help_text='Short project description', max_length=512 + ), + ), + ( + 'readme', + markupfield.fields.MarkupField( + blank=True, + help_text='Project README (optional, supports markdown)', + null=True, + rendered_field=True, + ), + ), + ( + 'readme_markup_type', + models.CharField( + choices=[ + ('', '--'), + ('html', 'HTML'), + ('plain', 'Plain'), + ('markdown', 'Markdown'), + ('restructuredtext', 'Restructured Text'), + ], + default='markdown', + editable=False, + max_length=30, + ), + ), + ( + 'submit_status', + models.CharField( + default='OK', + help_text='Status of project creation', + max_length=64, + ), + ), + ( + '_readme_rendered', + models.TextField(editable=False, null=True), + ), + ( + 'omics_uuid', + models.UUIDField( + default=uuid.uuid4, + help_text='Project Omics UUID', + unique=True, + ), + ), + ( + 'parent', + models.ForeignKey( + blank=True, + help_text='Parent category if nested', + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='children', + to='projectroles.project', + ), + ), + ], + options={ + 'ordering': ['parent__title', 'title'], + 'unique_together': {('title', 'parent')}, + }, + ), + migrations.CreateModel( + name='Role', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'name', + models.CharField( + help_text='Name of role', max_length=64, unique=True + ), + ), + ('description', models.TextField(help_text='Role description')), + ], + ), + migrations.CreateModel( + name='ProjectUserTag', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'name', + models.CharField( + default='STARRED', + help_text='Name of tag to be assigned', + max_length=64, + ), + ), + ( + 'omics_uuid', + models.UUIDField( + default=uuid.uuid4, + help_text='ProjectUserTag Omics UUID', + unique=True, + ), + ), + ( + 'project', + models.ForeignKey( + help_text='Project in which the tag is assigned', + on_delete=django.db.models.deletion.CASCADE, + related_name='tags', + to='projectroles.project', + ), + ), + ( + 'user', + models.ForeignKey( + help_text='User for whom the tag is assigned', + on_delete=django.db.models.deletion.CASCADE, + related_name='project_tags', + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + 'ordering': ['project__title', 'user__username', 'name'], + }, + ), + migrations.CreateModel( + name='ProjectInvite', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'email', + models.EmailField( + help_text='Email address of the person to be invited', + max_length=254, + ), + ), + ( + 'date_created', + models.DateTimeField( + auto_now_add=True, + help_text='DateTime of invite creation', + ), + ), + ( + 'date_expire', + models.DateTimeField( + help_text='Expiration of invite as DateTime' + ), + ), + ( + 'message', + models.TextField( + blank=True, + help_text='Message to be included in the invite email (optional)', + ), + ), + ( + 'secret', + models.CharField( + help_text='Secret token provided to user with the invite', + max_length=255, + unique=True, + ), + ), + ( + 'active', + models.BooleanField( + default=True, + help_text='Status of the invite (False if claimed or revoked)', + ), + ), + ( + 'omics_uuid', + models.UUIDField( + default=uuid.uuid4, + help_text='ProjectInvite Omics UUID', + unique=True, + ), + ), + ( + 'issuer', + models.ForeignKey( + help_text='User who issued the invite', + on_delete=django.db.models.deletion.CASCADE, + related_name='issued_invites', + to=settings.AUTH_USER_MODEL, + ), + ), + ( + 'project', + models.ForeignKey( + help_text='Project to which the person is invited', + on_delete=django.db.models.deletion.CASCADE, + related_name='invites', + to='projectroles.project', + ), + ), + ( + 'role', + models.ForeignKey( + help_text='Role assigned to the person', + on_delete=django.db.models.deletion.CASCADE, + to='projectroles.role', + ), + ), + ], + options={ + 'ordering': ['project__title', 'email', 'role__name'], + }, + ), + migrations.CreateModel( + name='RoleAssignment', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'omics_uuid', + models.UUIDField( + default=uuid.uuid4, + help_text='RoleAssignment Omics UUID', + unique=True, + ), + ), + ( + 'project', + models.ForeignKey( + help_text='Project in which role is assigned', + on_delete=django.db.models.deletion.CASCADE, + related_name='roles', + to='projectroles.project', + ), + ), + ( + 'role', + models.ForeignKey( + help_text='Role to be assigned', + on_delete=django.db.models.deletion.CASCADE, + related_name='assignments', + to='projectroles.role', + ), + ), + ( + 'user', + models.ForeignKey( + help_text='User for whom role is assigned', + on_delete=django.db.models.deletion.CASCADE, + related_name='roles', + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + 'ordering': [ + 'project__parent__title', + 'project__title', + 'role__name', + 'user__username', + ], + 'indexes': [ + models.Index( + fields=['project'], + name='projectrole_project_b13795_idx', + ), + models.Index( + fields=['user'], name='projectrole_user_id_0e46f7_idx' + ), + ], + }, + ), + migrations.CreateModel( + name='ProjectSetting', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'name', + models.CharField( + help_text='Name of the setting', max_length=255 + ), + ), + ( + 'type', + models.CharField( + choices=[ + ('BOOLEAN', 'Boolean'), + ('INTEGER', 'Integer'), + ('STRING', 'String'), + ], + help_text='Type of the setting', + max_length=64, + ), + ), + ( + 'value', + models.CharField( + blank=True, + help_text='Value of the setting', + max_length=255, + null=True, + ), + ), + ( + 'omics_uuid', + models.UUIDField( + default=uuid.uuid4, + help_text='ProjectSetting Omics UUID', + unique=True, + ), + ), + ( + 'app_plugin', + models.ForeignKey( + help_text='App to which the setting belongs', + on_delete=django.db.models.deletion.CASCADE, + related_name='settings', + to='djangoplugins.plugin', + ), + ), + ( + 'project', + models.ForeignKey( + help_text='Project to which the setting belongs', + on_delete=django.db.models.deletion.CASCADE, + related_name='settings', + to='projectroles.project', + ), + ), + ], + options={ + 'ordering': ['project__title', 'app_plugin__name', 'name'], + 'unique_together': {('project', 'app_plugin', 'name')}, + }, + ), + migrations.RunPython( + code=populate_roles, + reverse_code=migrations.RunPython.noop, + ), + migrations.RenameField( + model_name='project', + old_name='omics_uuid', + new_name='sodar_uuid', + ), + migrations.RenameField( + model_name='projectinvite', + old_name='omics_uuid', + new_name='sodar_uuid', + ), + migrations.RenameField( + model_name='projectsetting', + old_name='omics_uuid', + new_name='sodar_uuid', + ), + migrations.RenameField( + model_name='projectusertag', + old_name='omics_uuid', + new_name='sodar_uuid', + ), + migrations.RenameField( + model_name='roleassignment', + old_name='omics_uuid', + new_name='sodar_uuid', + ), + migrations.AlterField( + model_name='project', + name='sodar_uuid', + field=models.UUIDField( + default=uuid.uuid4, help_text='Project SODAR UUID', unique=True + ), + ), + migrations.AlterField( + model_name='projectinvite', + name='sodar_uuid', + field=models.UUIDField( + default=uuid.uuid4, + help_text='ProjectInvite SODAR UUID', + unique=True, + ), + ), + migrations.AlterField( + model_name='projectsetting', + name='sodar_uuid', + field=models.UUIDField( + default=uuid.uuid4, + help_text='ProjectSetting SODAR UUID', + unique=True, + ), + ), + migrations.AlterField( + model_name='projectusertag', + name='sodar_uuid', + field=models.UUIDField( + default=uuid.uuid4, + help_text='ProjectUserTag SODAR UUID', + unique=True, + ), + ), + migrations.AlterField( + model_name='roleassignment', + name='sodar_uuid', + field=models.UUIDField( + default=uuid.uuid4, + help_text='RoleAssignment SODAR UUID', + unique=True, + ), + ), + migrations.AlterField( + model_name='project', + name='description', + field=models.CharField( + blank=True, + help_text='Short project description', + max_length=512, + null=True, + ), + ), + migrations.CreateModel( + name='RemoteSite', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'name', + models.CharField( + help_text='Site name', max_length=255, unique=True + ), + ), + ('url', models.URLField(help_text='Site URL', max_length=2000)), + ( + 'mode', + models.CharField( + default='TARGET', help_text='Site mode', max_length=64 + ), + ), + ('description', models.TextField(help_text='Site description')), + ( + 'secret', + models.CharField( + help_text='Secret token for connecting to the source site', + max_length=255, + ), + ), + ( + 'sodar_uuid', + models.UUIDField( + default=uuid.uuid4, + help_text='RemoteSite relation UUID (local)', + unique=True, + ), + ), + ], + options={ + 'ordering': ['name'], + 'unique_together': {('url', 'mode', 'secret')}, + }, + ), + migrations.CreateModel( + name='RemoteProject', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'project_uuid', + models.UUIDField(default=None, help_text='Project UUID'), + ), + ( + 'level', + models.CharField( + default='NONE', + help_text='Project access level', + max_length=255, + ), + ), + ( + 'date_access', + models.DateTimeField( + help_text='DateTime of last access from/to remote site', + null=True, + ), + ), + ( + 'sodar_uuid', + models.UUIDField( + default=uuid.uuid4, + help_text='RemoteProject relation UUID (local)', + unique=True, + ), + ), + ( + 'project', + models.ForeignKey( + blank=True, + help_text='Related project object (if created locally)', + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='remotes', + to='projectroles.project', + ), + ), + ( + 'site', + models.ForeignKey( + help_text='Remote SODAR site', + on_delete=django.db.models.deletion.CASCADE, + related_name='projects', + to='projectroles.remotesite', + ), + ), + ], + options={ + 'ordering': ['site__name', 'project_uuid'], + }, + ), + migrations.RunPython( + code=fix_remote_project_keys, + reverse_code=migrations.RunPython.noop, + ), + migrations.AddField( + model_name='projectsetting', + name='user', + field=models.ForeignKey( + blank=True, + help_text='User to which the setting belongs', + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='user_settings', + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name='projectsetting', + name='project', + field=models.ForeignKey( + blank=True, + help_text='Project to which the setting belongs', + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='settings', + to='projectroles.project', + ), + ), + migrations.CreateModel( + name='AppSetting', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'name', + models.CharField( + help_text='Name of the setting', max_length=255 + ), + ), + ( + 'type', + models.CharField( + choices=[ + ('BOOLEAN', 'Boolean'), + ('INTEGER', 'Integer'), + ('STRING', 'String'), + ], + help_text='Type of the setting', + max_length=64, + ), + ), + ( + 'value', + models.CharField( + blank=True, + help_text='Value of the setting', + max_length=255, + null=True, + ), + ), + ( + 'sodar_uuid', + models.UUIDField( + default=uuid.uuid4, + help_text='AppSetting SODAR UUID', + unique=True, + ), + ), + ( + 'app_plugin', + models.ForeignKey( + help_text='App to which the setting belongs', + on_delete=django.db.models.deletion.CASCADE, + related_name='settings', + to='djangoplugins.plugin', + ), + ), + ( + 'project', + models.ForeignKey( + blank=True, + help_text='Project to which the setting belongs', + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='settings', + to='projectroles.project', + ), + ), + ( + 'user', + models.ForeignKey( + blank=True, + help_text='User to which the setting belongs', + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='user_settings', + to=settings.AUTH_USER_MODEL, + ), + ), + ( + 'user_modifiable', + models.BooleanField( + default=True, help_text='Setting visibility in forms' + ), + ), + ( + 'value_json', + django.contrib.postgres.fields.jsonb.JSONField( + default=dict, + help_text='Optional JSON value for the setting', + ), + ), + ], + options={ + 'ordering': ['project__title', 'app_plugin__name', 'name'], + # 'unique_together': {('project', 'app_plugin', 'name')}, + }, + ), + migrations.DeleteModel( + name='ProjectSetting', + ), + migrations.RemoveIndex( + model_name='roleassignment', + name='projectrole_project_b13795_idx', + ), + migrations.RemoveIndex( + model_name='roleassignment', + name='projectrole_user_id_0e46f7_idx', + ), + migrations.AddField( + model_name='remotesite', + name='user_display', + field=models.BooleanField( + default=True, help_text='RemoteSite visibility to users' + ), + ), + migrations.AlterField( + model_name='remotesite', + name='secret', + field=models.CharField( + help_text='Secret token for connecting to the source site', + max_length=255, + null=True, + ), + ), + migrations.AlterField( + model_name='appsetting', + name='type', + field=models.CharField( + help_text='Type of the setting', max_length=64 + ), + ), + migrations.AlterField( + model_name='appsetting', + name='value_json', + field=django.contrib.postgres.fields.jsonb.JSONField( + default=dict, + help_text='Optional JSON value for the setting', + null=True, + ), + ), + migrations.AlterUniqueTogether( + name='appsetting', + unique_together={('project', 'user', 'app_plugin', 'name')}, + ), + migrations.AlterField( + model_name='appsetting', + name='app_plugin', + field=models.ForeignKey( + help_text='App to which the setting belongs', + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='settings', + to='djangoplugins.plugin', + ), + ), + migrations.AddField( + model_name='project', + name='full_title', + field=models.CharField( + help_text='Full project title with parent path (auto-generated)', + max_length=4096, + null=True, + ), + ), + migrations.RunPython( + code=populate_project_full_title, + reverse_code=migrations.RunPython.noop, + ), + migrations.AlterField( + model_name='appsetting', + name='value_json', + field=models.JSONField( + default=dict, + help_text='Optional JSON value for the setting', + null=True, + ), + ), + migrations.AddField( + model_name='project', + name='public_guest_access', + field=models.BooleanField( + default=False, + help_text='Allow public guest access for the project, also including unauthenticated users if allowed on the site', + ), + ), + migrations.AddField( + model_name='project', + name='has_public_children', + field=models.BooleanField( + default=False, + help_text='Whether project has children with public access (auto-generated)', + ), + ), + migrations.RunPython( + code=populate_public_children, + reverse_code=migrations.RunPython.noop, + ), + migrations.RemoveField( + model_name='project', + name='submit_status', + ), + migrations.AddField( + model_name='role', + name='rank', + field=models.IntegerField( + default=0, help_text='Role rank for determining role hierarchy' + ), + ), + migrations.RunPython( + code=set_role_ranks, + reverse_code=migrations.RunPython.noop, + ), + migrations.AddField( + model_name='project', + name='archive', + field=models.BooleanField( + default=False, help_text='Project is archived (read-only)' + ), + ), + migrations.RunPython( + code=migrate_project_stars, + reverse_code=migrations.RunPython.noop, + ), + migrations.DeleteModel( + name='ProjectUserTag', + ), + migrations.RunPython( + code=delete_category_app_settings, + reverse_code=migrations.RunPython.noop, + ), + migrations.AddField( + model_name='role', + name='project_types', + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=64), + default=projectroles.models.get_role_project_type_default, + help_text='Allowed project types for the role', + size=None, + ), + ), + migrations.AlterField( + model_name='roleassignment', + name='project', + field=models.ForeignKey( + help_text='Project in which role is assigned', + on_delete=django.db.models.deletion.CASCADE, + related_name='local_roles', + to='projectroles.project', + ), + ), + migrations.RunPython( + code=populate_finder_role, + reverse_code=reverse_finder_role, + ), + migrations.CreateModel( + name='SODARUserAdditionalEmail', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'email', + models.EmailField( + help_text='Email address', max_length=254 + ), + ), + ( + 'verified', + models.BooleanField( + default=False, help_text='Email verification status' + ), + ), + ( + 'secret', + models.CharField( + help_text='Secret token for email verification', + max_length=255, + unique=True, + ), + ), + ( + 'date_created', + models.DateTimeField( + auto_now_add=True, help_text='DateTime of creation' + ), + ), + ( + 'date_modified', + models.DateTimeField( + auto_now=True, help_text='DateTime of last modification' + ), + ), + ( + 'sodar_uuid', + models.UUIDField( + default=uuid.uuid4, + help_text='SODARUserAdditionalEmail SODAR UUID', + unique=True, + ), + ), + ( + 'user', + models.ForeignKey( + help_text='User for whom the email is assigned', + on_delete=django.db.models.deletion.CASCADE, + related_name='additional_emails', + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + 'ordering': ['user__username', 'email'], + 'unique_together': {('user', 'email')}, + }, + ), + migrations.RunPython( + code=populate_additional_email_model, + reverse_code=migrations.RunPython.noop, + ), + 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' + ), + ), + migrations.AlterField( + model_name='appsetting', + name='value', + field=models.CharField( + blank=True, help_text='Value of the setting', null=True + ), + ), + ] diff --git a/projectroles/migrations/0026_delete_category_settings.py b/projectroles/migrations/0026_delete_category_settings.py index c21635e5..d753bac1 100644 --- a/projectroles/migrations/0026_delete_category_settings.py +++ b/projectroles/migrations/0026_delete_category_settings.py @@ -12,13 +12,13 @@ def clean_up_app_settings(apps, schema_editor): pr_settings = AppSetting.objects.exclude(project=None) for app_setting in pr_settings: try: - app_name = ( + plugin_name = ( app_setting.app_plugin.name if app_setting.app_plugin else 'projectroles' ) setting_def = app_settings.get_definition( - app_name=app_name, name=app_setting.name + plugin_name=plugin_name, name=app_setting.name ) except ValueError: app_setting.delete() diff --git a/projectroles/migrations/0029_sodaruseradditionalemail.py b/projectroles/migrations/0029_sodaruseradditionalemail.py new file mode 100644 index 00000000..e870777e --- /dev/null +++ b/projectroles/migrations/0029_sodaruseradditionalemail.py @@ -0,0 +1,79 @@ +# Generated by Django 4.2.11 on 2024-04-25 11:48 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("projectroles", "0028_populate_finder_role"), + ] + + operations = [ + migrations.CreateModel( + name="SODARUserAdditionalEmail", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("email", models.EmailField(help_text="Email address", max_length=254)), + ( + "verified", + models.BooleanField( + default=False, help_text="Email verification status" + ), + ), + ( + "secret", + models.CharField( + help_text="Secret token for email verification", + max_length=255, + unique=True, + ), + ), + ( + "date_created", + models.DateTimeField( + auto_now_add=True, help_text="DateTime of creation" + ), + ), + ( + "date_modified", + models.DateTimeField( + auto_now=True, help_text="DateTime of last modification" + ), + ), + ( + "sodar_uuid", + models.UUIDField( + default=uuid.uuid4, + help_text="SODARUserAdditionalEmail SODAR UUID", + unique=True, + ), + ), + ( + "user", + models.ForeignKey( + help_text="User for whom the email is assigned", + on_delete=django.db.models.deletion.CASCADE, + related_name="additional_emails", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["user__username", "email"], + "unique_together": {("user", "email")}, + }, + ), + ] diff --git a/projectroles/migrations/0030_populate_sodaruseradditionalemail.py b/projectroles/migrations/0030_populate_sodaruseradditionalemail.py new file mode 100644 index 00000000..5fe21b16 --- /dev/null +++ b/projectroles/migrations/0030_populate_sodaruseradditionalemail.py @@ -0,0 +1,48 @@ +# Generated by Django 4.2.11 on 2024-04-25 13:14 + +import random +import string + +from django.db import migrations + + +def populate_model(apps, schema_editor): + """ + Move existing additional email app settings into the + SODARUserAdditionalEmail model as verified emais. Delete the settings + objects. + """ + AppSetting = apps.get_model('projectroles', 'AppSetting') + SODARUserAdditionalEmail = apps.get_model( + 'projectroles', 'SODARUserAdditionalEmail' + ) + for a in AppSetting.objects.filter( + app_plugin=None, name='user_email_additional' + ): + for v in a.value.split(';'): + secret = ''.join( + random.SystemRandom().choice( + string.ascii_lowercase + string.digits + ) + for _ in range(32) + ) + try: + SODARUserAdditionalEmail.objects.create( + user=a.user, email=v, verified=True, secret=secret + ) + except Exception: + pass + a.delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("projectroles", "0029_sodaruseradditionalemail"), + ] + + operations = [ + migrations.RunPython( + populate_model, reverse_code=migrations.RunPython.noop + ) + ] 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/migrations/0032_alter_appsetting_value.py b/projectroles/migrations/0032_alter_appsetting_value.py new file mode 100644 index 00000000..455396cb --- /dev/null +++ b/projectroles/migrations/0032_alter_appsetting_value.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.13 on 2024-06-20 14:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("projectroles", "0031_remotesite_owner_modifiable"), + ] + + operations = [ + migrations.AlterField( + model_name="appsetting", + name="value", + field=models.CharField( + blank=True, help_text="Value of the setting", null=True + ), + ), + ] diff --git a/projectroles/models.py b/projectroles/models.py index 290e4342..01602443 100644 --- a/projectroles/models.py +++ b/projectroles/models.py @@ -1,7 +1,6 @@ """Models for the projectroles app""" import logging -import re import uuid from django.apps import apps @@ -12,7 +11,7 @@ from django.db import models from django.db.models import Q from django.urls import reverse -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from djangoplugins.models import Plugin from markupfield.fields import MarkupField @@ -34,6 +33,9 @@ PROJECT_ROLE_GUEST = SODAR_CONSTANTS['PROJECT_ROLE_GUEST'] PROJECT_ROLE_FINDER = SODAR_CONSTANTS['PROJECT_ROLE_FINDER'] APP_SETTING_SCOPE_SITE = SODAR_CONSTANTS['APP_SETTING_SCOPE_SITE'] +AUTH_TYPE_LOCAL = SODAR_CONSTANTS['AUTH_TYPE_LOCAL'] +AUTH_TYPE_LDAP = SODAR_CONSTANTS['AUTH_TYPE_LDAP'] +AUTH_TYPE_OIDC = SODAR_CONSTANTS['AUTH_TYPE_OIDC'] # Local constants ROLE_RANKING = { @@ -51,7 +53,6 @@ ('STRING', 'String'), ('JSON', 'Json'), ] -APP_SETTING_VAL_MAXLENGTH = 255 PROJECT_SEARCH_TYPES = ['project'] PROJECT_TAG_STARRED = 'STARRED' CAT_DELIMITER = ' / ' @@ -61,6 +62,12 @@ ROLE_PROJECT_TYPE_ERROR_MSG = ( 'Invalid project type "{project_type}" for ' 'role "{role_name}"' ) +CAT_PUBLIC_ACCESS_MSG = 'Public guest access is not allowed for categories' +ADD_EMAIL_ALREADY_SET_MSG = 'Email already set as {email_type} email for user' +REMOTE_PROJECT_UNIQUE_MSG = ( + 'RemoteProject with the same project UUID and site anready exists' +) +AUTH_PROVIDER_OIDC = 'oidc' # Project ---------------------------------------------------------------------- @@ -107,7 +114,7 @@ class Project(models.Model): type = models.CharField( max_length=64, choices=PROJECT_TYPE_CHOICES, - default=SODAR_CONSTANTS['PROJECT_TYPE_PROJECT'], + default=PROJECT_TYPE_PROJECT, help_text='Type of project ("CATEGORY", "PROJECT")', ) @@ -197,13 +204,16 @@ def save(self, *args, **kwargs): self._validate_archive() # Update full title of self and children self.full_title = self._get_full_title() + # TODO: Save with commit=False with other args to avoid double save()? + super().save(*args, **kwargs) if self.type == PROJECT_TYPE_CATEGORY: for child in self.children.all(): child.save() # Update public children # NOTE: Parents will be updated in ProjectModifyMixin.modify_project() - self.has_public_children = self._has_public_children() - super().save(*args, **kwargs) + if self._has_public_children(): + self.has_public_children = True + super().save(*args, **kwargs) def _validate_parent(self): """ @@ -214,23 +224,21 @@ def _validate_parent(self): def _validate_parent_type(self): """Validate parent value to ensure parent can not be a project""" - if ( - self.parent - and self.parent.type == SODAR_CONSTANTS['PROJECT_TYPE_PROJECT'] - ): + if self.parent and self.parent.type == PROJECT_TYPE_PROJECT: raise ValidationError( 'Subprojects are only allowed within categories' ) def _validate_public_guest_access(self): - """Validate public guest access to ensure it is not set on categories""" - if ( - self.public_guest_access - and self.type == SODAR_CONSTANTS['PROJECT_TYPE_CATEGORY'] - ): - raise ValidationError( - 'Public guest access is not allowed for categories' - ) + """ + Validate public guest access to ensure it is not set on categories. + + NOTE: Does not prevent saving but forces the value to be False, see + issue #1404. + """ + if self.type == PROJECT_TYPE_CATEGORY and self.public_guest_access: + logger.warning(CAT_PUBLIC_ACCESS_MSG + ', setting to False') + self.public_guest_access = False def _validate_title(self): """ @@ -250,10 +258,7 @@ def _validate_archive(self): Validate archive status against project type to ensure archiving is only applied to projects. """ - if ( - self.archive - and self.type != SODAR_CONSTANTS['PROJECT_TYPE_PROJECT'] - ): + if self.archive and self.type != PROJECT_TYPE_PROJECT: raise ValidationError( 'Archiving a category is not currently supported' ) @@ -653,6 +658,9 @@ def is_revoked(self): def set_public(self, public=True): """Helper for setting value of public_guest_access""" if public != self.public_guest_access: + # NOTE: Validation no longer raises an exception (see ¤1404) + if self.type == PROJECT_TYPE_CATEGORY and public: + raise ValidationError(CAT_PUBLIC_ACCESS_MSG) self.public_guest_access = public self.save() self._update_public_children() # Update for parents @@ -852,14 +860,14 @@ class AppSettingManager(models.Manager): """Manager for custom table-level AppSetting queries""" def get_setting_value( - self, app_name, setting_name, project=None, user=None + self, plugin_name, setting_name, project=None, user=None ): """ - Return value of setting_name for app_name in project or for user. + Return value of setting_name for plugin_name in project or for user. Note that project and/or user must be set. - :param app_name: App plugin name (string) + :param plugin_name: App plugin name (string) :param setting_name: Name of setting (string) :param project: Project object or pk :param user: User object or pk @@ -871,8 +879,8 @@ def get_setting_value( 'project': project, 'user': user, } - if not app_name == 'projectroles': - query_parameters['app_plugin__name'] = app_name + if not plugin_name == 'projectroles': + query_parameters['app_plugin__name'] = plugin_name setting = super().get_queryset().get(**query_parameters) return setting.get_value() @@ -929,7 +937,6 @@ class AppSetting(models.Model): #: Value of the setting value = models.CharField( - max_length=APP_SETTING_VAL_MAXLENGTH, unique=False, null=True, blank=True, @@ -1094,6 +1101,31 @@ def __repr__(self): values = (self.project.title, self.email, self.role.name, self.active) return 'ProjectInvite({})'.format(', '.join(repr(v) for v in values)) + # Custom row-level functions + + def is_ldap(self): + """ + Return True if invite is intended for an LDAP user. + + :return: Boolean + """ + # Only consider LDAP if enabled in Django settings + if not settings.ENABLE_LDAP: + return False + # Check if domain is associated with LDAP + domain = self.email.split('@')[1].lower() + domain_no_tld = domain.split('.')[0].lower() + ldap_domains = [ + getattr(settings, 'AUTH_LDAP_USERNAME_DOMAIN', '').lower(), + getattr(settings, 'AUTH_LDAP2_USERNAME_DOMAIN', '').lower(), + ] + alt_domains = [ + a.lower() for a in getattr(settings, 'LDAP_ALT_DOMAINS', []) + ] + if domain_no_tld in ldap_domains or domain in alt_domains: + return True + return False + # RemoteSite ------------------------------------------------------------------- @@ -1141,6 +1173,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, @@ -1148,11 +1193,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'] @@ -1180,8 +1220,10 @@ def _validate_mode(self): def get_access_date(self): """Return date of latest project access by remote site""" - projects = RemoteProject.objects.filter(site=self).order_by( - '-date_access' + projects = ( + RemoteProject.objects.filter(site=self) + .exclude(date_access__isnull=True) + .order_by('-date_access') ) if projects.count() > 0: return projects.first().date_access @@ -1250,6 +1292,15 @@ class RemoteProject(models.Model): class Meta: ordering = ['site__name', 'project_uuid'] + def save(self, *args, **kwargs): + # NOTE: Can't use unique constraint with foreign key relation + rp = self.__class__.objects.filter( + project_uuid=self.project_uuid, site=self.site + ).first() + if rp and rp.id != self.id: + raise ValidationError(REMOTE_PROJECT_UNIQUE_MSG) + super().save(*args, **kwargs) + def __str__(self): return '{}: {} ({})'.format( self.site.name, str(self.project_uuid), self.site.mode @@ -1269,7 +1320,7 @@ def get_project(self): ) -# Abstract User Model ---------------------------------------------------------- +# User Models ------------------------------------------------------------------ class SODARUser(AbstractUser): @@ -1320,20 +1371,60 @@ def get_form_label(self, email=False): ret += ' <{}>'.format(self.email) return ret + def get_auth_type(self): + """ + Return user authentication type: OIDC, LDAP or local. + + :return: String which may equal AUTH_TYPE_OIDC, AUTH_TYPE_LDAP or + AUTH_TYPE_LOCAL. + """ + groups = [g.name for g in self.groups.all()] + if 'oidc' in groups: + return AUTH_TYPE_OIDC + elif ( + self.username.find('@') != -1 + and self.username.split('@')[1].lower() in groups + ): + return AUTH_TYPE_LDAP + return AUTH_TYPE_LOCAL + + def is_local(self): + """ + Return True if user is of type AUTH_TYPE_LOCAL. + + :return: Boolean + """ + return self.get_auth_type() == AUTH_TYPE_LOCAL + def set_group(self): - """Set user group based on user name.""" - if self.username.find('@') != -1: + """Set user group based on user name or social auth provider""" + social_auth = getattr(self, 'social_auth', None) + if social_auth: + social_auth = social_auth.first() + ldap_domains = [ + getattr(settings, 'AUTH_LDAP_USERNAME_DOMAIN', '').upper(), + getattr(settings, 'AUTH_LDAP2_USERNAME_DOMAIN', '').upper(), + ] + # OIDC user group + if social_auth and social_auth.provider == AUTH_PROVIDER_OIDC: + group_name = AUTH_PROVIDER_OIDC + # LDAP domain user groups + elif ( + self.username.find('@') != -1 + and self.username.split('@')[1] in ldap_domains + ): group_name = self.username.split('@')[1].lower() + # System user group for local users else: group_name = SODAR_CONSTANTS['SYSTEM_USER_GROUP'] group, created = Group.objects.get_or_create(name=group_name) if group not in self.groups.all(): group.user_set.add(self) + logger.info( + 'Set user group "{}" for {}'.format(group_name, self.username) + ) return group_name - def is_local(self): - return not bool(re.search('@[A-Za-z0-9._-]+$', self.username)) - def update_full_name(self): """ Update full name of user. @@ -1341,11 +1432,19 @@ def update_full_name(self): :return: String """ # Save user name from first_name and last_name into name - if self.name in ['', None] and self.first_name != '': - self.name = self.first_name + ( + full_name = '' + if self.first_name != '': + full_name = self.first_name + ( ' ' + self.last_name if self.last_name != '' else '' ) - self.save() + if self.name != full_name: + self.name = full_name + self.save() + logger.info( + 'Full name updated for user {}: {}'.format( + self.username, self.name + ) + ) return self.name def update_ldap_username(self): @@ -1363,3 +1462,89 @@ def update_ldap_username(self): self.username = u_split[0] + '@' + u_split[1].upper() self.save() return self.username + + +class SODARUserAdditionalEmail(models.Model): + """ + Model representing an additional email address for a user. Stores + information for email verification. + """ + + #: User for whom the email is assigned + user = models.ForeignKey( + AUTH_USER_MODEL, + related_name='additional_emails', + help_text='User for whom the email is assigned', + on_delete=models.CASCADE, + ) + + #: Email address + email = models.EmailField( + unique=False, + null=False, + blank=False, + help_text='Email address', + ) + + #: Email verification status + verified = models.BooleanField( + default=False, help_text='Email verification status' + ) + + #: Secret token for email verification + secret = models.CharField( + max_length=255, + unique=True, + blank=False, + null=False, + help_text='Secret token for email verification', + ) + + #: DateTime of creation + date_created = models.DateTimeField( + auto_now_add=True, help_text='DateTime of creation' + ) + + #: DateTime of last modification + date_modified = models.DateTimeField( + auto_now=True, help_text='DateTime of last modification' + ) + + #: SODARUserAdditionalEmail SODAR UUID + sodar_uuid = models.UUIDField( + default=uuid.uuid4, + unique=True, + help_text='SODARUserAdditionalEmail SODAR UUID', + ) + + class Meta: + ordering = ['user__username', 'email'] + unique_together = ['user', 'email'] + + def __str__(self): + return '{}: {}'.format(self.user.username, self.email) + + def __repr__(self): + values = ( + self.user.username, + self.email, + str(self.verified), + self.secret, + str(self.sodar_uuid), + ) + return 'SODARUserAdditionalEmail({})'.format( + ', '.join(repr(v) for v in values) + ) + + def _validate_email_unique(self): + """ + Assert the same email has not yet been set for the user. + """ + if self.email == self.user.email: + raise ValidationError( + ADD_EMAIL_ALREADY_SET_MSG.format(email_type='primary') + ) + + def save(self, *args, **kwargs): + self._validate_email_unique() + super().save(*args, **kwargs) diff --git a/projectroles/plugins.py b/projectroles/plugins.py index b5cbb4b9..c1dba387 100644 --- a/projectroles/plugins.py +++ b/projectroles/plugins.py @@ -165,7 +165,7 @@ def perform_project_sync(self, project): def perform_project_setting_update( self, - app_name, + plugin_name, setting_name, value, old_value, @@ -176,8 +176,8 @@ def perform_project_setting_update( Perform additional actions when updating a single app setting with PROJECT scope. - :param app_name: Name of app plugin for the setting, "projectroles" is - used for projectroles settings (string) + :param plugin_name: Name of app plugin for the setting, "projectroles" + is used for projectroles settings (string) :param setting_name: Setting name (string) :param value: New value for setting :param old_value: Previous value for setting @@ -189,7 +189,7 @@ def perform_project_setting_update( def revert_project_setting_update( self, - app_name, + plugin_name, setting_name, value, old_value, @@ -200,8 +200,8 @@ def revert_project_setting_update( Revert updating a single app setting with PROJECT scope if errors have occurred in other apps. - :param app_name: Name of app plugin for the setting, "projectroles" is - used for projectroles settings (string) + :param plugin_name: Name of app plugin for the setting, "projectroles" + is used for projectroles settings (string) :param setting_name: Setting name (string) :param value: New value for setting :param old_value: Previous value for setting @@ -356,12 +356,12 @@ def get_object(self, model, uuid): def get_object_link(self, model_str, uuid): """ - Return URL referring to an object used by the app, along with a label to + Return URL referring to an object used by the app, along with a name to be shown to the user for linking. :param model_str: Object class (string) :param uuid: sodar_uuid of the referred object - :return: Dict or None if not found + :return: PluginObjectLink or None if not found """ obj = self.get_object(eval(model_str), uuid) if not obj: @@ -384,17 +384,11 @@ def search(self, search_terms, user, search_type=None, keywords=None): :param user: User object for user initiating the search :param search_type: String :param keywords: List (optional) - :return: Dict + :return: List of PluginSearchResult objects """ # TODO: Implement this in your app plugin # TODO: Implement display of results in the app's search template - return { - 'all': { # You can add 1-N lists of result items - 'title': 'Title to be displayed', - 'search_types': [], - 'items': [], - } - } + return [] def update_cache(self, name=None, project=None, user=None): """ @@ -497,12 +491,12 @@ def get_object(self, model, uuid): def get_object_link(self, model_str, uuid): """ - Return URL referring to an object used by the app, along with a label to + Return URL referring to an object used by the app, along with a name to be shown to the user for linking. :param model_str: Object class (string) :param uuid: sodar_uuid of the referred object - :return: Dict or None if not found + :return: PluginObjectLink or None if not found """ obj = self.get_object(eval(model_str), uuid) if not obj: @@ -604,12 +598,12 @@ def get_object(self, model, uuid): def get_object_link(self, model_str, uuid): """ - Return URL referring to an object used by the app, along with a label to + Return URL referring to an object used by the app, along with a name to be shown to the user for linking. :param model_str: Object class (string) :param uuid: sodar_uuid of the referred object - :return: Dict or None if not found + :return: PluginObjectLink or None if not found """ obj = self.get_object(eval(model_str), uuid) if not obj: @@ -634,6 +628,73 @@ def validate_form_app_settings(self, app_settings, user=None): return None +# Data Classes ----------------------------------------------------------------- + + +class PluginObjectLink: + """ + Class representing a hyperlink to an object used by the app. Expected to be + returned from get_object_link() implementations. + """ + + #: URL to the object (string) + url = None + #: Name of the object to be displayed in link (string, formerly "label") + name = None + #: Open the link in a blank browser tab (boolean, default=False) + blank = False + + def __init__(self, url, name, blank=False): + """ + Initialize PluginObjectLink. + + :param url: URL to the object (string) + :param name: Name of the object to be displayed in link (string, + formerly "label") + :param blank: Open the link in a blank browser tab (boolean, + default=False) + """ + self.url = url + self.name = name + self.blank = blank + + +class PluginSearchResult: + """ + Class representing a list of search results from a specific plugin for one + or more search types. Expected to be returned from search() implementations. + """ + + #: Category of the result set, used in templates (string) + category = None + #: Title to be displayed for this set of search results in the UI (string) + title = None + #: List of one or more search type keywords for these results + search_types = [] + #: List or QuerySet of result objects + items = [] + + def __init__(self, category, title, search_types, items): + """ + Initialize PluginSearchResult. + + :param category: Category of the result set, used in templates (string) + :param title: Title to be displayed for this set of search results in + the UI (string) + :param search_types: List of one or more search type keywords for the + results + :param items: List or QuerySet of result objects + """ + self.category = category + self.title = title + self.search_types = search_types + if not isinstance(search_types, list) or len(search_types) < 1: + raise ValueError( + 'At least one type keyword must be provided in search_types' + ) + self.items = items + + # Plugin API ------------------------------------------------------------------- @@ -667,9 +728,11 @@ def get_active_plugins(plugin_type='project_app', custom_order=False): ] return sorted( ret, - key=lambda x: x.plugin_ordering - if custom_order and plugin_type == 'project_app' - else x.name, + key=lambda x: ( + x.plugin_ordering + if custom_order and plugin_type == 'project_app' + else x.name + ), ) return None diff --git a/projectroles/remote_projects.py b/projectroles/remote_projects.py index d39d9cf4..3bf2e7d3 100644 --- a/projectroles/remote_projects.py +++ b/projectroles/remote_projects.py @@ -6,7 +6,6 @@ import urllib from copy import deepcopy -from packaging import version from django.conf import settings from django.contrib import auth @@ -20,7 +19,7 @@ from projectroles.app_settings import ( AppSettingAPI, - APP_SETTING_LOCAL_DEFAULT, + APP_SETTING_GLOBAL_DEFAULT, ) from projectroles.models import ( Project, @@ -28,6 +27,7 @@ RoleAssignment, RemoteProject, RemoteSite, + SODARUserAdditionalEmail, SODAR_CONSTANTS, AppSetting, ) @@ -47,15 +47,16 @@ SITE_MODE_TARGET = SODAR_CONSTANTS['SITE_MODE_TARGET'] SITE_MODE_SOURCE = SODAR_CONSTANTS['SITE_MODE_SOURCE'] 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_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'] +SYSTEM_USER_GROUP = SODAR_CONSTANTS['SYSTEM_USER_GROUP'] # Local constants APP_NAME = 'projectroles' +NO_LOCAL_USERS_MSG = 'Local users not allowed' class RemoteProjectAPI: @@ -80,6 +81,9 @@ def __init__(self): #: Updated parent projects in current sync operation self.updated_parents = [] + #: User name/UUID lookup + self.user_lookup = {} + # Internal Source Site Functions ------------------------------------------- @classmethod @@ -105,9 +109,9 @@ def _add_parent_categories(cls, sync_data, category, project_level): cat_data = { 'title': category.title, 'type': PROJECT_TYPE_CATEGORY, - 'parent_uuid': str(category.parent.sodar_uuid) - if category.parent - else None, + 'parent_uuid': ( + str(category.parent.sodar_uuid) if category.parent else None + ), 'description': category.description, 'readme': category.readme.raw, } @@ -156,59 +160,84 @@ def _add_user(cls, sync_data, user): if user.username not in [ u['username'] for u in sync_data['users'].values() ]: + add_emails = SODARUserAdditionalEmail.objects.filter( + user=user, verified=True + ) sync_data['users'][str(user.sodar_uuid)] = { 'username': user.username, 'name': user.name, 'first_name': user.first_name, 'last_name': user.last_name, 'email': user.email, + 'additional_emails': [e.email for e in add_emails], 'groups': [g.name for g in user.groups.all()], + 'sodar_uuid': str(user.sodar_uuid), } return sync_data @classmethod - def _add_app_setting(cls, sync_data, app_setting, all_defs, add_user_name): + def _add_app_setting(cls, sync_data, app_setting, all_defs): """ Add app setting to sync data on source site. :param sync_data: Sync data to be updated (dict) :param app_setting: AppSetting object :param all_defs: All settings defs - :param add_user_name: Add user_name to sync data (boolean) :return: Updated sync_data (dict) """ if app_setting.app_plugin: plugin_name = app_setting.app_plugin.name else: plugin_name = 'projectroles' - local = ( - all_defs.get(plugin_name, {}) - .get(app_setting.name, {}) - .get('local', APP_SETTING_LOCAL_DEFAULT) + global_val = app_settings.get_global_value( + all_defs.get(plugin_name, {}).get(app_setting.name, {}) ) - # TODO: Remove user_name once #1316 and #1317 are implemented + # NOTE: Provide user_name in case of local target users sync_data['app_settings'][str(app_setting.sodar_uuid)] = { 'name': app_setting.name, 'type': app_setting.type, 'value': app_setting.value, 'value_json': app_setting.value_json, - 'app_plugin': app_setting.app_plugin.name - if app_setting.app_plugin - else None, - 'project_uuid': str(app_setting.project.sodar_uuid) - if app_setting.project - else None, - 'user_uuid': str(app_setting.user.sodar_uuid) - if app_setting.user - else None, - 'local': local, + 'app_plugin': ( + app_setting.app_plugin.name if app_setting.app_plugin else None + ), + 'project_uuid': ( + str(app_setting.project.sodar_uuid) + if app_setting.project + else None + ), + 'user_uuid': ( + str(app_setting.user.sodar_uuid) if app_setting.user else None + ), + 'user_name': ( + app_setting.user.username if app_setting.user else None + ), + 'global': global_val, } - if add_user_name: - sync_data['app_settings'][str(app_setting.sodar_uuid)][ - 'user_name' - ] = (app_setting.user.username if app_setting.user else None) return sync_data + @classmethod + def _get_app_setting_error(cls, app_setting, exception): + """ + Return app setting error message to be logged. + + :param app_setting: AppSetting object + :param exception: Exception object + :return: String + """ + return ( + 'Failed to add app setting "{}.settings.{}" (UUID={}): {} ' + ).format( + ( + app_setting.app_plugin.name + if app_setting.app_plugin + else 'projectroles' + ), + app_setting.name, + app_setting.sodar_uuid, + exception, + ) + # Source Site API functions ------------------------------------------------ def get_source_data(self, target_site, req_version=None): @@ -220,13 +249,6 @@ def get_source_data(self, target_site, req_version=None): :param req_version: Request version (string) :return: Dict """ - # TODO: Remove user_name workaround when API backwards compatibility to - # <0.13.3 is removed - if not req_version: - from projectroles.views_api import CORE_API_DEFAULT_VERSION - - req_version = CORE_API_DEFAULT_VERSION - add_user_name = version.parse(req_version) >= version.parse('0.13.3') sync_data = { 'users': {}, 'projects': {}, @@ -250,24 +272,11 @@ def get_source_data(self, target_site, req_version=None): ] # Get and add app settings for project - # NOTE: Also provide global settings in case they are not yet set for a in AppSetting.objects.filter(project=project): try: - sync_data = self._add_app_setting( - sync_data, a, all_defs, add_user_name - ) + sync_data = self._add_app_setting(sync_data, a, all_defs) except Exception as ex: - logger.error( - 'Failed to add app setting "{}.settings.{}" ' - '(UUID={}): {} '.format( - a.app_plugin.name - if a.app_plugin - else 'projectroles', - a.name, - a.sodar_uuid, - ex, - ) - ) + logger.error(self._get_app_setting_error(a, ex)) # RemoteSite data to create objects on target site for site in remote_sites: @@ -314,6 +323,13 @@ def get_source_data(self, target_site, req_version=None): } sync_data = self._add_user(sync_data, role_as.user) sync_data['projects'][str(rp.project_uuid)] = project_data + + # Sync USER scope settings + for a in AppSetting.objects.filter(project=None).exclude(user=None): + try: + sync_data = self._add_app_setting(sync_data, a, all_defs) + except Exception as ex: + logger.error(self._get_app_setting_error(a, ex)) return sync_data def get_remote_data(self, site): @@ -325,8 +341,8 @@ def get_remote_data(self, site): :return: remote data (dict) """ from projectroles.views_api import ( - CORE_API_MEDIA_TYPE, - CORE_API_DEFAULT_VERSION, + SYNC_API_MEDIA_TYPE, + SYNC_API_DEFAULT_VERSION, ) api_url = site.get_url() + reverse( @@ -338,7 +354,7 @@ def get_remote_data(self, site): api_req.add_header( 'accept', '{}; version={}'.format( - CORE_API_MEDIA_TYPE, CORE_API_DEFAULT_VERSION + SYNC_API_MEDIA_TYPE, SYNC_API_DEFAULT_VERSION ), ) response = urllib.request.urlopen(api_req) @@ -358,6 +374,19 @@ def get_remote_data(self, site): # Internal Target Site Functions ------------------------------------------- + @staticmethod + def _is_local_user(user_data): + """ + Return True if user data denotes a local user. + + :param user_data: Dict + :return: Boolean + """ + return ( + not user_data.get('groups') + or SYSTEM_USER_GROUP in user_data['groups'] + ) + @staticmethod def _update_obj(obj, obj_data, fields): """ @@ -401,18 +430,31 @@ def _check_local_categories(self, uuid): def _sync_user(self, uuid, user_data): """ - Synchronize LDAP user on target site. + Synchronize user on target site. For local users, will only update an + existing user object. Local users must be manually created. If local + users are not allowed, data is not synchronized. :param uuid: User UUID (string) :param user_data: User sync data (dict) """ + # Don't sync local users if disallowed + allow_local = getattr(settings, 'PROJECTROLES_ALLOW_LOCAL_USERS', False) + if self._is_local_user(user_data) and not allow_local: + logger.info(NO_LOCAL_USERS_MSG) + return + # Add UUID to user_data to ensure it gets synced + user_data['sodar_uuid'] = uuid + # Pop additional emails + # TODO: Simply omit this from object creation/update instead? + add_emails = user_data.pop('additional_emails') + # Update existing user try: user = User.objects.get(username=user_data['username']) updated_fields = [] for k, v in user_data.items(): if ( - k not in ['groups', 'sodar_uuid'] + k != 'groups' and hasattr(user, k) and str(getattr(user, k)) != str(v) ): @@ -452,6 +494,14 @@ def _sync_user(self, uuid, user_data): # Create new user except User.DoesNotExist: + if self._is_local_user(user_data): + logger.warning( + 'Local user "{}" not found: local users must ' + 'be manually created before they can be synced'.format( + user_data['username'] + ) + ) + return create_values = { k: v for k, v in user_data.items() if k != 'groups' } @@ -468,6 +518,29 @@ def _sync_user(self, uuid, user_data): ) ) + # Sync additional emails + deleted_emails = SODARUserAdditionalEmail.objects.filter( + user=user + ).exclude(email__in=add_emails) + for e in deleted_emails: + e.delete() + logger.info( + 'Deleted user {} additional email "{}"'.format( + user.username, e.email + ) + ) + for e in add_emails: + email_obj = SODARUserAdditionalEmail.objects.create( + user=user, email=e, verified=True + ) + logger.info( + 'Created user {} additional email "{}"'.format( + user.username, email_obj.email + ) + ) + # HACK: Re-add additional emails + user_data['additional_emails'] = add_emails + def _handle_user_error(self, error_msg, project, role_uuid): """ Handle user sync error on target site. @@ -545,7 +618,7 @@ def _update_project(self, project, project_data, parent): user=self.tl_user, event_name='remote_project_update', description=tl_desc, - status_type='OK', + status_type=self.timeline.TL_STATUS_OK, ) tl_event.add_object( self.source_site, 'site', self.source_site.name @@ -598,7 +671,7 @@ def _create_project(self, uuid, project_data, parent): user=self.tl_user, event_name='remote_project_create', description='create project from remote site {site}', - status_type='OK', + status_type=self.timeline.TL_STATUS_OK, ) # TODO: Add extra_data tl_event.add_object(self.source_site, 'site', self.source_site.name) @@ -668,8 +741,11 @@ def _update_roles(self, project, project_data): continue # Ensure the user is valid + local_user = self._is_local_user( + self.remote_data['users'][self.user_lookup[r['user']]] + ) if ( - '@' not in r['user'] + local_user and not allow_local and r['role'] != PROJECT_ROLE_OWNER ): @@ -679,10 +755,9 @@ def _update_roles(self, project, project_data): ) self._handle_user_error(error_msg, project, r_uuid) continue - # If local user, ensure they exist elif ( - '@' not in r['user'] + local_user and allow_local and r['role'] != PROJECT_ROLE_OWNER and not User.objects.filter(username=r['user']).first() @@ -693,21 +768,20 @@ def _update_roles(self, project, project_data): ) self._handle_user_error(error_msg, project, r_uuid) continue - - # Use the default owner, if owner role for a non-LDAP user and local + # Use the default owner, if owner role is for local user and local # users are not allowed if ( - r['role'] == PROJECT_ROLE_OWNER + local_user + and r['role'] == PROJECT_ROLE_OWNER and ( not allow_local or not User.objects.filter(username=r['user']).first() ) - and '@' not in r['user'] ): role_user = self.default_owner # Notify of assigning role to default owner status_msg = ( - 'Non-LDAP/AD user "{}" set as owner, assigning role ' + 'Local user "{}" set as owner, assigning role ' 'to user "{}"'.format( r['user'], self.default_owner.username ) @@ -756,7 +830,7 @@ def _update_roles(self, project, project_data): user=self.tl_user, event_name='remote_role_update', description=tl_desc, - status_type='OK', + status_type=self.timeline.TL_STATUS_OK, ) tl_event.add_object(role_user, 'user', role_user.username) tl_event.add_object( @@ -793,7 +867,7 @@ def _update_roles(self, project, project_data): user=self.tl_user, event_name='remote_role_create', description=tl_desc, - status_type='OK', + status_type=self.timeline.TL_STATUS_OK, ) tl_event.add_object(role_user, 'user', role_user.username) tl_event.add_object( @@ -846,7 +920,7 @@ def _remove_deleted_roles(self, project, project_data): user=self.tl_user, event_name='remote_role_delete', description=tl_desc, - status_type='OK', + status_type=timeline.TL_STATUS_OK, ) tl_event.add_object(del_user, 'user', del_user.username) tl_event.add_object( @@ -945,7 +1019,6 @@ def _sync_project(self, uuid, project_data): ]: return # Create/update roles - # NOTE: Only update AD/LDAP user roles and local owner roles if project_data['level'] == REMOTE_LEVEL_READ_ROLES: self._update_roles(project, project_data) # Remove deleted user roles (also for REVOKED projects) @@ -1037,8 +1110,7 @@ def _remove_revoked_peers(cls, uuid, project_data): ) ) - @classmethod - def _sync_app_setting(cls, uuid, set_data): + def _sync_app_setting(self, uuid, set_data): """ Create or update an AppSetting on a target site. @@ -1047,37 +1119,42 @@ def _sync_app_setting(cls, uuid, set_data): """ ad = deepcopy(set_data) app_plugin = None - project = None user = None + user_name = ad.get('user_name') + user_uuid = ad.get('user_uuid') + project = None + obj = None + skip_msg = 'Skipping setting "{}": '.format(ad['name']) # Get app plugin (skip the rest if not found on target server) if ad['app_plugin']: app_plugin = Plugin.objects.filter(name=ad['app_plugin']).first() if not app_plugin: logger.debug( - 'Skipping setting "{}": App plugin not found with name ' - '"{}"'.format(ad['name'], ad['app_plugin']) + skip_msg + 'App plugin not found with name ' + '"{}"'.format(ad['app_plugin']) ) return - - if ad['project_uuid']: - project = Project.objects.get(sodar_uuid=ad['project_uuid']) - # TODO: Use UUID for LDAP users once #1316 and #1317 are implemented - if ad.get('user_name'): + if user_name: + local_user = self._is_local_user( + self.remote_data['users'][user_uuid] + ) + allow_local = getattr( + settings, 'PROJECTROLES_ALLOW_LOCAL_USERS', False + ) + if local_user and not allow_local: + logger.info(skip_msg + NO_LOCAL_USERS_MSG) + return + user = User.objects.filter(sodar_uuid=user_uuid).first() # User may not be found if e.g. local users allowed but not created - user = User.objects.filter(username=ad['user_name']).first() if not user: logger.info( - 'Skipping setting {}: User not found'.format(ad['name']) + 'Skipping setting "{}": User not found (user_name={}; ' + 'user_uuid={})'.format(ad['name'], user_name, user_uuid) ) return - # Skip for now, as UUIDs have not been correctly synced - # TODO: Remove skip after #1316 and #1317 - elif ad['user_uuid']: - logger.info( - 'Skipping setting {}: user_name not present'.format(ad['name']) - ) - return + if ad['project_uuid']: + project = Project.objects.get(sodar_uuid=ad['project_uuid']) try: obj = AppSetting.objects.get( @@ -1087,7 +1164,7 @@ def _sync_app_setting(cls, uuid, set_data): user=user, ) # Keep target instance of local app setting if available - if ad.get('local', APP_SETTING_LOCAL_DEFAULT): + if not ad.get('global', APP_SETTING_GLOBAL_DEFAULT): logger.info('Keeping local setting {}'.format(ad['name'])) set_data['status'] = 'skipped' return @@ -1100,16 +1177,16 @@ def _sync_app_setting(cls, uuid, set_data): ) set_data['status'] = 'skipped' return - # Else update existing value by recreating object + # Else update existing value action_str = 'updating' - obj.delete() except ObjectDoesNotExist: action_str = 'creating' # Remove keys that are not available in the model - ad.pop('local', None) + ad.pop('global', None) + ad.pop('local', None) # TODO: Remove in v1.1 (see #1394) ad.pop('project_uuid', None) - ad.pop('user_name', None) # TODO: Remove once user UUID support added + ad.pop('user_name', None) ad.pop('user_uuid', None) # Add keys required for the model ad['project'] = project @@ -1118,9 +1195,13 @@ def _sync_app_setting(cls, uuid, set_data): if app_plugin: ad['app_plugin'] = app_plugin - # Create new app setting - obj = AppSetting(**ad) logger.info('{} setting {}'.format(action_str.capitalize(), str(obj))) + logger.debug('Setting data: {}'.format(ad)) + if action_str == 'updating' and obj: # Update existing setting object + for k, v in ad.items(): + setattr(obj, k, v) + else: # Create new setting object + obj = AppSetting(**ad) obj.save() set_data['status'] = action_str.replace('ing', 'ed') @@ -1206,14 +1287,12 @@ def sync_remote_data(self, site, remote_data, request=None): logger.info('No new Peer Sites to sync') # Users - logger.info('Synchronizing LDAP/AD users..') - # NOTE: only sync LDAP/AD users - for u_uuid, u_data in { - k: v - for k, v in self.remote_data['users'].items() - if '@' in v['username'] - }.items(): + logger.info('Synchronizing users..') + # NOTE: Add all users here, only update local users within _sync_user() + for u_uuid, u_data in self.remote_data['users'].items(): self._sync_user(u_uuid, u_data) + # HACK: Populate user lookup + self.user_lookup[u_data['username']] = u_uuid logger.info('User sync OK') # Categories and Projects @@ -1236,9 +1315,11 @@ def sync_remote_data(self, site, remote_data, request=None): except Exception as ex: logger.error( 'Failed to set app setting "{}.setting.{}" ({}): {}'.format( - a_data['app_plugin'] - if a_data['app_plugin'] - else 'projectroles', + ( + a_data['app_plugin'] + if a_data['app_plugin'] + else 'projectroles' + ), a_data['name'], a_uuid, ex, diff --git a/projectroles/rules.py b/projectroles/rules.py index e634cec8..37a30fbb 100644 --- a/projectroles/rules.py +++ b/projectroles/rules.py @@ -142,6 +142,18 @@ def is_allowed_anonymous(user): return False +@rules.predicate +def is_source_site(): + """Return True if PROJECTROLES_SITE_MODE is set to SITE_MODE_PROJECT""" + return settings.PROJECTROLES_SITE_MODE == SITE_MODE_SOURCE + + +@rules.predicate +def is_target_site(): + """Return True if PROJECTROLES_SITE_MODE is set to SITE_MODE_TARGET""" + return settings.PROJECTROLES_SITE_MODE == SITE_MODE_TARGET + + # Combined predicates ---------------------------------------------------------- diff --git a/projectroles/serializers.py b/projectroles/serializers.py index 7048b6b3..b459cfe1 100644 --- a/projectroles/serializers.py +++ b/projectroles/serializers.py @@ -14,6 +14,7 @@ RoleAssignment, ProjectInvite, AppSetting, + SODARUserAdditionalEmail, SODAR_CONSTANTS, ROLE_RANKING, CAT_DELIMITER, @@ -147,9 +148,26 @@ def to_representation(self, instance): class SODARUserSerializer(SODARModelSerializer): """Serializer for the user model used in SODAR Core based sites""" + additional_emails = serializers.SerializerMethodField() + class Meta: model = User - fields = ['username', 'name', 'email', 'is_superuser', 'sodar_uuid'] + fields = [ + 'username', + 'name', + 'email', + 'additional_emails', + 'is_superuser', + 'sodar_uuid', + ] + + def get_additional_emails(self, obj): + return [ + e.email + for e in SODARUserAdditionalEmail.objects.filter( + user=obj, verified=True + ).order_by('email') + ] # Projectroles Serializers ----------------------------------------------------- @@ -367,10 +385,12 @@ class Meta: 'readme', 'public_guest_access', 'archive', + 'full_title', 'owner', 'roles', 'sodar_uuid', ] + read_only_fields = ['full_title'] def _validate_parent(self, parent, attrs, current_user, disable_categories): """Validate parent field""" @@ -546,7 +566,7 @@ def to_representation(self, instance): title=ret['title'], **{'parent__sodar_uuid': parent} if parent else {}, ) - # Return only title and UUID for projects with finder role + # Return only title, full title and UUID for projects with finder role user = self.context['request'].user if ( project.type == PROJECT_TYPE_PROJECT @@ -560,6 +580,7 @@ def to_representation(self, instance): ): return { 'title': project.title, + 'full_title': project.full_title, 'sodar_uuid': str(project.sodar_uuid), } # Else return full serialization @@ -575,6 +596,8 @@ def to_representation(self, instance): ret['roles'][k]['inherited'] = ( True if role_as.project != project else False ) + # Set full_title manually + ret['full_title'] = project.full_title return ret @@ -585,13 +608,13 @@ class AppSettingSerializer(SODARProjectModelSerializer): directly is not the intended way to set/get app settings. """ - app_name = serializers.CharField(read_only=True) + plugin_name = serializers.CharField(read_only=True) user = SODARUserSerializer(read_only=True) class Meta: model = AppSetting fields = [ - 'app_name', + 'plugin_name', 'project', 'user', 'name', @@ -605,8 +628,8 @@ def to_representation(self, instance): """Override to clean up data for serialization""" ret = super().to_representation(instance) if instance.app_plugin: - ret['app_name'] = instance.app_plugin.name + ret['plugin_name'] = instance.app_plugin.name else: - ret['app_name'] = 'projectroles' + ret['plugin_name'] = 'projectroles' ret['value'] = instance.get_value() return ret diff --git a/projectroles/signals.py b/projectroles/signals.py index 9808f9e5..13444b4c 100644 --- a/projectroles/signals.py +++ b/projectroles/signals.py @@ -9,6 +9,8 @@ user_login_failed, ) +from projectroles.models import AUTH_PROVIDER_OIDC + logger = logging.getLogger(__name__) @@ -20,6 +22,7 @@ def handle_ldap_login(sender, user, **kwargs): """Signal for LDAP login handling""" try: if hasattr(user, 'ldap_username'): + logger.debug('Updating LDAP user..') user.update_full_name() user.update_ldap_username() except Exception as ex: @@ -28,6 +31,22 @@ def handle_ldap_login(sender, user, **kwargs): raise ex +def handle_oidc_login(sender, user, **kwargs): + """Signal for OIDC login handling""" + social_auth = getattr(user, 'social_auth', None) + if not social_auth: + return + try: + social_auth = social_auth.first() + if social_auth and social_auth.provider == AUTH_PROVIDER_OIDC: + logger.debug('Updating OIDC user..') + user.update_full_name() + except Exception as ex: + logger.error('Exception in handle_oidc_login(): {}'.format(ex)) + if settings.DEBUG: + raise ex + + def assign_user_group(sender, user, **kwargs): """Signal for user group assignment""" try: @@ -55,6 +74,7 @@ def log_user_login_failure(sender, credentials, **kwargs): user_logged_in.connect(handle_ldap_login) +user_logged_in.connect(handle_oidc_login) user_logged_in.connect(assign_user_group) user_logged_in.connect(log_user_login) user_logged_out.connect(log_user_logout) diff --git a/projectroles/static/projectroles/css/projectroles.css b/projectroles/static/projectroles/css/projectroles.css index a9431f1a..5d14e241 100644 --- a/projectroles/static/projectroles/css/projectroles.css +++ b/projectroles/static/projectroles/css/projectroles.css @@ -392,6 +392,10 @@ dl dd { border-radius: .25rem; } +/* Fix for incorrect highlight color after DAL upgrade (#1412) */ +.select2-results__option--highlighted { + color: #ffffff !important; +} /* HACK for Bootstrap v4 button group alignment with dropdown enabled */ .btn { @@ -406,6 +410,12 @@ dl dd { height: 2.35em; } +/* Enable disabling button-style anchors */ +a.btn[disabled='disabled'] { + pointer-events: none; + opacity: 75%; +} + .sodar-list-btn-group { height: 1.65em; } @@ -435,6 +445,7 @@ a.sodar-pr-btn-copy-secret i { .sodar-pr-sidebar-nav-item a { padding-bottom: 2px !important; + word-break: break-word; } .sodar-pr-sidebar-alt-btn { @@ -634,6 +645,9 @@ span.select2-selection__rendered { /* Misc --------------------------------------------------------------------- */ +hr { + border-color: #dfdfdf; +} img.sodar-navbar-logo { height: 36px; diff --git a/projectroles/static/projectroles/js/project_detail.js b/projectroles/static/projectroles/js/project_detail.js new file mode 100644 index 00000000..51dc2b4f --- /dev/null +++ b/projectroles/static/projectroles/js/project_detail.js @@ -0,0 +1,40 @@ +/* Project detail view specific JQuery */ + +/* Update target/peer remote project links once accessed */ +var updateRemoteProjectLinks = function() { + var linkUuids = []; + $('.sodar-pr-link-remote').each(function() { + if (!$(this).hasClass('sodar-pr-link-remote-source') + && $(this).attr('disabled') === 'disabled') { + linkUuids.push($(this).attr('data-uuid')); + } + }); + if (linkUuids.length > 0) { + var queryParams = [] + for (var i = 0; i < linkUuids.length; i++) { + queryParams.push(['rp', linkUuids[i]]) + } + $.ajax({ + url: window.remoteLinkUrl + '?' + new URLSearchParams(queryParams), + method: 'GET', + dataType: 'JSON', + }).done(function (data) { + for (var k in data) { + var elem = $('a[data-uuid="' +k + '"]'); + if (elem.attr('disabled') === 'disabled' && data[k] === true) { + elem.removeAttr('disabled'); + } + } + }); + }}; + +$(document).ready(function() { + // Set up remote project link updating, omit categories + if ($('div#sodar-pr-page-container-detail').attr( + 'data-project-type') === 'PROJECT') { + updateRemoteProjectLinks(); + setInterval(function () { + updateRemoteProjectLinks(); + }, 5000); + } +}); diff --git a/projectroles/static/projectroles/js/project_form.js b/projectroles/static/projectroles/js/project_form.js index 4a3acdb8..b6e8a974 100644 --- a/projectroles/static/projectroles/js/project_form.js +++ b/projectroles/static/projectroles/js/project_form.js @@ -1,83 +1,64 @@ $(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') { $('div[id^="div_id_settings"]').each(function () { var $parentDiv = $(this); - var $projectElements = $parentDiv.find('select[data-project-types="project"]') - .add($parentDiv.find('input[data-project-types="project"]')) - .add($parentDiv.find('textarea[data-project-types="project"]')) - .add($parentDiv.find('select[data-project-types="project,category"]')) - .add($parentDiv.find('input[data-project-types="project,category"]')) - .add($parentDiv.find('textarea[data-project-types="project,category"]')); - if ($projectElements.length > 0) { - $parentDiv.show(); - } else { - $parentDiv.hide(); - } + var $projectElements = $parentDiv.find('[data-project-types="project"]') + .add($parentDiv.find('[data-project-types="project,category"]')); + if ($projectElements.length > 0) $parentDiv.show(); + 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(); } - if ($('#sodar-pr-project-form-title').attr('data-project-type') === 'CATEGORY') { $('div[id^="div_id_settings"]').each(function () { var $parentDiv = $(this); - var $categoryElements = $parentDiv.find('select[data-project-types="category"]') - .add($parentDiv.find('input[data-project-types="category"]')) - .add($parentDiv.find('textarea[data-project-types="category"]')) - .add($parentDiv.find('select[data-project-types="project,category"]')) - .add($parentDiv.find('input[data-project-types="project,category"]')) - .add($parentDiv.find('textarea[data-project-types="project,category"]')); - if ($categoryElements.length > 0) { - $parentDiv.show(); - } else { - $parentDiv.hide(); - } + var $categoryElements = $parentDiv.find('[data-project-types="category"]') + .add($parentDiv.find('[data-project-types="project,category"]')); + if ($categoryElements.length > 0) $parentDiv.show(); + else $parentDiv.hide(); }); } - // 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"]') - .add($parentDiv.find('input[data-project-types="project"]')) - .add($parentDiv.find('textarea[data-project-types="project"]')) - .add($parentDiv.find('select[data-project-types="project,category"]')) - .add($parentDiv.find('input[data-project-types="project,category"]')) - .add($parentDiv.find('textarea[data-project-types="project,category"]')); - if ($projectElements.length > 0) { - $parentDiv.show(); - } else { - $parentDiv.hide(); - } - - // Temporary solution for hiding the public_guest_access field + var $projectElements = $parentDiv.find('[data-project-types="project"]') + .add($parentDiv.find('[data-project-types="project,category"]')); + if ($projectElements.length > 0) $parentDiv.show(); + else $parentDiv.hide(); $('#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"]') - .add($parentDiv.find('input[data-project-types="category"]')) - .add($parentDiv.find('textarea[data-project-types="category"]')) - .add($parentDiv.find('select[data-project-types="project,category"]')) - .add($parentDiv.find('input[data-project-types="project,category"]')) - .add($parentDiv.find('textarea[data-project-types="project,category"]')); - if ($categoryElements.length > 0) { - $parentDiv.show(); - } else { - $parentDiv.hide(); - } + var $categoryElements = $parentDiv.find('[data-project-types="category"]') + .add($parentDiv.find('[data-project-types="project,category"]')); + if ($categoryElements.length > 0) $parentDiv.show(); + 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/templates/projectroles/_login_oidc.html b/projectroles/templates/projectroles/_login_oidc.html new file mode 100644 index 00000000..2c31f8da --- /dev/null +++ b/projectroles/templates/projectroles/_login_oidc.html @@ -0,0 +1,14 @@ +{# Display custom or default OIDC login element #} +{% load projectroles_common_tags %} + +{% template_exists template_include_path|add:'/_login_oidc.html' as tpl_oidc %} + +{% if tpl_oidc %} + {% include template_include_path|add:'/_login_oidc.html' %} +{% else %} + + OpenID Connect Login + +{% endif %} diff --git a/projectroles/templates/projectroles/_project_menu_btn.html b/projectroles/templates/projectroles/_project_menu_btn.html index e14829aa..48fe74af 100644 --- a/projectroles/templates/projectroles/_project_menu_btn.html +++ b/projectroles/templates/projectroles/_project_menu_btn.html @@ -20,82 +20,14 @@ Home - {# Project stuff #} - {% if project %} - - {# Overview #} - - {% if project.type == PROJECT_TYPE_CATEGORY %} - - {% else %} - - {% endif %} - Overview + {% get_project_app_links request project as dropdown_links %} + {% for link in dropdown_links %} + + {{ link.label }} - - {# App plugins #} - {% for plugin in app_plugins %} - {% is_app_visible plugin project request.user as app_link_visible %} - {% if app_link_visible %} - - {{ plugin.title }} - - {% endif %} - {% endfor %} - - {# Role and project editing #} - {% if can_view_roles %} - - Members - - {% endif %} - {% if can_update_project %} - - Update {% get_display_name project.type title=True %} - - {% endif %} - - {% endif %} - - {# Project and Category Creation #} - - {% if project and project.type == 'CATEGORY' %} - {% has_perm 'projectroles.create_project' request.user project as can_create_project %} - {% if allow_creation and can_create_project and not project.is_remote %} - - Create {% get_display_name 'PROJECT' title=True %} or {% get_display_name 'CATEGORY' title=True %} - - {% endif %} - {% elif disable_categories and request.user.is_superuser %} {# Allow project creation under root #} - - Create {% get_display_name 'PROJECT' title=True %} - - {% elif request.resolver_match.url_name == 'home' or request.resolver_match.url_name == 'create' and not project %} - {% has_perm 'projectroles.create_project' request.user as can_create_project %} - {% if allow_creation and can_create_project %} - - Create {% get_display_name 'CATEGORY' title=True %} - - {% endif %} - {% endif %} - -
+ {% endfor %} +
diff --git a/projectroles/templates/projectroles/_project_sidebar.html b/projectroles/templates/projectroles/_project_sidebar.html index 7cd20d72..6a24c7d9 100644 --- a/projectroles/templates/projectroles/_project_sidebar.html +++ b/projectroles/templates/projectroles/_project_sidebar.html @@ -1,146 +1,19 @@ -{% load rules %} {% load projectroles_tags %} -{% load projectroles_common_tags %} - -{% sodar_constant 'PROJECT_TYPE_PROJECT' as PROJECT_TYPE_PROJECT %} -{% sodar_constant 'PROJECT_TYPE_CATEGORY' as PROJECT_TYPE_CATEGORY %} -{% allow_project_creation as allow_creation %} -{% get_django_setting 'PROJECTROLES_DISABLE_CATEGORIES' as disable_categories %} -{% get_display_name 'PROJECT' title=True as project_display %} -{% get_display_name 'CATEGORY' title=True as category_display %} {# Project nav #} - -{% if project %} - {% has_perm 'projectroles.view_project' request.user project as can_view_project %} - {% has_perm 'projectroles.view_project_roles' request.user project as can_view_roles %} - {% has_perm 'projectroles.update_project' request.user project as can_update_project %} - - {# Overview #} - - - - {# App plugins #} - {% for plugin in app_plugins %} - {% is_app_visible plugin project request.user as app_link_visible %} - {% if app_link_visible %} - {% get_sidebar_app_legend plugin.title as app_legend %} - - {% endif %} - {% endfor %} - - {# Role and project editing #} - {% if can_view_roles %} - - {% endif %} - {% if can_update_project %} - - {% endif %} - -{% endif %} - -{# Project and Category Creation #} -{% if project and project.type == 'CATEGORY' %} - {% has_perm 'projectroles.create_project' request.user project as can_create_project %} - {% if allow_creation and can_create_project and not project.is_remote %} - - {% endif %} -{# Allow project creation under root #} -{% elif disable_categories and request.user.is_superuser %} - -{% elif request.resolver_match.url_name == 'home' or request.resolver_match.app_name == 'projectroles' and not project %} - {% has_perm 'projectroles.create_project' request.user as can_create_project %} - {% if allow_creation and can_create_project %} - - {% endif %} -{% endif %} +{% endfor %} diff --git a/projectroles/templates/projectroles/_remote_project_link.html b/projectroles/templates/projectroles/_remote_project_link.html new file mode 100644 index 00000000..ef493081 --- /dev/null +++ b/projectroles/templates/projectroles/_remote_project_link.html @@ -0,0 +1,17 @@ +{% load projectroles_common_tags %} + + + {{ site.name }} + {% if site.mode == 'SOURCE' %} + (Source {% get_display_name object.type title=True %}) + {% endif %} + diff --git a/projectroles/templates/projectroles/_site_titlebar_dropdown.html b/projectroles/templates/projectroles/_site_titlebar_dropdown.html index 3251a871..9bc7920f 100644 --- a/projectroles/templates/projectroles/_site_titlebar_dropdown.html +++ b/projectroles/templates/projectroles/_site_titlebar_dropdown.html @@ -5,49 +5,16 @@ {% get_django_setting 'PROJECTROLES_KIOSK_MODE' as kiosk_mode %} {# Responsive replacement for user dropdown #} - -{# Admin link #} -{% if request.user.is_superuser %} - -{% endif %} - -{# Site-wide apps #} -{% for plugin in site_apps %} - {% has_perm plugin.app_permission request.user as can_view_app %} - {% if not plugin.app_permission or can_view_app %} - - {% endif %} {% endfor %} - -{# Log out link #} -{% if request.user.is_authenticated %} - -{% elif not kiosk_mode %} - -{% endif %} - {# Actual user dropdown #} diff --git a/projectroles/templates/projectroles/login.html b/projectroles/templates/projectroles/login.html index 2e97b207..62cc4876 100644 --- a/projectroles/templates/projectroles/login.html +++ b/projectroles/templates/projectroles/login.html @@ -7,6 +7,7 @@ {% block title %}Login{% endblock title %} {% block content %} +{% get_django_setting 'PROJECTROLES_TEMPLATE_INCLUDE_PATH' as template_include_path %}
{# Django messages / site app messages #} @@ -26,7 +27,6 @@

Login

- {% autoescape off %} {% get_login_info %} {% endautoescape %} @@ -46,18 +46,17 @@

Login

Login - {% get_django_setting 'ENABLE_SAML' as enable_saml %} - {% if enable_saml %} -
-

To log in with your SSO provider, please click below.

- - Single Sign-On - - {% endif %}
+ {# OpenID Connect (OIDC) auth #} + {% get_django_setting 'ENABLE_OIDC' as enable_oidc %} + {% if enable_oidc %} +
+ {% include 'projectroles/_login_oidc.html' %} +
+ {% endif %} + {# Optional template for additional login page HTML #} - {% get_django_setting 'PROJECTROLES_TEMPLATE_INCLUDE_PATH' as template_include_path %} {% template_exists template_include_path|add:'/_login_extend.html' as login_extend %} {% if login_extend %} {% include template_include_path|add:'/_login_extend.html' %} diff --git a/projectroles/templates/projectroles/project_detail.html b/projectroles/templates/projectroles/project_detail.html index 821109a5..feac3c16 100644 --- a/projectroles/templates/projectroles/project_detail.html +++ b/projectroles/templates/projectroles/project_detail.html @@ -9,145 +9,122 @@ {% block projectroles %} -{% has_perm 'projectroles.view_project' request.user object as can_view_project %} {% has_perm 'projectroles.update_project' request.user object as can_update_project %} -{% has_perm 'projectroles.view_hidden_projects' request.user object as can_view_hidden_projects %} {% sodar_constant 'PROJECT_TYPE_CATEGORY' as PROJECT_TYPE_CATEGORY %} -{% if can_view_project %} - {% include 'projectroles/_project_header.html' %} +{% include 'projectroles/_project_header.html' %} -
- {# Links to remote projects #} - {% if object.type == 'PROJECT' %} - {% get_visible_projects target_projects can_view_hidden_projects as visible_target_projects %} - {% if visible_target_projects %} -
-
-

- - {% get_display_name object.type title=True %} on Other Sites -

-
-
- {% for rp in visible_target_projects %} - - {{ rp.site.name }} - - {% endfor %} -
+
+ {# Links to remote projects #} + {% if object.type == 'PROJECT' %} + {% if target_projects %} +
+
+

+ + {% get_display_name object.type title=True %} on Other Sites +

- - {% elif object.is_remote %} -
-
-

{% get_display_name object.type title=True %} on Other Sites

-
-
- - {{ object.get_source_site.name }} (Master Project) - - {% get_visible_projects peer_projects can_view_hidden_projects as visible_peer_projects %} - {% for peer_p in visible_peer_projects %} - - {{ peer_p.site.name }} - - {% endfor %} -
+
+ {% for rp in target_projects %} + {% include 'projectroles/_remote_project_link.html' with site=rp.site %} + {% endfor %}
- {% endif %} - {% endif %} - - {# README #} -
-
-

ReadMe

-
- {% if object.readme.rendered|length > 0 %} - {% autoescape off %} - {% render_markdown object.readme.raw %} - {% endautoescape %} - {% else %} -

- No ReadMe is currently set for this {% get_display_name object.type title=False %}. - {% if can_update_project %} - You can update the ReadMe here. - {% endif %} -

- {% endif %} + {% elif object.is_remote %} +
+
+

+ + {% get_display_name object.type title=True %} on Other Sites +

+
+
+ {% include 'projectroles/_remote_project_link.html' with site=object.get_source_site %} + {% for rp in peer_projects %} + {% include 'projectroles/_remote_project_link.html' with site=rp.site %} + {% endfor %} +
-
- - {# Subprojects #} - {% if object.type == 'CATEGORY' %} - {% include 'projectroles/_project_list.html' with parent=object %} {% endif %} + {% endif %} - {# App Plugin Cards #} - {% for plugin in app_plugins %} - {% is_app_visible plugin project request.user as app_visible %} - {% if app_visible %} -
-
-

- - {% if plugin.details_title %} - {{ plugin.details_title }} - {% else %} - {{ plugin.title }} - {% endif %} - - {% get_info_link plugin.description as info_link %} - {{ info_link | safe }} - -

-
- {% if plugin.details_template %} -
- {% include plugin.details_template %} -
- {% else %} -
-

No app card template found

-
+ {# README #} +
+
+

ReadMe

+
+
+ {% if object.readme.rendered|length > 0 %} + {% autoescape off %} + {% render_markdown object.readme.raw %} + {% endautoescape %} + {% else %} +

+ No ReadMe is currently set for this {% get_display_name object.type title=False %}. + {% if can_update_project %} + You can update the ReadMe here. {% endif %} -

+

{% endif %} - {% endfor %} +
-{% else %} - -{% endif %} + {# Subprojects #} + {% if object.type == 'CATEGORY' %} + {% include 'projectroles/_project_list.html' with parent=object %} + {% endif %} + + {# App Plugin Cards #} + {% for plugin in app_plugins %} + {% is_app_visible plugin project request.user as app_visible %} + {% if app_visible %} +
+
+

+ + {% if plugin.details_title %} + {{ plugin.details_title }} + {% else %} + {{ plugin.title }} + {% endif %} + + {% get_info_link plugin.description as info_link %} + {{ info_link | safe }} + +

+
+ {% if plugin.details_template %} +
+ {% include plugin.details_template %} +
+ {% else %} +
+

No app card template found

+
+ {% endif %} +
+ {% endif %} + {% endfor %} +
{% endblock projectroles %} {% block javascript %} {{ block.super }} + {% if object.type == 'CATEGORY' %} {% endif %} + + {% endblock javascript %} diff --git a/userprofile/templates/userprofile/email_confirm_delete.html b/userprofile/templates/userprofile/email_confirm_delete.html new file mode 100644 index 00000000..dd5c38ba --- /dev/null +++ b/userprofile/templates/userprofile/email_confirm_delete.html @@ -0,0 +1,44 @@ +{% extends 'projectroles/base.html' %} + +{% load crispy_forms_filters %} + +{% block title %} + Delete Email Address +{% endblock title %} + +{% block projectroles %} + +
+

{{ request.user.get_full_name }}

+
User Profile
+
+ +
+

Delete Email Address {{ object.email }}

+
+ +
+
+ Are you sure you want to delete the additional email address + {{ object.email }}? Deleting the + address means it will no longer receive automated emails from this site. This + can only be undone by adding and re-verifying the address. +
+
+ {% csrf_token %} + {{ form | crispy }} +
+
+ + Cancel + + +
+
+
+
+ +{% endblock projectroles %} diff --git a/userprofile/templates/userprofile/email_form.html b/userprofile/templates/userprofile/email_form.html new file mode 100644 index 00000000..8326ff94 --- /dev/null +++ b/userprofile/templates/userprofile/email_form.html @@ -0,0 +1,38 @@ +{% extends 'projectroles/base.html' %} + +{% load crispy_forms_filters %} + +{% block title %} + Add Email Address +{% endblock title %} + +{% block projectroles %} + +
+

{{ request.user.get_full_name }}

+
User Profile
+
+ +
+

Add Email Address

+
+ +
+
+ {% csrf_token %} + {{ form | crispy }} +
+
+ + Cancel + + +
+
+
+
+ +{% endblock projectroles %} diff --git a/userprofile/tests/test_permissions.py b/userprofile/tests/test_permissions.py index aadceec4..374dccf7 100644 --- a/userprofile/tests/test_permissions.py +++ b/userprofile/tests/test_permissions.py @@ -4,12 +4,27 @@ from django.urls import reverse # Projectroles dependency -from projectroles.tests.test_permissions import TestSiteAppPermissionBase +from projectroles.models import SODAR_CONSTANTS +from projectroles.tests.test_models import ( + SODARUserAdditionalEmailMixin, + ADD_EMAIL, +) +from projectroles.tests.test_permissions import SiteAppPermissionTestBase -class TestUserProfilePermissions(TestSiteAppPermissionBase): +# SODAR constants +SITE_MODE_TARGET = SODAR_CONSTANTS['SITE_MODE_TARGET'] + + +class TestUserProfilePermissions( + SODARUserAdditionalEmailMixin, SiteAppPermissionTestBase +): """Tests for userprofile view permissions""" + def setUp(self): + super().setUp() + self.regular_user2 = self.make_user('regular_user2') + def test_get_profile(self): """Test UserDetailView GET""" url = reverse('userprofile:detail') @@ -35,3 +50,57 @@ def test_get_settings_update_anon(self): url = reverse('userprofile:settings_update') self.assert_response(url, [self.superuser, self.regular_user], 200) self.assert_response(url, self.anonymous, 302) + + def test_get_email_create(self): + """Test UserEmailCreateView GET""" + url = reverse('userprofile:email_create') + self.assert_response(url, [self.superuser, self.regular_user], 200) + self.assert_response(url, self.anonymous, 302) + + @override_settings(PROJECTROLES_ALLOW_ANONYMOUS=True) + def test_get_email_create_anon(self): + """Test UserEmailCreateView GET with anonymous access""" + url = reverse('userprofile:email_create') + self.assert_response(url, [self.superuser, self.regular_user], 200) + self.assert_response(url, self.anonymous, 302) + + @override_settings(PROJECTROLES_SITE_MODE=SITE_MODE_TARGET) + def test_get_email_create_target(self): + """Test UserEmailCreateView GET as target site""" + url = reverse('userprofile:email_create') + self.assert_response(url, self.superuser, 200) + self.assert_response(url, [self.regular_user, self.anonymous], 302) + + def test_get_email_delete(self): + """Test UserEmailDeleteView GET""" + email = self.make_email(self.regular_user, ADD_EMAIL) + url = reverse( + 'userprofile:email_delete', + kwargs={'sodaruseradditionalemail': email.sodar_uuid}, + ) + self.assert_response(url, [self.superuser, self.regular_user], 200) + self.assert_response(url, [self.regular_user2, self.anonymous], 302) + + @override_settings(PROJECTROLES_ALLOW_ANONYMOUS=True) + def test_get_email_delete_anon(self): + """Test UserEmailDeleteView GET with anonymous access""" + email = self.make_email(self.regular_user, ADD_EMAIL) + url = reverse( + 'userprofile:email_delete', + kwargs={'sodaruseradditionalemail': email.sodar_uuid}, + ) + self.assert_response(url, [self.superuser, self.regular_user], 200) + self.assert_response(url, [self.regular_user2, self.anonymous], 302) + + @override_settings(PROJECTROLES_SITE_MODE=SITE_MODE_TARGET) + def test_get_email_delete_target(self): + """Test UserEmailDeleteView GET as target site""" + email = self.make_email(self.regular_user, ADD_EMAIL) + url = reverse( + 'userprofile:email_delete', + kwargs={'sodaruseradditionalemail': email.sodar_uuid}, + ) + self.assert_response(url, [self.superuser], 200) + self.assert_response( + url, [self.regular_user, self.regular_user2, self.anonymous], 302 + ) diff --git a/userprofile/tests/test_ui.py b/userprofile/tests/test_ui.py index 7a66146c..9299e20c 100644 --- a/userprofile/tests/test_ui.py +++ b/userprofile/tests/test_ui.py @@ -2,15 +2,23 @@ from django.test import override_settings from django.urls import reverse + from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait # Projectroles dependency -from projectroles.tests.test_ui import TestUIBase +from projectroles.forms import SETTING_DISABLE_LABEL +from projectroles.models import SODAR_CONSTANTS +from projectroles.tests.test_models import SODARUserAdditionalEmailMixin +from projectroles.tests.test_ui import UITestBase + + +# SODAR constants +SITE_MODE_TARGET = SODAR_CONSTANTS['SITE_MODE_TARGET'] @override_settings(AUTH_LDAP_USERNAME_DOMAIN='EXAMPLE') -class TestUserDetails(TestUIBase): +class TestUserDetails(SODARUserAdditionalEmailMixin, UITestBase): """Tests for user details page""" def setUp(self): @@ -18,15 +26,113 @@ def setUp(self): # Create users self.local_user = self.make_user('local_user', False) self.ldap_user = self.make_user('user@EXAMPLE', False) + self.url = reverse('userprofile:detail') def test_update_button(self): """Test existence of user update button""" - url = reverse('userprofile:detail') expected = [(self.local_user, 1), (self.ldap_user, 0)] - self.assert_element_count(expected, url, 'sodar-user-btn-update') + self.assert_element_count(expected, self.url, 'sodar-user-btn-update') + + def test_additional_email_unset(self): + """Test existence of additional email elements without email""" + self.assert_element_exists( + [self.local_user], + self.url, + 'sodar-user-btn-email-add', + True, + ) + self.assert_element_count( + [(self.local_user, 0)], + self.url, + 'sodar-user-email-table-row', + 'class', + ) + self.assert_element_count( + [(self.local_user, 0)], + self.url, + 'sodar-user-email-dropdown', + 'class', + ) + self.assert_element_exists( + [self.local_user], + self.url, + 'sodar-user-email-table-not-found', + True, + ) + + def test_additional_email_set(self): + """Test existence of additional email elements with email""" + self.make_email(self.local_user, 'add1@example.com') + self.make_email(self.local_user, 'add2@example.com', verified=False) + # Another user, should not be visible + self.make_email(self.ldap_user, 'add3@example.com') + self.assert_element_exists( + [self.local_user], + self.url, + 'sodar-user-btn-email-add', + True, + ) + self.assert_element_count( + [(self.local_user, 2)], + self.url, + 'sodar-user-email-table-row', + 'class', + ) + self.assert_element_count( + [(self.local_user, 2)], + self.url, + 'sodar-user-email-dropdown', + 'class', + ) + self.assert_element_exists( + [self.local_user], + self.url, + 'sodar-user-email-table-not-found', + False, + ) + @override_settings(PROJECTROLES_SEND_EMAIL=False) + def test_additional_email_disabled(self): + """Test existence of email card with PROJECTROLES_SEND_EMAIL=False""" + self.assert_element_exists( + [self.local_user], + self.url, + 'sodar-user-email-card', + False, + ) -class TestUserSettings(TestUIBase): + @override_settings(PROJECTROLES_SITE_MODE=SITE_MODE_TARGET) + def test_additional_email_target(self): + """Test existence of additional email elements as target site""" + self.make_email(self.local_user, 'add1@example.com') + self.make_email(self.local_user, 'add2@example.com', verified=False) + self.assert_element_exists( + [self.local_user], + self.url, + 'sodar-user-btn-email-add', + False, + ) + self.assert_element_count( + [(self.local_user, 2)], + self.url, + 'sodar-user-email-table-row', + 'class', + ) + self.assert_element_count( + [(self.local_user, 0)], + self.url, + 'sodar-user-email-dropdown', + 'class', + ) + self.assert_element_exists( + [self.local_user], + self.url, + 'sodar-user-email-table-not-found', + False, + ) + + +class TestUserSettings(UITestBase): """Tests for user settings page""" def setUp(self): @@ -34,13 +140,11 @@ def setUp(self): # Create users self.local_user = self.make_user('local_user', False) self.ldap_user = self.make_user('user@EXAMPLE', False) + self.url = reverse('userprofile:settings_update') def test_settings_label_icon(self): """Test existence of settings label icon""" - url = reverse('userprofile:settings_update') - self.login_and_redirect( - self.superuser, url, wait_elem=None, wait_loc='ID' - ) + self.login_and_redirect(self.superuser, self.url) WebDriverWait(self.selenium, 15).until( lambda x: x.find_element( By.CSS_SELECTOR, @@ -59,3 +163,44 @@ def test_settings_label_icon(self): ) icon = label.find_element(By.TAG_NAME, 'svg') self.assertTrue(icon.is_displayed()) + + def test_global_setting_source(self): + """Test global user setting on source site""" + self.login_and_redirect(self.superuser, self.url) + WebDriverWait(self.selenium, 15).until( + lambda x: x.find_element( + By.CSS_SELECTOR, + 'div[id="div_id_settings.projectroles.notify_email_project"]', + ) + ) + input_elem = self.selenium.find_element( + By.CSS_SELECTOR, + 'input[id="id_settings.projectroles.notify_email_project"]', + ) + self.assertIsNone(input_elem.get_attribute('disabled')) + label = self.selenium.find_element( + By.CSS_SELECTOR, + 'div[id="div_id_settings.projectroles.notify_email_project"] label', + ) + self.assertNotIn(SETTING_DISABLE_LABEL, label.text) + + @override_settings(PROJECTROLES_SITE_MODE=SITE_MODE_TARGET) + def test_global_setting_target(self): + """Test global user setting on target site""" + self.login_and_redirect(self.superuser, self.url) + WebDriverWait(self.selenium, 15).until( + lambda x: x.find_element( + By.CSS_SELECTOR, + 'div[id="div_id_settings.projectroles.notify_email_project"]', + ) + ) + input_elem = self.selenium.find_element( + By.CSS_SELECTOR, + 'input[id="id_settings.projectroles.notify_email_project"]', + ) + self.assertIsNotNone(input_elem.get_attribute('disabled')) + label = self.selenium.find_element( + By.CSS_SELECTOR, + 'div[id="div_id_settings.projectroles.notify_email_project"] label', + ) + self.assertIn(SETTING_DISABLE_LABEL, label.text) diff --git a/userprofile/tests/test_views.py b/userprofile/tests/test_views.py index aecbf204..4ec1e532 100644 --- a/userprofile/tests/test_views.py +++ b/userprofile/tests/test_views.py @@ -1,27 +1,54 @@ """Tests for views in the userprofile Django app""" +import uuid + from django.contrib import auth from django.contrib.messages import get_messages +from django.core import mail +from django.forms.models import model_to_dict +from django.test import override_settings from django.urls import reverse from test_plus.test import TestCase +# Timeline dependency +from timeline.models import TimelineEvent + # Projectroles dependency from projectroles.app_settings import AppSettingAPI -from projectroles.tests.test_models import EXAMPLE_APP_NAME, AppSettingMixin -from projectroles.views import MSG_FORM_INVALID +from projectroles.models import SODARUserAdditionalEmail, SODAR_CONSTANTS +from projectroles.tests.test_models import ( + EXAMPLE_APP_NAME, + AppSettingMixin, + SODARUserAdditionalEmailMixin, +) +from projectroles.views import FORM_INVALID_MSG +from projectroles.utils import build_secret -from userprofile.views import SETTING_UPDATE_MSG +from userprofile.views import ( + SETTING_UPDATE_MSG, + EMAIL_NOT_FOUND_MSG, + EMAIL_ALREADY_VERIFIED_MSG, + EMAIL_VERIFIED_MSG, + EMAIL_VERIFY_RESEND_MSG, +) app_settings = AppSettingAPI() User = auth.get_user_model() -INVALID_SETTING_VALUE = 'INVALID VALUE' +# SODAR constants +SITE_MODE_TARGET = SODAR_CONSTANTS['SITE_MODE_TARGET'] +# Local constants +INVALID_VALUE = 'INVALID VALUE' +ADD_EMAIL = 'add1@example.com' +ADD_EMAIL2 = 'add2@example.com' +ADD_EMAIL_SECRET = build_secret(32) -class UserViewsTestBase(TestCase): + +class UserViewTestBase(TestCase): """Base class for view testing""" def setUp(self): @@ -35,7 +62,7 @@ def setUp(self): # View tests ------------------------------------------------------------------- -class TestUserDetailView(UserViewsTestBase): +class TestUserDetailView(SODARUserAdditionalEmailMixin, UserViewTestBase): """Tests for UserDetailView""" def test_get(self): @@ -44,9 +71,20 @@ def test_get(self): response = self.client.get(reverse('userprofile:detail')) self.assertEqual(response.status_code, 200) self.assertIsNotNone(response.context['user_settings']) + self.assertEqual(response.context['add_emails'].count(), 0) + + def test_get_additional_email(self): + """Test GET with additional email""" + self.make_email(self.user, 'add@example.com') + self.make_email(self.user, 'add_unverified@example.com', verified=False) + with self.login(self.user): + response = self.client.get(reverse('userprofile:detail')) + self.assertEqual(response.status_code, 200) + self.assertIsNotNone(response.context['user_settings']) + self.assertEqual(response.context['add_emails'].count(), 2) -class TestUserSettingsView(AppSettingMixin, UserViewsTestBase): +class TestUserSettingsView(AppSettingMixin, UserViewTestBase): """Tests for UserSettingsView""" def _get_setting(self, name): @@ -56,7 +94,7 @@ def setUp(self): super().setUp() # Init test setting self.setting_str = self.make_setting( - app_name=EXAMPLE_APP_NAME, + plugin_name=EXAMPLE_APP_NAME, name='user_str_setting', setting_type='STRING', value='test', @@ -64,7 +102,7 @@ def setUp(self): ) # Init integer setting self.setting_int = self.make_setting( - app_name=EXAMPLE_APP_NAME, + plugin_name=EXAMPLE_APP_NAME, name='user_int_setting', setting_type='INTEGER', value=170, @@ -72,7 +110,7 @@ def setUp(self): ) # Init test setting with options self.setting_str_options = self.make_setting( - app_name=EXAMPLE_APP_NAME, + plugin_name=EXAMPLE_APP_NAME, name='user_str_setting_options', setting_type='STRING', value='string1', @@ -80,7 +118,7 @@ def setUp(self): ) # Init integer setting with options self.setting_int_options = self.make_setting( - app_name=EXAMPLE_APP_NAME, + plugin_name=EXAMPLE_APP_NAME, name='user_int_setting_options', setting_type='INTEGER', value=0, @@ -88,7 +126,7 @@ def setUp(self): ) # Init boolean setting self.setting_bool = self.make_setting( - app_name=EXAMPLE_APP_NAME, + plugin_name=EXAMPLE_APP_NAME, name='user_bool_setting', setting_type='BOOLEAN', value=True, @@ -96,7 +134,7 @@ def setUp(self): ) # Init json setting self.setting_json = self.make_setting( - app_name=EXAMPLE_APP_NAME, + plugin_name=EXAMPLE_APP_NAME, name='user_json_setting', setting_type='JSON', value=None, @@ -199,8 +237,7 @@ def test_post(self): def test_post_custom_validation(self): """Test POST with custom validation and invalid value""" values = { - 'settings.example_project_app.' - 'user_str_setting': INVALID_SETTING_VALUE, + 'settings.example_project_app.' 'user_str_setting': INVALID_VALUE, 'settings.example_project_app.user_int_setting': '170', 'settings.example_project_app.user_str_setting_options': 'string1', 'settings.example_project_app.user_int_setting_options': '0', @@ -219,6 +256,287 @@ def test_post_custom_validation(self): self.assertEqual(response.status_code, 200) self.assertEqual( list(get_messages(response.wsgi_request))[0].message, - MSG_FORM_INVALID, + FORM_INVALID_MSG, ) self.assertEqual(self._get_setting('user_str_setting'), 'test') + + +class TestUserEmailCreateView(SODARUserAdditionalEmailMixin, UserViewTestBase): + """Tests for UserEmailCreateView""" + + def setUp(self): + super().setUp() + self.url = reverse('userprofile:email_create') + self.url_redirect = reverse('userprofile:detail') + + def test_get(self): + """Test UserEmailCreateView GET""" + with self.login(self.user): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertIsNotNone(response.context['form']) + + @override_settings(PROJECTROLES_SEND_EMAIL=False) + def test_get_email_disabled(self): + """Test GET with disabled email sending""" + with self.login(self.user): + response = self.client.get(self.url) + self.assertRedirects(response, self.url_redirect) + + def test_post(self): + """Test POST""" + self.assertEqual(SODARUserAdditionalEmail.objects.count(), 0) + self.assertEqual( + TimelineEvent.objects.filter(event_name='email_create').count(), 0 + ) + self.assertEqual(len(mail.outbox), 0) + with self.login(self.user): + data = { + 'user': self.user.pk, + 'email': ADD_EMAIL, + 'secret': ADD_EMAIL_SECRET, + } + response = self.client.post(self.url, data) + self.assertRedirects(response, self.url_redirect) + self.assertEqual(SODARUserAdditionalEmail.objects.count(), 1) + email = SODARUserAdditionalEmail.objects.first() + expected = { + 'id': email.pk, + 'user': self.user.pk, + 'email': ADD_EMAIL, + 'secret': ADD_EMAIL_SECRET, + 'verified': False, + 'sodar_uuid': email.sodar_uuid, + } + self.assertEqual(model_to_dict(email), expected) + self.assertEqual( + TimelineEvent.objects.filter(event_name='email_create').count(), 1 + ) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].recipients(), [ADD_EMAIL]) + verify_url = reverse( + 'userprofile:email_verify', kwargs={'secret': email.secret} + ) + self.assertIn(verify_url, mail.outbox[0].body) + + def test_post_existing_primary(self): + """Test POST with email used as primary email for user""" + self.assertEqual(SODARUserAdditionalEmail.objects.count(), 0) + self.assertEqual( + TimelineEvent.objects.filter(event_name='email_create').count(), 0 + ) + self.assertEqual(len(mail.outbox), 0) + with self.login(self.user): + data = { + 'user': self.user.pk, + 'email': self.user.email, + 'secret': ADD_EMAIL_SECRET, + } + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 200) + self.assertEqual(SODARUserAdditionalEmail.objects.count(), 0) + self.assertEqual( + TimelineEvent.objects.filter(event_name='email_create').count(), 0 + ) + self.assertEqual(len(mail.outbox), 0) + + def test_post_existing_additional(self): + """Test POST with email used as additional email for user""" + self.make_email(self.user, ADD_EMAIL) + with self.login(self.user): + data = { + 'user': self.user.pk, + 'email': ADD_EMAIL, + 'secret': ADD_EMAIL_SECRET, + } + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 200) + + def test_post_multiple(self): + """Test POST with different existing additional email""" + self.make_email(self.user, ADD_EMAIL) + self.assertEqual(SODARUserAdditionalEmail.objects.count(), 1) + self.assertEqual(len(mail.outbox), 0) + with self.login(self.user): + data = { + 'user': self.user.pk, + 'email': ADD_EMAIL2, + 'secret': ADD_EMAIL_SECRET, + } + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(SODARUserAdditionalEmail.objects.count(), 2) + self.assertEqual(len(mail.outbox), 1) + + +class TestUserEmailVerifyView(SODARUserAdditionalEmailMixin, UserViewTestBase): + """Tests for UserEmailVerifyView""" + + def setUp(self): + super().setUp() + self.url_redirect = reverse('userprofile:detail') + self.email = self.make_email(self.user, ADD_EMAIL, verified=False) + self.url = reverse( + 'userprofile:email_verify', kwargs={'secret': self.email.secret} + ) + + def test_get(self): + """Test UserEmailVerifyView GET""" + self.assertEqual(self.email.verified, False) + with self.login(self.user): + response = self.client.get(self.url) + self.assertRedirects(response, self.url_redirect) + self.email.refresh_from_db() + self.assertEqual(self.email.verified, True) + self.assertEqual( + list(get_messages(response.wsgi_request))[0].message, + EMAIL_VERIFIED_MSG.format(email=self.email.email), + ) + + def test_get_invalid_secret(self): + """Test GET with invalid secret""" + self.assertEqual(self.email.verified, False) + with self.login(self.user): + response = self.client.get( + reverse( + 'userprofile:email_verify', + kwargs={'secret': build_secret(32)}, + ) + ) + self.assertEqual(response.status_code, 302) + self.email.refresh_from_db() + self.assertEqual(self.email.verified, False) + self.assertEqual( + list(get_messages(response.wsgi_request))[0].message, + EMAIL_NOT_FOUND_MSG, + ) + + def test_get_wrong_user(self): + """Test GET with wrong user""" + user_new = self.make_user('user_new') + self.assertEqual(self.email.verified, False) + with self.login(user_new): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 302) + self.email.refresh_from_db() + self.assertEqual(self.email.verified, False) + self.assertEqual( + list(get_messages(response.wsgi_request))[0].message, + EMAIL_NOT_FOUND_MSG, + ) + + def test_get_verified(self): + """Test GET with verified email""" + self.email.verified = True + self.email.save() + with self.login(self.user): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 302) + self.email.refresh_from_db() + self.assertEqual(self.email.verified, True) + self.assertEqual( + list(get_messages(response.wsgi_request))[0].message, + EMAIL_ALREADY_VERIFIED_MSG, + ) + + +class TestUserEmailVerifyResendView( + SODARUserAdditionalEmailMixin, UserViewTestBase +): + """Tests for UserEmailVerifyResendView""" + + def setUp(self): + super().setUp() + self.url_redirect = reverse('userprofile:detail') + self.email = self.make_email(self.user, ADD_EMAIL, verified=False) + self.url = reverse( + 'userprofile:email_verify_resend', + kwargs={'sodaruseradditionalemail': self.email.sodar_uuid}, + ) + + def test_get(self): + """Test UserEmailVerifyResendView GET""" + self.assertEqual(len(mail.outbox), 0) + with self.login(self.user): + response = self.client.get(self.url) + self.assertRedirects(response, self.url_redirect) + self.email.refresh_from_db() + self.assertEqual( + list(get_messages(response.wsgi_request))[0].message, + EMAIL_VERIFY_RESEND_MSG.format(email=self.email.email), + ) + self.assertEqual(len(mail.outbox), 1) + + def test_get_invalid_uuid(self): + """Test GET with invalid UUID""" + self.assertEqual(self.email.verified, False) + with self.login(self.user): + response = self.client.get( + reverse( + 'userprofile:email_verify_resend', + kwargs={'sodaruseradditionalemail': uuid.uuid4()}, + ) + ) + self.assertEqual(response.status_code, 302) + self.email.refresh_from_db() + self.assertEqual(self.email.verified, False) + self.assertEqual( + list(get_messages(response.wsgi_request))[0].message, + EMAIL_NOT_FOUND_MSG, + ) + + def test_get_wrong_user(self): + """Test GET with wrong user""" + user_new = self.make_user('user_new') + self.assertEqual(self.email.verified, False) + with self.login(user_new): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 302) + self.email.refresh_from_db() + self.assertEqual(self.email.verified, False) + self.assertEqual( + list(get_messages(response.wsgi_request))[0].message, + EMAIL_NOT_FOUND_MSG, + ) + + def test_get_verified(self): + """Test GET with verified email""" + self.email.verified = True + self.email.save() + with self.login(self.user): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 302) + self.email.refresh_from_db() + self.assertEqual(self.email.verified, True) + self.assertEqual( + list(get_messages(response.wsgi_request))[0].message, + EMAIL_ALREADY_VERIFIED_MSG, + ) + + +class TestUserEmailDeleteView(SODARUserAdditionalEmailMixin, UserViewTestBase): + """Tests for UserEmailDeleteView""" + + def setUp(self): + super().setUp() + self.url_redirect = reverse('userprofile:detail') + self.email = self.make_email(self.user, ADD_EMAIL, verified=False) + self.url = reverse( + 'userprofile:email_delete', + kwargs={'sodaruseradditionalemail': self.email.sodar_uuid}, + ) + + def test_get(self): + """Test UserEmailDeleteView GET""" + with self.login(self.user): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['object'], self.email) + + def test_post(self): + """Test POST""" + self.assertEqual(SODARUserAdditionalEmail.objects.count(), 1) + with self.login(self.user): + response = self.client.post(self.url) + self.assertRedirects(response, self.url_redirect) + self.assertEqual(SODARUserAdditionalEmail.objects.count(), 0) diff --git a/userprofile/urls.py b/userprofile/urls.py index a7bd9492..8229e7ee 100644 --- a/userprofile/urls.py +++ b/userprofile/urls.py @@ -11,8 +11,28 @@ name='detail', ), path( - route='profile/settings/update', + route='settings/update', view=views.UserSettingsView.as_view(), name='settings_update', ), + path( + route='email/create', + view=views.UserEmailCreateView.as_view(), + name='email_create', + ), + path( + route='email/verify/', + view=views.UserEmailVerifyView.as_view(), + name='email_verify', + ), + path( + route='email/resend/', + view=views.UserEmailVerifyResendView.as_view(), + name='email_verify_resend', + ), + path( + route='email/delete/', + view=views.UserEmailDeleteView.as_view(), + name='email_delete', + ), ] diff --git a/userprofile/views.py b/userprofile/views.py index 5dfbea1e..3f95688f 100644 --- a/userprofile/views.py +++ b/userprofile/views.py @@ -1,21 +1,31 @@ """UI views for the userprofile app""" +from django.conf import settings from django.contrib import auth, messages from django.contrib.auth.mixins import LoginRequiredMixin -from django.urls import reverse_lazy -from django.views.generic import TemplateView, FormView +from django.shortcuts import redirect +from django.urls import reverse, reverse_lazy +from django.views.generic import ( + TemplateView, + FormView, + CreateView, + DeleteView, + View, +) # Projectroles dependency from projectroles.app_settings import AppSettingAPI -from projectroles.models import SODAR_CONSTANTS -from projectroles.plugins import get_active_plugins +from projectroles.email import send_generic_mail, get_email_user +from projectroles.models import SODARUserAdditionalEmail, SODAR_CONSTANTS +from projectroles.plugins import get_active_plugins, get_backend_api from projectroles.views import ( LoggedInPermissionMixin, HTTPRefererMixin, InvalidFormMixin, + CurrentUserFormMixin, ) -from userprofile.forms import UserSettingsForm +from userprofile.forms import UserSettingsForm, UserEmailForm User = auth.get_user_model() @@ -27,7 +37,25 @@ APP_SETTING_SCOPE_USER = SODAR_CONSTANTS['APP_SETTING_SCOPE_USER'] # Local Constants +APP_NAME = 'userprofile' SETTING_UPDATE_MSG = 'User settings updated.' +VERIFY_EMAIL_SUBJECT = 'Verify your additional email address for {site}' +VERIFY_EMAIL_BODY = r''' +{user} has added this address to their +additional emails on {site}. + +Once verified, the site may use this email to send automated email for +notifications as configured in user settings. + +Please verify this email address by following this URL: +{url} + +If this was not requested by you, this message can be ignored. +'''.lstrip() +EMAIL_NOT_FOUND_MSG = 'No email found.' +EMAIL_ALREADY_VERIFIED_MSG = 'Email already verified.' +EMAIL_VERIFIED_MSG = 'Email "{email}" verified.' +EMAIL_VERIFY_RESEND_MSG = 'Verification message to "{email}" resent.' class UserDetailView(LoginRequiredMixin, LoggedInPermissionMixin, TemplateView): @@ -49,7 +77,9 @@ def _get_user_settings(self): else: name = 'projectroles' p_settings = app_settings.get_definitions( - APP_SETTING_SCOPE_USER, app_name=name, user_modifiable=True + APP_SETTING_SCOPE_USER, + plugin_name=name, + user_modifiable=True, ) for k, v in p_settings.items(): yield { @@ -61,7 +91,9 @@ def _get_user_settings(self): def get_context_data(self, **kwargs): result = super().get_context_data(**kwargs) result['user_settings'] = list(self._get_user_settings()) - result['local_user'] = self.request.user.is_local() + result['add_emails'] = SODARUserAdditionalEmail.objects.filter( + user=self.request.user + ).order_by('email') return result @@ -88,10 +120,152 @@ def form_valid(self, form): result = super().form_valid(form) for k, v in form.cleaned_data.items(): if k.startswith('settings.'): - _, app_name, setting_name = k.split('.', 3) + _, plugin_name, setting_name = k.split('.', 3) # TODO: Omit global USER settings (#1329) app_settings.set( - app_name, setting_name, v, user=self.request.user + plugin_name, setting_name, v, user=self.request.user ) messages.success(self.request, SETTING_UPDATE_MSG) return result + + +class UserEmailMixin: + """Mixin for user email helpers""" + + def send_verify_email(self, email, resend=False): + """ + Send verification message to additional email address. + + :param email: SODARUserAdditionalEmail object + """ + subject = VERIFY_EMAIL_SUBJECT.format(site=settings.SITE_INSTANCE_TITLE) + body = VERIFY_EMAIL_BODY.format( + user=get_email_user(email.user), + site=settings.SITE_INSTANCE_TITLE, + url=self.request.build_absolute_uri( + reverse( + 'userprofile:email_verify', kwargs={'secret': email.secret} + ) + ), + ) + try: + send_generic_mail(subject, body, [email.email], self.request) + if resend: + messages.success( + self.request, + EMAIL_VERIFY_RESEND_MSG.format(email=email.email), + ) + else: + messages.success( + self.request, + 'Email added. A verification message has been sent to the ' + 'address. Follow the received verification link to ' + 'activate the address.', + ) + except Exception as ex: + messages.error( + self.request, 'Failed to send verification mail: {}'.format(ex) + ) + + +class UserEmailCreateView( + LoginRequiredMixin, + LoggedInPermissionMixin, + CurrentUserFormMixin, + UserEmailMixin, + CreateView, +): + """User additional email creation view""" + + form_class = UserEmailForm + permission_required = 'userprofile.create_email' + template_name = 'userprofile/email_form.html' + + def get_success_url(self): + timeline = get_backend_api('timeline_backend') + self.send_verify_email(self.object) + if timeline: + timeline.add_event( + project=None, + app_name=APP_NAME, + user=self.request.user, + event_name='email_create', + description='create additional email "{}"'.format( + self.object.email + ), + classified=True, + status_type=timeline.TL_STATUS_OK, + ) + return reverse('userprofile:detail') + + def get(self, *args, **kwargs): + if not settings.PROJECTROLES_SEND_EMAIL: + messages.warning( + self.request, + 'Email sending disabled, adding email addresses is not ' + 'allowed.', + ) + return redirect(reverse('userprofile:detail')) + return super().get(*args, **kwargs) + + +class UserEmailVerifyView(LoginRequiredMixin, LoggedInPermissionMixin, View): + """View for verifying a created additional email address""" + + http_method_names = ['get'] + permission_required = 'userprofile.create_email' + + def get(self, request, *args, **kwargs): + secret = self.kwargs.get('secret') + email = SODARUserAdditionalEmail.objects.filter( + user=request.user, secret=secret + ).first() + if not email: + messages.error(request, EMAIL_NOT_FOUND_MSG) + elif email.verified: + messages.info(request, EMAIL_ALREADY_VERIFIED_MSG) + else: + email.verified = True + email.save() + messages.success( + request, EMAIL_VERIFIED_MSG.format(email=email.email) + ) + return redirect(reverse('userprofile:detail')) + + +class UserEmailVerifyResendView( + LoginRequiredMixin, LoggedInPermissionMixin, UserEmailMixin, View +): + """View for resending additional email verification message""" + + http_method_names = ['get'] + permission_required = 'userprofile.create_email' + + def get(self, request, *args, **kwargs): + email_uuid = self.kwargs.get('sodaruseradditionalemail') + email = SODARUserAdditionalEmail.objects.filter( + user=request.user, sodar_uuid=email_uuid + ).first() + if not email: + messages.error(request, EMAIL_NOT_FOUND_MSG) + elif email.verified: + messages.info(request, EMAIL_ALREADY_VERIFIED_MSG) + else: + self.send_verify_email(email, resend=True) + return redirect(reverse('userprofile:detail')) + + +class UserEmailDeleteView( + LoginRequiredMixin, LoggedInPermissionMixin, DeleteView +): + """View for deleting additional email""" + + model = SODARUserAdditionalEmail + permission_required = 'userprofile.delete_email' + slug_url_kwarg = 'sodaruseradditionalemail' + slug_field = 'sodar_uuid' + template_name = 'userprofile/email_confirm_delete.html' + + def get_success_url(self): + messages.success(self.request, 'Email address deleted.') + return reverse('userprofile:detail') diff --git a/utility/get_chromedriver_url.py b/utility/get_chromedriver_url.py index deb7fa1a..a27c550e 100644 --- a/utility/get_chromedriver_url.py +++ b/utility/get_chromedriver_url.py @@ -17,7 +17,7 @@ 'last-known-good-versions.json' ) DL_URL = ( - 'https://edgedl.me.gvt1.com/edgedl/chrome/chrome-for-testing/' + 'https://storage.googleapis.com/chrome-for-testing-public/' '{driver_version}/{platform}/chromedriver-linux64.zip' ) PLATFORM = 'linux64' diff --git a/utility/install_os_gitlab.sh b/utility/install_os_gitlab.sh index 8f0e0465..8f1e9f69 100755 --- a/utility/install_os_gitlab.sh +++ b/utility/install_os_gitlab.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Install OS dependencies for Docker image python:3.8 used in Gitlab CI +# Install OS dependencies for Docker image python used in Gitlab CI echo "***********************************************" echo "Apt-get update" diff --git a/utility/install_postgres.sh b/utility/install_postgres.sh index db10302c..209c792a 100755 --- a/utility/install_postgres.sh +++ b/utility/install_postgres.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash echo "***********************************************" -echo "Installing PostgreSQL v11" +echo "Installing PostgreSQL v16" echo "***********************************************" add-apt-repository -y "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - apt-get -y update -apt-get -y install postgresql-11 +apt-get -y install postgresql-16 diff --git a/utility/install_python.sh b/utility/install_python.sh index d74cf554..2cab6db3 100755 --- a/utility/install_python.sh +++ b/utility/install_python.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash echo "***********************************************" -echo "Installing Python 3.8" +echo "Installing Python 3.11" echo "***********************************************" add-apt-repository -y ppa:deadsnakes/ppa apt-get -y update -apt-get -y install python3.8 python3.8-dev python3.8-venv -curl https://bootstrap.pypa.io/get-pip.py | sudo -H python3.8 +apt-get -y install python3.11 python3.11-dev python3.11-venv +curl https://bootstrap.pypa.io/get-pip.py | sudo -H python3.11 diff --git a/utility/install_python_dependencies.sh b/utility/install_python_dependencies.sh index a8f94497..7e39d83c 100755 --- a/utility/install_python_dependencies.sh +++ b/utility/install_python_dependencies.sh @@ -22,7 +22,7 @@ if [ -z "$VIRTUAL_ENV" ]; then echo >&2 -e "\n" exit 1; else - pip install "wheel>=0.40.0, <0.41" + pip install "wheel>=0.42.0, <0.43" pip install -r $PROJECT_DIR/requirements/local.txt pip install -r $PROJECT_DIR/requirements/test.txt pip install -r $PROJECT_DIR/requirements.txt diff --git a/versioneer.py b/versioneer.py index 18e34c2f..1e3753e6 100644 --- a/versioneer.py +++ b/versioneer.py @@ -1,5 +1,5 @@ -# Version: 0.28 +# Version: 0.29 """The Versioneer - like a rocketeer, but for versions. @@ -10,7 +10,7 @@ * https://github.com/python-versioneer/python-versioneer * Brian Warner * License: Public Domain (Unlicense) -* Compatible with: Python 3.7, 3.8, 3.9, 3.10 and pypy3 +* Compatible with: Python 3.7, 3.8, 3.9, 3.10, 3.11 and pypy3 * [![Latest Version][pypi-image]][pypi-url] * [![Build Status][travis-image]][travis-url] @@ -316,7 +316,8 @@ import subprocess import sys from pathlib import Path -from typing import Callable, Dict +from typing import Any, Callable, cast, Dict, List, Optional, Tuple, Union +from typing import NoReturn import functools have_tomllib = True @@ -332,8 +333,16 @@ class VersioneerConfig: """Container for Versioneer configuration parameters.""" + VCS: str + style: str + tag_prefix: str + versionfile_source: str + versionfile_build: Optional[str] + parentdir_prefix: Optional[str] + verbose: Optional[bool] -def get_root(): + +def get_root() -> str: """Get the project root directory. We require that all commands are run from the project root, i.e. the @@ -341,13 +350,23 @@ def get_root(): """ root = os.path.realpath(os.path.abspath(os.getcwd())) setup_py = os.path.join(root, "setup.py") + pyproject_toml = os.path.join(root, "pyproject.toml") versioneer_py = os.path.join(root, "versioneer.py") - if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)): + if not ( + os.path.exists(setup_py) + or os.path.exists(pyproject_toml) + or os.path.exists(versioneer_py) + ): # allow 'python path/to/setup.py COMMAND' root = os.path.dirname(os.path.realpath(os.path.abspath(sys.argv[0]))) setup_py = os.path.join(root, "setup.py") + pyproject_toml = os.path.join(root, "pyproject.toml") versioneer_py = os.path.join(root, "versioneer.py") - if not (os.path.exists(setup_py) or os.path.exists(versioneer_py)): + if not ( + os.path.exists(setup_py) + or os.path.exists(pyproject_toml) + or os.path.exists(versioneer_py) + ): err = ("Versioneer was unable to run the project root directory. " "Versioneer requires setup.py to be executed from " "its immediate directory (like 'python setup.py COMMAND'), " @@ -372,23 +391,24 @@ def get_root(): return root -def get_config_from_root(root): +def get_config_from_root(root: str) -> VersioneerConfig: """Read the project setup.cfg file to determine Versioneer config.""" # This might raise OSError (if setup.cfg is missing), or # configparser.NoSectionError (if it lacks a [versioneer] section), or # configparser.NoOptionError (if it lacks "VCS="). See the docstring at # the top of versioneer.py for instructions on writing your setup.cfg . - root = Path(root) - pyproject_toml = root / "pyproject.toml" - setup_cfg = root / "setup.cfg" - section = None + root_pth = Path(root) + pyproject_toml = root_pth / "pyproject.toml" + setup_cfg = root_pth / "setup.cfg" + section: Union[Dict[str, Any], configparser.SectionProxy, None] = None if pyproject_toml.exists() and have_tomllib: try: with open(pyproject_toml, 'rb') as fobj: pp = tomllib.load(fobj) section = pp['tool']['versioneer'] - except (tomllib.TOMLDecodeError, KeyError): - pass + except (tomllib.TOMLDecodeError, KeyError) as e: + print(f"Failed to load config from {pyproject_toml}: {e}") + print("Try to load it from setup.cfg") if not section: parser = configparser.ConfigParser() with open(setup_cfg) as cfg_file: @@ -397,16 +417,25 @@ def get_config_from_root(root): section = parser["versioneer"] + # `cast`` really shouldn't be used, but its simplest for the + # common VersioneerConfig users at the moment. We verify against + # `None` values elsewhere where it matters + cfg = VersioneerConfig() cfg.VCS = section['VCS'] cfg.style = section.get("style", "") - cfg.versionfile_source = section.get("versionfile_source") + cfg.versionfile_source = cast(str, section.get("versionfile_source")) cfg.versionfile_build = section.get("versionfile_build") - cfg.tag_prefix = section.get("tag_prefix") + cfg.tag_prefix = cast(str, section.get("tag_prefix")) if cfg.tag_prefix in ("''", '""', None): cfg.tag_prefix = "" cfg.parentdir_prefix = section.get("parentdir_prefix") - cfg.verbose = section.get("verbose") + if isinstance(section, configparser.SectionProxy): + # Make sure configparser translates to bool + cfg.verbose = section.getboolean("verbose") + else: + cfg.verbose = section.get("verbose") + return cfg @@ -419,22 +448,28 @@ class NotThisMethod(Exception): HANDLERS: Dict[str, Dict[str, Callable]] = {} -def register_vcs_handler(vcs, method): # decorator +def register_vcs_handler(vcs: str, method: str) -> Callable: # decorator """Create decorator to mark a method as the handler of a VCS.""" - def decorate(f): + def decorate(f: Callable) -> Callable: """Store f in HANDLERS[vcs][method].""" HANDLERS.setdefault(vcs, {})[method] = f return f return decorate -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, - env=None): +def run_command( + commands: List[str], + args: List[str], + cwd: Optional[str] = None, + verbose: bool = False, + hide_stderr: bool = False, + env: Optional[Dict[str, str]] = None, +) -> Tuple[Optional[str], Optional[int]]: """Call the given command(s).""" assert isinstance(commands, list) process = None - popen_kwargs = {} + popen_kwargs: Dict[str, Any] = {} if sys.platform == "win32": # This hides the console window if pythonw.exe is used startupinfo = subprocess.STARTUPINFO() @@ -450,8 +485,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, stderr=(subprocess.PIPE if hide_stderr else None), **popen_kwargs) break - except OSError: - e = sys.exc_info()[1] + except OSError as e: if e.errno == errno.ENOENT: continue if verbose: @@ -479,7 +513,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, # that just contains the computed version number. # This file is released into the public domain. -# Generated by versioneer-0.28 +# Generated by versioneer-0.29 # https://github.com/python-versioneer/python-versioneer """Git implementation of _version.py.""" @@ -489,11 +523,11 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, import re import subprocess import sys -from typing import Callable, Dict +from typing import Any, Callable, Dict, List, Optional, Tuple import functools -def get_keywords(): +def get_keywords() -> Dict[str, str]: """Get the keywords needed to look up the version information.""" # these strings will be replaced by git during git-archive. # setup.py/versioneer.py will grep for the variable names, so they must @@ -509,8 +543,15 @@ def get_keywords(): class VersioneerConfig: """Container for Versioneer configuration parameters.""" + VCS: str + style: str + tag_prefix: str + parentdir_prefix: str + versionfile_source: str + verbose: bool + -def get_config(): +def get_config() -> VersioneerConfig: """Create, populate and return the VersioneerConfig() object.""" # these strings are filled in when 'setup.py versioneer' creates # _version.py @@ -532,9 +573,9 @@ class NotThisMethod(Exception): HANDLERS: Dict[str, Dict[str, Callable]] = {} -def register_vcs_handler(vcs, method): # decorator +def register_vcs_handler(vcs: str, method: str) -> Callable: # decorator """Create decorator to mark a method as the handler of a VCS.""" - def decorate(f): + def decorate(f: Callable) -> Callable: """Store f in HANDLERS[vcs][method].""" if vcs not in HANDLERS: HANDLERS[vcs] = {} @@ -543,13 +584,19 @@ def decorate(f): return decorate -def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, - env=None): +def run_command( + commands: List[str], + args: List[str], + cwd: Optional[str] = None, + verbose: bool = False, + hide_stderr: bool = False, + env: Optional[Dict[str, str]] = None, +) -> Tuple[Optional[str], Optional[int]]: """Call the given command(s).""" assert isinstance(commands, list) process = None - popen_kwargs = {} + popen_kwargs: Dict[str, Any] = {} if sys.platform == "win32": # This hides the console window if pythonw.exe is used startupinfo = subprocess.STARTUPINFO() @@ -565,8 +612,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, stderr=(subprocess.PIPE if hide_stderr else None), **popen_kwargs) break - except OSError: - e = sys.exc_info()[1] + except OSError as e: if e.errno == errno.ENOENT: continue if verbose: @@ -586,7 +632,11 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, return stdout, process.returncode -def versions_from_parentdir(parentdir_prefix, root, verbose): +def versions_from_parentdir( + parentdir_prefix: str, + root: str, + verbose: bool, +) -> Dict[str, Any]: """Try to determine the version from the parent directory name. Source tarballs conventionally unpack into a directory that includes both @@ -611,13 +661,13 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): @register_vcs_handler("git", "get_keywords") -def git_get_keywords(versionfile_abs): +def git_get_keywords(versionfile_abs: str) -> Dict[str, str]: """Extract version information from the given file.""" # the code embedded in _version.py can just fetch the value of these # keywords. When used from setup.py, we don't want to import _version.py, # so we do it with a regexp instead. This function is not used from # _version.py. - keywords = {} + keywords: Dict[str, str] = {} try: with open(versionfile_abs, "r") as fobj: for line in fobj: @@ -639,7 +689,11 @@ def git_get_keywords(versionfile_abs): @register_vcs_handler("git", "keywords") -def git_versions_from_keywords(keywords, tag_prefix, verbose): +def git_versions_from_keywords( + keywords: Dict[str, str], + tag_prefix: str, + verbose: bool, +) -> Dict[str, Any]: """Get version information from git keywords.""" if "refnames" not in keywords: raise NotThisMethod("Short version file found") @@ -703,7 +757,12 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): @register_vcs_handler("git", "pieces_from_vcs") -def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): +def git_pieces_from_vcs( + tag_prefix: str, + root: str, + verbose: bool, + runner: Callable = run_command +) -> Dict[str, Any]: """Get version from 'git describe' in the root of the source tree. This only gets called if the git-archive 'subst' keywords were *not* @@ -743,7 +802,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): raise NotThisMethod("'git rev-parse' failed") full_out = full_out.strip() - pieces = {} + pieces: Dict[str, Any] = {} pieces["long"] = full_out pieces["short"] = full_out[:7] # maybe improved later pieces["error"] = None @@ -835,14 +894,14 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): return pieces -def plus_or_dot(pieces): +def plus_or_dot(pieces: Dict[str, Any]) -> str: """Return a + if we don't already have one, else return a .""" if "+" in pieces.get("closest-tag", ""): return "." return "+" -def render_pep440(pieces): +def render_pep440(pieces: Dict[str, Any]) -> str: """Build up version string, with post-release "local version identifier". Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you @@ -867,7 +926,7 @@ def render_pep440(pieces): return rendered -def render_pep440_branch(pieces): +def render_pep440_branch(pieces: Dict[str, Any]) -> str: """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] . The ".dev0" means not master branch. Note that .dev0 sorts backwards @@ -897,7 +956,7 @@ def render_pep440_branch(pieces): return rendered -def pep440_split_post(ver): +def pep440_split_post(ver: str) -> Tuple[str, Optional[int]]: """Split pep440 version string at the post-release segment. Returns the release segments before the post-release and the @@ -907,7 +966,7 @@ def pep440_split_post(ver): return vc[0], int(vc[1] or 0) if len(vc) == 2 else None -def render_pep440_pre(pieces): +def render_pep440_pre(pieces: Dict[str, Any]) -> str: """TAG[.postN.devDISTANCE] -- No -dirty. Exceptions: @@ -931,7 +990,7 @@ def render_pep440_pre(pieces): return rendered -def render_pep440_post(pieces): +def render_pep440_post(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]+gHEX] . The ".dev0" means dirty. Note that .dev0 sorts backwards @@ -958,7 +1017,7 @@ def render_pep440_post(pieces): return rendered -def render_pep440_post_branch(pieces): +def render_pep440_post_branch(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] . The ".dev0" means not master branch. @@ -987,7 +1046,7 @@ def render_pep440_post_branch(pieces): return rendered -def render_pep440_old(pieces): +def render_pep440_old(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]] . The ".dev0" means dirty. @@ -1009,7 +1068,7 @@ def render_pep440_old(pieces): return rendered -def render_git_describe(pieces): +def render_git_describe(pieces: Dict[str, Any]) -> str: """TAG[-DISTANCE-gHEX][-dirty]. Like 'git describe --tags --dirty --always'. @@ -1029,7 +1088,7 @@ def render_git_describe(pieces): return rendered -def render_git_describe_long(pieces): +def render_git_describe_long(pieces: Dict[str, Any]) -> str: """TAG-DISTANCE-gHEX[-dirty]. Like 'git describe --tags --dirty --always -long'. @@ -1049,7 +1108,7 @@ def render_git_describe_long(pieces): return rendered -def render(pieces, style): +def render(pieces: Dict[str, Any], style: str) -> Dict[str, Any]: """Render the given version pieces into the requested style.""" if pieces["error"]: return {"version": "unknown", @@ -1085,7 +1144,7 @@ def render(pieces, style): "date": pieces.get("date")} -def get_versions(): +def get_versions() -> Dict[str, Any]: """Get version information or return default if unable to do so.""" # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have # __file__, we can work backwards from there to the root. Some @@ -1133,13 +1192,13 @@ def get_versions(): @register_vcs_handler("git", "get_keywords") -def git_get_keywords(versionfile_abs): +def git_get_keywords(versionfile_abs: str) -> Dict[str, str]: """Extract version information from the given file.""" # the code embedded in _version.py can just fetch the value of these # keywords. When used from setup.py, we don't want to import _version.py, # so we do it with a regexp instead. This function is not used from # _version.py. - keywords = {} + keywords: Dict[str, str] = {} try: with open(versionfile_abs, "r") as fobj: for line in fobj: @@ -1161,7 +1220,11 @@ def git_get_keywords(versionfile_abs): @register_vcs_handler("git", "keywords") -def git_versions_from_keywords(keywords, tag_prefix, verbose): +def git_versions_from_keywords( + keywords: Dict[str, str], + tag_prefix: str, + verbose: bool, +) -> Dict[str, Any]: """Get version information from git keywords.""" if "refnames" not in keywords: raise NotThisMethod("Short version file found") @@ -1225,7 +1288,12 @@ def git_versions_from_keywords(keywords, tag_prefix, verbose): @register_vcs_handler("git", "pieces_from_vcs") -def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): +def git_pieces_from_vcs( + tag_prefix: str, + root: str, + verbose: bool, + runner: Callable = run_command +) -> Dict[str, Any]: """Get version from 'git describe' in the root of the source tree. This only gets called if the git-archive 'subst' keywords were *not* @@ -1265,7 +1333,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): raise NotThisMethod("'git rev-parse' failed") full_out = full_out.strip() - pieces = {} + pieces: Dict[str, Any] = {} pieces["long"] = full_out pieces["short"] = full_out[:7] # maybe improved later pieces["error"] = None @@ -1357,7 +1425,7 @@ def git_pieces_from_vcs(tag_prefix, root, verbose, runner=run_command): return pieces -def do_vcs_install(versionfile_source, ipy): +def do_vcs_install(versionfile_source: str, ipy: Optional[str]) -> None: """Git-specific installation logic for Versioneer. For Git, this means creating/changing .gitattributes to mark _version.py @@ -1395,7 +1463,11 @@ def do_vcs_install(versionfile_source, ipy): run_command(GITS, ["add", "--"] + files) -def versions_from_parentdir(parentdir_prefix, root, verbose): +def versions_from_parentdir( + parentdir_prefix: str, + root: str, + verbose: bool, +) -> Dict[str, Any]: """Try to determine the version from the parent directory name. Source tarballs conventionally unpack into a directory that includes both @@ -1420,7 +1492,7 @@ def versions_from_parentdir(parentdir_prefix, root, verbose): SHORT_VERSION_PY = """ -# This file was generated by 'versioneer.py' (0.28) from +# This file was generated by 'versioneer.py' (0.29) from # revision-control system data, or from the parent directory name of an # unpacked source archive. Distribution tarballs contain a pre-generated copy # of this file. @@ -1437,7 +1509,7 @@ def get_versions(): """ -def versions_from_file(filename): +def versions_from_file(filename: str) -> Dict[str, Any]: """Try to determine the version from _version.py if present.""" try: with open(filename) as f: @@ -1454,9 +1526,8 @@ def versions_from_file(filename): return json.loads(mo.group(1)) -def write_to_version_file(filename, versions): +def write_to_version_file(filename: str, versions: Dict[str, Any]) -> None: """Write the given version number to the given _version.py file.""" - os.unlink(filename) contents = json.dumps(versions, sort_keys=True, indent=1, separators=(",", ": ")) with open(filename, "w") as f: @@ -1465,14 +1536,14 @@ def write_to_version_file(filename, versions): print("set %s to '%s'" % (filename, versions["version"])) -def plus_or_dot(pieces): +def plus_or_dot(pieces: Dict[str, Any]) -> str: """Return a + if we don't already have one, else return a .""" if "+" in pieces.get("closest-tag", ""): return "." return "+" -def render_pep440(pieces): +def render_pep440(pieces: Dict[str, Any]) -> str: """Build up version string, with post-release "local version identifier". Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you @@ -1497,7 +1568,7 @@ def render_pep440(pieces): return rendered -def render_pep440_branch(pieces): +def render_pep440_branch(pieces: Dict[str, Any]) -> str: """TAG[[.dev0]+DISTANCE.gHEX[.dirty]] . The ".dev0" means not master branch. Note that .dev0 sorts backwards @@ -1527,7 +1598,7 @@ def render_pep440_branch(pieces): return rendered -def pep440_split_post(ver): +def pep440_split_post(ver: str) -> Tuple[str, Optional[int]]: """Split pep440 version string at the post-release segment. Returns the release segments before the post-release and the @@ -1537,7 +1608,7 @@ def pep440_split_post(ver): return vc[0], int(vc[1] or 0) if len(vc) == 2 else None -def render_pep440_pre(pieces): +def render_pep440_pre(pieces: Dict[str, Any]) -> str: """TAG[.postN.devDISTANCE] -- No -dirty. Exceptions: @@ -1561,7 +1632,7 @@ def render_pep440_pre(pieces): return rendered -def render_pep440_post(pieces): +def render_pep440_post(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]+gHEX] . The ".dev0" means dirty. Note that .dev0 sorts backwards @@ -1588,7 +1659,7 @@ def render_pep440_post(pieces): return rendered -def render_pep440_post_branch(pieces): +def render_pep440_post_branch(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]+gHEX[.dirty]] . The ".dev0" means not master branch. @@ -1617,7 +1688,7 @@ def render_pep440_post_branch(pieces): return rendered -def render_pep440_old(pieces): +def render_pep440_old(pieces: Dict[str, Any]) -> str: """TAG[.postDISTANCE[.dev0]] . The ".dev0" means dirty. @@ -1639,7 +1710,7 @@ def render_pep440_old(pieces): return rendered -def render_git_describe(pieces): +def render_git_describe(pieces: Dict[str, Any]) -> str: """TAG[-DISTANCE-gHEX][-dirty]. Like 'git describe --tags --dirty --always'. @@ -1659,7 +1730,7 @@ def render_git_describe(pieces): return rendered -def render_git_describe_long(pieces): +def render_git_describe_long(pieces: Dict[str, Any]) -> str: """TAG-DISTANCE-gHEX[-dirty]. Like 'git describe --tags --dirty --always -long'. @@ -1679,7 +1750,7 @@ def render_git_describe_long(pieces): return rendered -def render(pieces, style): +def render(pieces: Dict[str, Any], style: str) -> Dict[str, Any]: """Render the given version pieces into the requested style.""" if pieces["error"]: return {"version": "unknown", @@ -1719,7 +1790,7 @@ class VersioneerBadRootError(Exception): """The project root directory is unknown or missing key files.""" -def get_versions(verbose=False): +def get_versions(verbose: bool = False) -> Dict[str, Any]: """Get the project version from whatever source is available. Returns dict with two keys: 'version' and 'full'. @@ -1734,7 +1805,7 @@ def get_versions(verbose=False): assert cfg.VCS is not None, "please set [versioneer]VCS= in setup.cfg" handlers = HANDLERS.get(cfg.VCS) assert handlers, "unrecognized VCS '%s'" % cfg.VCS - verbose = verbose or cfg.verbose + verbose = verbose or bool(cfg.verbose) # `bool()` used to avoid `None` assert cfg.versionfile_source is not None, \ "please set versioneer.versionfile_source" assert cfg.tag_prefix is not None, "please set versioneer.tag_prefix" @@ -1795,12 +1866,12 @@ def get_versions(verbose=False): "date": None} -def get_version(): +def get_version() -> str: """Get the short version string for this project.""" return get_versions()["version"] -def get_cmdclass(cmdclass=None): +def get_cmdclass(cmdclass: Optional[Dict[str, Any]] = None): """Get the custom setuptools subclasses used by Versioneer. If the package uses a different cmdclass (e.g. one from numpy), it @@ -1828,16 +1899,16 @@ def get_cmdclass(cmdclass=None): class cmd_version(Command): description = "report generated version string" - user_options = [] - boolean_options = [] + user_options: List[Tuple[str, str, str]] = [] + boolean_options: List[str] = [] - def initialize_options(self): + def initialize_options(self) -> None: pass - def finalize_options(self): + def finalize_options(self) -> None: pass - def run(self): + def run(self) -> None: vers = get_versions(verbose=True) print("Version: %s" % vers["version"]) print(" full-revisionid: %s" % vers.get("full-revisionid")) @@ -1867,12 +1938,12 @@ def run(self): # we override different "build_py" commands for both environments if 'build_py' in cmds: - _build_py = cmds['build_py'] + _build_py: Any = cmds['build_py'] else: from setuptools.command.build_py import build_py as _build_py class cmd_build_py(_build_py): - def run(self): + def run(self) -> None: root = get_root() cfg = get_config_from_root(root) versions = get_versions() @@ -1891,12 +1962,12 @@ def run(self): cmds["build_py"] = cmd_build_py if 'build_ext' in cmds: - _build_ext = cmds['build_ext'] + _build_ext: Any = cmds['build_ext'] else: from setuptools.command.build_ext import build_ext as _build_ext class cmd_build_ext(_build_ext): - def run(self): + def run(self) -> None: root = get_root() cfg = get_config_from_root(root) versions = get_versions() @@ -1923,7 +1994,7 @@ def run(self): cmds["build_ext"] = cmd_build_ext if "cx_Freeze" in sys.modules: # cx_freeze enabled? - from cx_Freeze.dist import build_exe as _build_exe + from cx_Freeze.dist import build_exe as _build_exe # type: ignore # nczeczulin reports that py2exe won't like the pep440-style string # as FILEVERSION, but it can be used for PRODUCTVERSION, e.g. # setup(console=[{ @@ -1932,7 +2003,7 @@ def run(self): # ... class cmd_build_exe(_build_exe): - def run(self): + def run(self) -> None: root = get_root() cfg = get_config_from_root(root) versions = get_versions() @@ -1956,12 +2027,12 @@ def run(self): if 'py2exe' in sys.modules: # py2exe enabled? try: - from py2exe.setuptools_buildexe import py2exe as _py2exe + from py2exe.setuptools_buildexe import py2exe as _py2exe # type: ignore except ImportError: - from py2exe.distutils_buildexe import py2exe as _py2exe + from py2exe.distutils_buildexe import py2exe as _py2exe # type: ignore class cmd_py2exe(_py2exe): - def run(self): + def run(self) -> None: root = get_root() cfg = get_config_from_root(root) versions = get_versions() @@ -1984,12 +2055,12 @@ def run(self): # sdist farms its file list building out to egg_info if 'egg_info' in cmds: - _egg_info = cmds['egg_info'] + _egg_info: Any = cmds['egg_info'] else: from setuptools.command.egg_info import egg_info as _egg_info class cmd_egg_info(_egg_info): - def find_sources(self): + def find_sources(self) -> None: # egg_info.find_sources builds the manifest list and writes it # in one shot super().find_sources() @@ -2021,12 +2092,12 @@ def find_sources(self): # we override different "sdist" commands for both environments if 'sdist' in cmds: - _sdist = cmds['sdist'] + _sdist: Any = cmds['sdist'] else: from setuptools.command.sdist import sdist as _sdist class cmd_sdist(_sdist): - def run(self): + def run(self) -> None: versions = get_versions() self._versioneer_generated_versions = versions # unless we update this, the command will keep using the old @@ -2034,7 +2105,7 @@ def run(self): self.distribution.metadata.version = versions["version"] return _sdist.run(self) - def make_release_tree(self, base_dir, files): + def make_release_tree(self, base_dir: str, files: List[str]) -> None: root = get_root() cfg = get_config_from_root(root) _sdist.make_release_tree(self, base_dir, files) @@ -2099,7 +2170,7 @@ def make_release_tree(self, base_dir, files): """ -def do_setup(): +def do_setup() -> int: """Do main VCS-independent setup function for installing Versioneer.""" root = get_root() try: @@ -2126,6 +2197,7 @@ def do_setup(): ipy = os.path.join(os.path.dirname(cfg.versionfile_source), "__init__.py") + maybe_ipy: Optional[str] = ipy if os.path.exists(ipy): try: with open(ipy, "r") as f: @@ -2146,16 +2218,16 @@ def do_setup(): print(" %s unmodified" % ipy) else: print(" %s doesn't exist, ok" % ipy) - ipy = None + maybe_ipy = None # Make VCS-specific changes. For git, this means creating/changing # .gitattributes to mark _version.py for export-subst keyword # substitution. - do_vcs_install(cfg.versionfile_source, ipy) + do_vcs_install(cfg.versionfile_source, maybe_ipy) return 0 -def scan_setup_py(): +def scan_setup_py() -> int: """Validate the contents of setup.py against Versioneer's expectations.""" found = set() setters = False @@ -2192,7 +2264,7 @@ def scan_setup_py(): return errors -def setup_command(): +def setup_command() -> NoReturn: """Set up Versioneer and exit with appropriate error code.""" errors = do_setup() errors += scan_setup_py()