diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38e80f4b73..55283b2312 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,7 @@ jobs: - name: Install Dependencies run: | sudo apt update - sudo apt install --yes pandoc texlive-xetex + sudo apt install --yes pandoc texlive-xetex librsvg2-bin python -m pip install --upgrade pip setuptools wheel pandoc --version - name: Install rdmo[mysql] and start mysql diff --git a/pyproject.toml b/pyproject.toml index e5c838a042..bd02299fac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -183,7 +183,7 @@ rest_framework = ["rest_framework"] [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "config.settings" testpaths = ["rdmo"] -python_files = "test_*[!.txt].py" +python_files = "test_*.py" pythonpath = [".", "testing"] addopts = '-p no:randomly -m "not e2e"' markers = [ diff --git a/rdmo/accounts/adapter.py b/rdmo/accounts/adapter.py index ccc7bf4d25..efa7902ce4 100644 --- a/rdmo/accounts/adapter.py +++ b/rdmo/accounts/adapter.py @@ -1,4 +1,5 @@ from django.conf import settings +from django.contrib.auth.models import Group from allauth.account.adapter import DefaultAccountAdapter from allauth.socialaccount.adapter import DefaultSocialAccountAdapter @@ -9,8 +10,26 @@ class AccountAdapter(DefaultAccountAdapter): def is_open_for_signup(self, request): return settings.ACCOUNT_SIGNUP + def save_user(self, request, user, form, commit=True): + user = super().save_user(request, user, form, commit) + + if settings.ACCOUNT_GROUPS: + groups = Group.objects.filter(name__in=settings.ACCOUNT_GROUPS) + user.groups.set(groups) + + return user class SocialAccountAdapter(DefaultSocialAccountAdapter): def is_open_for_signup(self, request, sociallogin): return settings.SOCIALACCOUNT_SIGNUP + + def save_user(self, request, sociallogin, form=None): + user = super().save_user(request, sociallogin, form) + + if settings.SOCIALACCOUNT_GROUPS: + provider = str(sociallogin.account.provider) + groups = Group.objects.filter(name__in=settings.SOCIALACCOUNT_GROUPS.get(provider, [])) + user.groups.set(groups) + + return user diff --git a/rdmo/core/settings.py b/rdmo/core/settings.py index c99f1a2a51..5b529518cb 100644 --- a/rdmo/core/settings.py +++ b/rdmo/core/settings.py @@ -98,15 +98,9 @@ ACCOUNT = False ACCOUNT_SIGNUP = False +ACCOUNT_GROUPS = [] ACCOUNT_TERMS_OF_USE = False - -SOCIALACCOUNT = False - -SHIBBOLETH = False -SHIBBOLETH_LOGIN_URL = '/Shibboleth.sso/Login' -SHIBBOLETH_LOGOUT_URL = '/Shibboleth.sso/Logout' -SHIBBOLETH_USERNAME_PATTERN = None - +ACCOUNT_ADAPTER = 'rdmo.accounts.adapter.AccountAdapter' ACCOUNT_SIGNUP_FORM_CLASS = 'rdmo.accounts.forms.SignupForm' ACCOUNT_USER_DISPLAY = 'rdmo.accounts.utils.get_full_name' ACCOUNT_EMAIL_REQUIRED = True @@ -120,11 +114,16 @@ ACCOUNT_PREVENT_ENUMERATION = False ACCOUNT_ALLOW_USER_TOKEN = False -ACCOUNT_ADAPTER = 'rdmo.accounts.adapter.AccountAdapter' - -SOCIALACCOUNT_ADAPTER = 'rdmo.accounts.adapter.SocialAccountAdapter' +SOCIALACCOUNT = False SOCIALACCOUNT_SIGNUP = False +SOCIALACCOUNT_GROUPS = [] SOCIALACCOUNT_AUTO_SIGNUP = False +SOCIALACCOUNT_ADAPTER = 'rdmo.accounts.adapter.SocialAccountAdapter' + +SHIBBOLETH = False +SHIBBOLETH_LOGIN_URL = '/Shibboleth.sso/Login' +SHIBBOLETH_LOGOUT_URL = '/Shibboleth.sso/Logout' +SHIBBOLETH_USERNAME_PATTERN = None LANGUAGE_CODE = 'en-us' @@ -306,6 +305,9 @@ PROJECT_REMOVE_VIEWS = True +PROJECT_CREATE_RESTRICTED = False +PROJECT_CREATE_GROUPS = [] + NESTED_PROJECTS = True OPTIONSET_PROVIDERS = [] diff --git a/rdmo/projects/permissions.py b/rdmo/projects/permissions.py index cb924a9c57..9fa7be5ffb 100644 --- a/rdmo/projects/permissions.py +++ b/rdmo/projects/permissions.py @@ -1,4 +1,4 @@ -from rdmo.core.permissions import HasObjectPermission, log_result +from rdmo.core.permissions import HasModelPermission, HasObjectPermission, log_result class HasProjectsPermission(HasObjectPermission): @@ -8,11 +8,21 @@ def has_permission(self, request, view): if not (request.user and request.user.is_authenticated): return False - # always return True: - # for retrieve, update, partial_update, the permission will be checked on the - # object level (in the next step), list and create is allowed for every user since - # the filtering is done in the queryset - return True + if view.detail: + # for retrieve, update, partial_update, the permission will be checked on the + # object level (in the next step) + return True + + if view.action == 'list': + # list is allowed for every user since the filtering is done in the queryset + return True + + if 'create' in view.action_map.values(): + # for create, check the permission (from rules.py), + # but only if it is not a ReadOnlyValueSet (i.e. only for ProjectViewSet) + return super().has_permission(request, view) + else: + return True @log_result def has_object_permission(self, request, view, obj): @@ -64,12 +74,19 @@ def get_required_object_permissions(self, method, model_cls): return ('projects.view_page_object', ) -class HasProjectProgressPermission(HasProjectPermission): +class HasProjectProgressModelPermission(HasModelPermission): + + def get_required_permissions(self, method, model_cls): + if method == 'POST': + return ('projects.change_project', ) + else: + return ('projects.view_project', ) + + +class HasProjectProgressObjectPermission(HasProjectPermission): def get_required_object_permissions(self, method, model_cls): - if method == 'GET': - return ('projects.view_project_object', ) - elif method == 'POST': + if method == 'POST': return ('projects.change_project_progress_object', ) else: - raise RuntimeError('Unsupported method for HasProjectProgressPermission') + return ('projects.view_project_object', ) diff --git a/rdmo/projects/rules.py b/rdmo/projects/rules.py index f11061e064..fbab7c065f 100644 --- a/rdmo/projects/rules.py +++ b/rdmo/projects/rules.py @@ -1,9 +1,21 @@ +from django.conf import settings from django.contrib.sites.shortcuts import get_current_site import rules from rules.predicates import is_superuser +@rules.predicate +def can_add_project(user): + if not settings.PROJECT_CREATE_RESTRICTED: + return True + + if settings.PROJECT_CREATE_GROUPS: + return user.groups.filter(name__in=settings.PROJECT_CREATE_GROUPS).exists() + else: + return False + + @rules.predicate def is_project_member(user, project): return user in project.member or (project.parent and is_project_member(user, project.parent)) @@ -54,7 +66,7 @@ def is_site_manager_for_current_site(user, request): # Add rule for check in template rules.add_rule('projects.can_view_all_projects', is_site_manager_for_current_site | is_superuser) - +rules.add_perm('projects.add_project', can_add_project) rules.add_perm('projects.view_project_object', is_project_member | is_site_manager) rules.add_perm('projects.change_project_object', is_project_manager | is_project_owner | is_site_manager) rules.add_perm('projects.change_project_progress_object', is_project_author | is_project_manager | is_project_owner | is_site_manager) # noqa: E501 diff --git a/rdmo/projects/templates/projects/projects.html b/rdmo/projects/templates/projects/projects.html index b2aed864a1..37d29a499d 100644 --- a/rdmo/projects/templates/projects/projects.html +++ b/rdmo/projects/templates/projects/projects.html @@ -22,8 +22,13 @@ {% endblock %} {% block sidebar %} + {% has_perm 'projects.add_project' request.user as can_add_project %} + {% test_rule 'projects.can_view_all_projects' request.user request as can_view_all_projects %} + + {% if can_add_project or can_view_all_projects %}

{% trans 'Options' %}

+ {% if can_add_project %} + {% endif %} - {% test_rule 'projects.can_view_all_projects' request.user request as can_view_all_projects %} {% if can_view_all_projects %} {% endif %} + {% endif %}

{% trans 'Filter projects' %}

@@ -62,6 +68,8 @@

{% trans 'Filter projects' %}

+ {% if can_add_project %} +

{% trans 'Import existing project' %}

+ {% endif %} + {% if invites %}

{% trans 'Pending invitations' %}

diff --git a/rdmo/projects/tests/test_view_project.py b/rdmo/projects/tests/test_view_project.py index 7d63410f9c..6f277ed987 100644 --- a/rdmo/projects/tests/test_view_project.py +++ b/rdmo/projects/tests/test_view_project.py @@ -2,6 +2,7 @@ import pytest +from django.contrib.auth.models import Group, User from django.urls import reverse from pytest_django.asserts import assertContains, assertNotContains, assertTemplateUsed @@ -145,6 +146,33 @@ def test_project_create_get(db, client, username, password): assert response.status_code == 302 +def test_project_create_restricted_get(db, client, settings): + settings.PROJECT_CREATE_RESTRICTED = True + settings.PROJECT_CREATE_GROUPS = ['projects'] + + group = Group.objects.create(name='projects') + user = User.objects.get(username='user') + user.groups.add(group) + + client.login(username='user', password='user') + + url = reverse('project_create') + response = client.get(url) + + assert response.status_code == 200 + + +def test_project_create_forbidden_get(db, client, settings): + settings.PROJECT_CREATE_RESTRICTED = True + + client.login(username='user', password='user') + + url = reverse('project_create') + response = client.get(url) + + assert response.status_code == 403 + + @pytest.mark.parametrize('username,password', users) def test_project_create_get_for_extra_users_and_unavailable_catalogs(db, client, username, password): client.login(username=username, password=password) @@ -215,6 +243,43 @@ def test_project_create_post(db, client, username, password): assert Project.objects.count() == project_count +def test_project_create_post_restricted(db, client, settings): + settings.PROJECT_CREATE_RESTRICTED = True + settings.PROJECT_CREATE_GROUPS = ['projects'] + + group = Group.objects.create(name='projects') + user = User.objects.get(username='user') + user.groups.add(group) + + client.login(username='user', password='user') + + url = reverse('project_create') + data = { + 'title': 'A new project', + 'description': 'Some description', + 'catalog': catalog_id + } + response = client.post(url, data) + + assert response.status_code == 302 + + +def test_project_create_post_forbidden(db, client, settings): + settings.PROJECT_CREATE_RESTRICTED = True + + client.login(username='user', password='user') + + url = reverse('project_create') + data = { + 'title': 'A new project', + 'description': 'Some description', + 'catalog': catalog_id + } + response = client.post(url, data) + + assert response.status_code == 403 + + @pytest.mark.parametrize('username,password', users) def test_project_create_parent_post(db, client, username, password): client.login(username=username, password=password) diff --git a/rdmo/projects/tests/test_view_project_create_import.py b/rdmo/projects/tests/test_view_project_create_import.py index a1c3880719..f8af896dd5 100644 --- a/rdmo/projects/tests/test_view_project_create_import.py +++ b/rdmo/projects/tests/test_view_project_create_import.py @@ -4,6 +4,7 @@ import pytest +from django.contrib.auth.models import Group, User from django.urls import reverse from rdmo.core.constants import VALUE_TYPE_FILE @@ -43,6 +44,31 @@ def test_project_create_import_get(db, client, username, password): assert response.url.startswith('/account/login/') +def test_project_create_import_get_restricted(db, client, settings): + settings.PROJECT_CREATE_RESTRICTED = True + settings.PROJECT_CREATE_GROUPS = ['projects'] + + group = Group.objects.create(name='projects') + user = User.objects.get(username='user') + user.groups.add(group) + + client.login(username='user', password='user') + + url = reverse('project_create_import') + response = client.get(url) + assert response.status_code == 400 + + +def test_project_create_import_get_forbidden(db, client, settings): + settings.PROJECT_CREATE_RESTRICTED = True + + client.login(username='user', password='user') + + url = reverse('project_create_import') + response = client.get(url) + assert response.status_code == 403 + + @pytest.mark.parametrize('username,password', users) def test_project_create_import_post_empty(db, settings, client, username, password): client.login(username=username, password=password) @@ -122,6 +148,41 @@ def test_project_create_import_post_upload_file_empty(db, client, username, pass assert response.url.startswith('/account/login/') +def test_project_create_import_post_upload_file_restricted(db, client, settings): + settings.PROJECT_CREATE_RESTRICTED = True + settings.PROJECT_CREATE_GROUPS = ['projects'] + + group = Group.objects.create(name='projects') + user = User.objects.get(username='user') + user.groups.add(group) + + client.login(username='user', password='user') + + url = reverse('project_create_import') + xml_file = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') + with open(xml_file, encoding='utf8') as f: + response = client.post(url, { + 'method': 'upload_file', + 'uploaded_file': f + }) + assert response.status_code == 302 + + +def test_project_create_import_post_upload_file_forbidden(db, client, settings): + settings.PROJECT_CREATE_RESTRICTED = True + + client.login(username='user', password='user') + + url = reverse('project_create_import') + xml_file = os.path.join(settings.BASE_DIR, 'xml', 'project.xml') + with open(xml_file, encoding='utf8') as f: + response = client.post(url, { + 'method': 'upload_file', + 'uploaded_file': f + }) + assert response.status_code == 403 + + @pytest.mark.parametrize('username,password', users) def test_project_create_import_post_import_file(db, settings, client, files, username, password): client.login(username=username, password=password) diff --git a/rdmo/projects/tests/test_viewset_project.py b/rdmo/projects/tests/test_viewset_project.py index 3e523882f4..adf83dc608 100644 --- a/rdmo/projects/tests/test_viewset_project.py +++ b/rdmo/projects/tests/test_viewset_project.py @@ -1,5 +1,6 @@ import pytest +from django.contrib.auth.models import Group, User from django.urls import reverse from ..models import Project @@ -113,6 +114,43 @@ def test_create(db, client, username, password): assert response.status_code == 401 +def test_create_restricted(db, client, settings): + settings.PROJECT_CREATE_RESTRICTED = True + settings.PROJECT_CREATE_GROUPS = ['projects'] + + group = Group.objects.create(name='projects') + user = User.objects.get(username='user') + user.groups.add(group) + + client.login(username='user', password='user') + + url = reverse(urlnames['list']) + data = { + 'title': 'Lorem ipsum dolor sit amet', + 'description': 'At vero eos et accusam et justo duo dolores et ea rebum.', + 'catalog': catalog_id + } + response = client.post(url, data) + + assert response.status_code == 201 + + +def test_create_forbidden(db, client, settings): + settings.PROJECT_CREATE_RESTRICTED = True + + client.login(username='user', password='user') + + url = reverse(urlnames['list']) + data = { + 'title': 'Lorem ipsum dolor sit amet', + 'description': 'At vero eos et accusam et justo duo dolores et ea rebum.', + 'catalog': catalog_id + } + response = client.post(url, data) + + assert response.status_code == 403 + + def test_create_catalog_missing(db, client): client.login(username='user', password='user') diff --git a/rdmo/projects/views/project_create.py b/rdmo/projects/views/project_create.py index eb1cac6f50..c909e9a719 100644 --- a/rdmo/projects/views/project_create.py +++ b/rdmo/projects/views/project_create.py @@ -5,7 +5,7 @@ from django.urls import reverse_lazy from django.views.generic import CreateView, TemplateView -from rdmo.core.views import RedirectViewMixin +from rdmo.core.views import ObjectPermissionMixin, RedirectViewMixin from rdmo.questions.models import Catalog from rdmo.tasks.models import Task from rdmo.views.models import View @@ -17,9 +17,11 @@ logger = logging.getLogger(__name__) -class ProjectCreateView(LoginRequiredMixin, RedirectViewMixin, CreateView): +class ProjectCreateView(ObjectPermissionMixin, LoginRequiredMixin, + RedirectViewMixin, CreateView): model = Project form_class = ProjectForm + permission_required = 'projects.add_project' def get_form_kwargs(self): catalogs = Catalog.objects.filter_current_site() \ @@ -65,8 +67,10 @@ def form_valid(self, form): return response -class ProjectCreateImportView(ProjectImportMixin, LoginRequiredMixin, TemplateView): +class ProjectCreateImportView(ObjectPermissionMixin, LoginRequiredMixin, + ProjectImportMixin, TemplateView): success_url = reverse_lazy('projects') + permission_required = 'projects.add_project' def get(self, request, *args, **kwargs): self.object = None diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py index 0d3ab9e29e..378a97c68b 100644 --- a/rdmo/projects/viewsets.py +++ b/rdmo/projects/viewsets.py @@ -28,7 +28,8 @@ from .permissions import ( HasProjectPagePermission, HasProjectPermission, - HasProjectProgressPermission, + HasProjectProgressModelPermission, + HasProjectProgressObjectPermission, HasProjectsPermission, ) from .progress import compute_navigation, compute_progress @@ -174,7 +175,7 @@ def options(self, request, pk=None): raise NotFound() @action(detail=True, methods=['get', 'post'], - permission_classes=(HasModelPermission | HasProjectProgressPermission, )) + permission_classes=(HasProjectProgressModelPermission | HasProjectProgressObjectPermission, )) def progress(self, request, pk=None): project = self.get_object() diff --git a/testing/export/project.csv b/testing/export/project.csv index 1092d726f6..6d1bf98884 100644 --- a/testing/export/project.csv +++ b/testing/export/project.csv @@ -1,7 +1,7 @@ Text?,,Lorem ipsum dolor sit amet Textarea?,,"Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet." Yes or no?,,Yes -Radio buttons?,,Other: Lorem ipsum +Radio buttons?,,Text: Lorem ipsum Select drop-down?,,One Range slider?,,37 File?,,rdmo-logo.svg @@ -18,7 +18,7 @@ Checkbox?,,One; Three Text?,,Lorem ipsum dolor sit amet Textarea?,,"Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est. Lorem ipsum dolor sit amet." Yes or no?,,Yes -Radio buttons?,,Other: Lorem ipsum +Radio buttons?,,Text: Lorem ipsum Select drop-down?,,One Range slider?,,37 Date picker?,,2018-01-01 diff --git a/testing/export/project.html b/testing/export/project.html index aba80183d7..b961e65aa4 100644 --- a/testing/export/project.html +++ b/testing/export/project.html @@ -23,7 +23,7 @@

Yes or no

Radio buttons

Radio buttons?

-Other: Lorem ipsum +Text: Lorem ipsum

Select drop-down

Select drop-down?

@@ -171,7 +171,7 @@

Individual sets I

Radio buttons?

-Other: Lorem ipsum +Text: Lorem ipsum

Select drop-down?