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 %}
-
- Name | @@ -66,6 +66,5 @@ {% endfor %}
---|
' - '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 #} - -To log in with your SSO provider, please click below.
- - Single Sign-On - - {% endif %}- 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 %} +No app card template found
-+ 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 %} -
No app card template found
+