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 %}
-
@@ -33,8 +38,8 @@
{% trans 'Options' %}
+ {% endif %}
- {% test_rule 'projects.can_view_all_projects' request.user request as can_view_all_projects %}
{% if can_view_all_projects %}
-
@@ -44,6 +49,7 @@
{% trans 'Options' %}
{% endif %}
+ {% endif %}
{% trans 'Filter projects' %}
@@ -62,6 +68,8 @@ {% trans 'Filter projects' %}
+ {% if can_add_project %}
+
{% trans 'Import existing project' %}
@@ -91,6 +99,8 @@ {% trans 'Import existing project' %}
{% endif %}
+ {% 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?