Repository: rdmorganiser/rdmo (language: python)

Issue:
(all) content items should have ownership property (multi-site editor) 
### Rationale / Begründung

An editor of a tenant instance as part of a multi-tenant rdmo instance wants to make sure that the content items, which were created by the tenant, can only be edited by themselves and colleagues.

All content items (attributes, optionsets, questions etc.) are global, i.e. every editor of an instance is able to change any content item. 
In multi-site mode this means that any editor of a tenant can change the content of another tenant.
To prevent this, an additional property for every content item "owner" should be added. Owner of an item are sites (not users), that means editors of one site can change all items that they created. Only owners can edit and update the content items. 
Additionally, there might be multi-site editors and administrators who can edit all items. Non-owners can view items, use them and copy them, but cannot change them. 
There may be items that are not owned by any site, but globally used for every site. These items can only be changed by multi-site editors and administrators.

### Affected 

Managers, Admins

### Minimal functionality

#### Model field and permissions
* a Field ~~"owner"~~ is added to every content item Model (e.g. ManyToManyField to Sites)
  * `editor` , as in "content item editorial site", or "site ownership"?
* the ownership can be declared as "global", which means no site editors are allowed to edit the item (except super editor and administrators)
  * special permission when the field `editor` is empty
* multi-site editors, administrators can edit the ownership of an item
  *  need to check the permissions
 
#### CRUD Behaviour
* when a user creates a new content item, the owner is set to the site of which the user is a member of
  * can added via the `current_site()` of the User
* the property is not part of the export of content items (catalog etc.)
  * only for internal CMS purposes
* the owner of an item is mentioned in the update content item details form
  * another field in the `modal form` 
* if a non-owner tries to change an owned item, a error message is shown stating the owner of the item
  * similar to the `is_locked` functionality




Discussion / hints:
`owner` would be confusing with the `owner` of projects, maybe `editors` would be better (being a m2m field to site).
For adding the DRF [Object level permissions](https://www.django-rest-framework.org/tutorial/4-authentication-and-permissions/#object-level-permissions) to the `Catalog`. 
I could add this `IsEditorOrReadOnly` class to the `permission_classes` of the `CatalogViewSet`.  Then I don't need to use the `rules` and the `HasObjectPermission`  classes.


```py
class IsEditorOrReadOnly(BasePermission):
    """
    Object-level permission to only allow owners of an object to edit it.
    Assumes the model instance has an `editors` attribute.

    """
    
    def has_object_permission(self, request, view, obj):
        
        # Read permissions are allowed to any request,
        # so we'll always allow GET, HEAD or OPTIONS requests.
        if request.method in SAFE_METHODS:
            return True
        # Instance must have an attribute named `editors`.        
        return request.user.role.editor.filter(id__in=obj.editors.all()).exists()
```
and give this to the `CatalogViewSet`
```py
class CatalogViewSet(CopyModelMixin, ModelViewSet):
    permission_classes = (HasModelPermission & IsEditorOrReadOnly, )
    ....
```

Would this way be sufficient or does it need to be implemented via `rules` and `HasObjectPermission` ?

Fix patch:
diff --git a/rdmo/accounts/admin.py b/rdmo/accounts/admin.py
index 4e4bece4f2..ac5a14342a 100644
--- a/rdmo/accounts/admin.py
+++ b/rdmo/accounts/admin.py
@@ -1,4 +1,6 @@
 from django.contrib import admin
+from django.contrib.sites.models import Site
+from django.db.models import Count, Value
 
 from .models import AdditionalField, AdditionalFieldValue, ConsentFieldValue, Role
 
@@ -20,7 +22,34 @@ def has_add_permission(self, request, obj=None):
 
 class RoleAdmin(admin.ModelAdmin):
     search_fields = ('user__username', 'user__email')
-    list_filter = ('member', 'manager')
+    list_filter = ('member', 'manager', 'editor', 'reviewer')
+
+    list_display = ('user', 'members', 'managers', 'editors', 'reviewers')
+
+    def get_queryset(self, request):
+        return Role.objects.prefetch_related(
+                'member', 'manager', 'editor', 'reviewer').annotate(
+                    Count('member'), Count('manager'), Count('editor'), Count('reviewer'),
+                    sites_count=Value(Site.objects.count())
+                )
+
+    @staticmethod
+    def render_all_sites_or_join(obj, field_name: str) -> str:
+        if getattr(obj, f'{field_name}__count', 0) == obj.sites_count:
+            return 'all Sites'
+        return ', '.join([site.domain for site in getattr(obj, field_name).all()])
+
+    def members(self, obj):
+        return self.render_all_sites_or_join(obj, 'member')
+
+    def managers(self, obj):
+        return self.render_all_sites_or_join(obj, 'manager')
+
+    def editors(self, obj):
+        return self.render_all_sites_or_join(obj, 'editor')
+
+    def reviewers(self, obj):
+        return self.render_all_sites_or_join(obj, 'reviewer')
 
 
 admin.site.register(AdditionalField, AdditionalFieldAdmin)
diff --git a/rdmo/accounts/apps.py b/rdmo/accounts/apps.py
index 2f28eb0b9a..b7c205ad2a 100644
--- a/rdmo/accounts/apps.py
+++ b/rdmo/accounts/apps.py
@@ -5,6 +5,3 @@
 class AccountsConfig(AppConfig):
     name = 'rdmo.accounts'
     verbose_name = _('Accounts')
-
-    def ready(self):
-        from . import rules
diff --git a/rdmo/accounts/migrations/0020_add_role_editor_and_reviewer.py b/rdmo/accounts/migrations/0020_add_role_editor_and_reviewer.py
new file mode 100644
index 0000000000..566f260410
--- /dev/null
+++ b/rdmo/accounts/migrations/0020_add_role_editor_and_reviewer.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.2.16 on 2023-02-17 14:25
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('sites', '0002_alter_domain_unique'),
+        ('accounts', '0019_delete_proxyuser'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='role',
+            name='editor',
+            field=models.ManyToManyField(blank=True, help_text='The sites for which this user is an editor.', related_name='editors', to='sites.Site', verbose_name='Editor'),
+        ),
+        migrations.AddField(
+            model_name='role',
+            name='reviewer',
+            field=models.ManyToManyField(blank=True, help_text='The sites for which this user is a reviewer.', related_name='reviewers', to='sites.Site', verbose_name='Reviewer'),
+        ),
+    ]
diff --git a/rdmo/accounts/models.py b/rdmo/accounts/models.py
index 78ad3a61be..9d9c097a76 100644
--- a/rdmo/accounts/models.py
+++ b/rdmo/accounts/models.py
@@ -137,6 +137,16 @@ class Role(models.Model):
         verbose_name=_('Manager'),
         help_text=_('The sites for which this user is manager.')
     )
+    editor = models.ManyToManyField(
+        Site, related_name='editors', blank=True,
+        verbose_name=_('Editor'),
+        help_text=_('The sites for which this user is an editor.')
+    )
+    reviewer = models.ManyToManyField(
+        Site, related_name='reviewers', blank=True,
+        verbose_name=_('Reviewer'),
+        help_text=_('The sites for which this user is a reviewer.')
+    )
 
     class Meta:
         ordering = ('user', )
diff --git a/rdmo/accounts/rules.py b/rdmo/accounts/rules.py
deleted file mode 100644
index cd0def179b..0000000000
--- a/rdmo/accounts/rules.py
+++ /dev/null
@@ -1,16 +0,0 @@
-import rules
-from django.contrib.sites.models import Site
-
-from .utils import is_site_manager as is_site_manager_util
-
-
-@rules.predicate
-def is_site_manager(manager, user):
-    if is_site_manager_util(manager):
-        current_site = Site.objects.get_current()
-        return current_site in user.role.member.all()
-    else:
-        return False
-
-
-rules.add_perm('auth.view_user_object', is_site_manager)
diff --git a/rdmo/accounts/serializers/v1.py b/rdmo/accounts/serializers/v1.py
index c78bb001c8..ed9e627b6a 100644
--- a/rdmo/accounts/serializers/v1.py
+++ b/rdmo/accounts/serializers/v1.py
@@ -34,13 +34,17 @@ class RoleSerializer(serializers.ModelSerializer):
 
     member = SiteSerializer(many=True)
     manager = SiteSerializer(many=True)
+    editor = SiteSerializer(many=True)
+    reviewer = SiteSerializer(many=True)
 
     class Meta:
         model = Role
         fields = (
             'id',
             'member',
-            'manager'
+            'manager',
+            'editor',
+            'reviewer'
         )
 
 
diff --git a/rdmo/accounts/viewsets.py b/rdmo/accounts/viewsets.py
index 8cc62c19cd..baa733cb85 100644
--- a/rdmo/accounts/viewsets.py
+++ b/rdmo/accounts/viewsets.py
@@ -1,10 +1,11 @@
 from django.contrib.auth import get_user_model
-from django.contrib.sites.models import Site
 from django_filters.rest_framework import DjangoFilterBackend
 from rest_framework.viewsets import ReadOnlyModelViewSet
 
 from rdmo.core.permissions import HasModelPermission, HasObjectPermission
+from rdmo.management.rules import is_editor, is_reviewer
 
+from .models import Role
 from .serializers.v1 import UserSerializer
 from .utils import is_site_manager
 
@@ -16,8 +17,7 @@ def get_users_for_user(self, user):
             if user.has_perm('auth.view_user'):
                 return get_user_model().objects.all()
             elif is_site_manager(user):
-                current_site = Site.objects.get_current()
-                return get_user_model().objects.filter(role__member=current_site)
+                return get_user_model().objects.filter(role__member__id__in=user.role.manager.all()).distinct()
         return get_user_model().objects.none()
 
 
@@ -36,4 +36,7 @@ class UserViewSet(UserViewSetMixin, ReadOnlyModelViewSet):
 
     def get_queryset(self):
         return self.get_users_for_user(self.request.user) \
-                   .prefetch_related('groups', 'role__member', 'role__manager', 'memberships')
+                   .prefetch_related('groups',
+                                     'role__member', 'role__manager',
+                                     'role__editor', 'role__reviewer',
+                                     'memberships')
diff --git a/rdmo/conditions/admin.py b/rdmo/conditions/admin.py
index 12e953f6d9..17db94527c 100644
--- a/rdmo/conditions/admin.py
+++ b/rdmo/conditions/admin.py
@@ -24,6 +24,7 @@ class ConditionAdmin(admin.ModelAdmin):
     list_display = ('uri', 'source', 'relation', 'target_text', 'target_option')
     readonly_fields = ('uri', )
     list_filter = ('relation', )
+    filter_horizontal = ('editors', )
 
 
 admin.site.register(Condition, ConditionAdmin)
diff --git a/rdmo/conditions/imports.py b/rdmo/conditions/imports.py
index 3598459e72..e33edeaf49 100644
--- a/rdmo/conditions/imports.py
+++ b/rdmo/conditions/imports.py
@@ -1,5 +1,7 @@
 import logging
 
+from django.contrib.sites.models import Site
+
 from rdmo.core.imports import (set_common_fields, set_foreign_field,
                                validate_instance)
 
@@ -34,5 +36,6 @@ def import_condition(element, save=False):
             logger.info('Condition created with uri %s.', element.get('uri'))
 
         condition.save()
+        condition.editors.add(Site.objects.get_current())
 
     return condition
diff --git a/rdmo/conditions/migrations/0023_condition_editors.py b/rdmo/conditions/migrations/0023_condition_editors.py
new file mode 100644
index 0000000000..981a734920
--- /dev/null
+++ b/rdmo/conditions/migrations/0023_condition_editors.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.16 on 2023-02-24 14:14
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('sites', '0002_alter_domain_unique'),
+        ('conditions', '0022_condition_locked'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='condition',
+            name='editors',
+            field=models.ManyToManyField(blank=True, help_text='The sites that can edit this condition (in a multi site setup).', related_name='condition_editors', to='sites.Site', verbose_name='Editors'),
+        ),
+    ]
diff --git a/rdmo/conditions/models.py b/rdmo/conditions/models.py
index b89524cb86..4251305dfb 100644
--- a/rdmo/conditions/models.py
+++ b/rdmo/conditions/models.py
@@ -1,4 +1,5 @@
 from django.conf import settings
+from django.contrib.sites.models import Site
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 
@@ -54,6 +55,11 @@ class Condition(models.Model):
         verbose_name=_('Locked'),
         help_text=_('Designates whether this condition can be changed.')
     )
+    editors = models.ManyToManyField(
+        Site, related_name='%(class)s_editors', blank=True,
+        verbose_name=_('Editors'),
+        help_text=_('The sites that can edit this condition (in a multi site setup).')
+    )
     source = models.ForeignKey(
         Attribute, db_constraint=False, blank=True, null=True, on_delete=models.SET_NULL, related_name='conditions',
         verbose_name=_('Source'),
@@ -89,7 +95,7 @@ def save(self, *args, **kwargs):
 
     def copy(self, uri_prefix, key):
         condition = copy_model(self, uri_prefix=uri_prefix, key=key, source=self.source, target_option=self.target_option)
-
+ 
         return condition
 
     @property
diff --git a/rdmo/conditions/serializers/v1.py b/rdmo/conditions/serializers/v1.py
index 264cdf37f4..d262a3b30f 100644
--- a/rdmo/conditions/serializers/v1.py
+++ b/rdmo/conditions/serializers/v1.py
@@ -1,7 +1,8 @@
 from rest_framework import serializers
 
 from rdmo.core.serializers import (ElementExportSerializerMixin,
-                                   ElementModelSerializerMixin)
+                                   ElementModelSerializerMixin,
+                                   ReadOnlyObjectPermissionsSerializerMixin)
 from rdmo.domain.models import Attribute
 from rdmo.options.models import OptionSet
 from rdmo.questions.models import Page, Question, QuestionSet
@@ -11,7 +12,8 @@
 from ..validators import ConditionLockedValidator, ConditionUniqueURIValidator
 
 
-class ConditionSerializer(ElementModelSerializerMixin, serializers.ModelSerializer):
+class ConditionSerializer(ElementModelSerializerMixin, ReadOnlyObjectPermissionsSerializerMixin,
+                          serializers.ModelSerializer):
 
     model = serializers.SerializerMethodField()
     key = serializers.SlugField(required=True)
@@ -23,6 +25,8 @@ class ConditionSerializer(ElementModelSerializerMixin, serializers.ModelSerializ
     questions = serializers.PrimaryKeyRelatedField(queryset=Question.objects.all(), required=False, many=True)
     tasks = serializers.PrimaryKeyRelatedField(queryset=Task.objects.all(), required=False, many=True)
 
+    read_only = serializers.SerializerMethodField()
+
     class Meta:
         model = Condition
         fields = (
@@ -33,6 +37,8 @@ class Meta:
             'key',
             'comment',
             'locked',
+            'read_only',
+            'editors',
             'source',
             'relation',
             'target_text',
diff --git a/rdmo/conditions/static/conditions/js/conditions.js b/rdmo/conditions/static/conditions/js/conditions.js
index a134a215e1..f0bea5f171 100644
--- a/rdmo/conditions/static/conditions/js/conditions.js
+++ b/rdmo/conditions/static/conditions/js/conditions.js
@@ -14,6 +14,7 @@ angular.module('conditions', ['core'])
         attributes: $resource(baseurl + 'api/v1/domain/attributes/:id/'),
         options: $resource(baseurl + 'api/v1/options/options/:id/'),
         settings: $resource(baseurl + 'api/v1/core/settings/'),
+        sites: $resource(baseurl + 'api/v1/core/sites/')
     };
 
     /* configure factories */
@@ -36,6 +37,7 @@ angular.module('conditions', ['core'])
     service.init = function(options) {
         service.relations = resources.relations.query();
         service.settings = resources.settings.get();
+        service.sites = resources.sites.query();
         service.uri_prefixes = []
         service.uri_prefix = ''
         service.filter = sessionStorage.getItem('conditions_filter') || '';
diff --git a/rdmo/conditions/templates/conditions/conditions.html b/rdmo/conditions/templates/conditions/conditions.html
index 327ed5d093..e8a7428098 100644
--- a/rdmo/conditions/templates/conditions/conditions.html
+++ b/rdmo/conditions/templates/conditions/conditions.html
@@ -95,6 +95,9 @@ <h1>{% trans 'Conditions' %}</h1>
             <div class="panel-heading">
                 <div class="pull-right">
                     <i class="fa fa-lock text-danger" ng-show="condition.locked"></i>
+                    <i class="fa fa-ban text-muted" ng-show="condition.read_only"
+                        title="{% trans 'Read only' %}">
+                    </i>
 
                     <a href="" class="fa fa-pencil"
                         title="{% trans 'Update condition' %}"
@@ -117,7 +120,9 @@ <h1>{% trans 'Conditions' %}</h1>
                         ng-click="service.openDeleteModal('conditions', condition)">
                     </a>
                 </div>
-                <strong>{% trans 'Condition' %}</strong>
+                <strong ng-class="{'text-muted': condition.read_only}">
+                    {% trans 'Condition' %}
+                </strong>
                 <code class="code-conditions">{$ condition.uri $}</code>
             </div>
 
@@ -136,6 +141,27 @@ <h1>{% trans 'Conditions' %}</h1>
                     {$ condition.description $}
                 </li>
             </ul>
+            {% if settings.MULTISITE %}
+            <div class="panel-body">
+                <div class="row">
+                    <div class="col-sm-3">
+                        <strong>{% trans 'Editors' %}</strong>
+                    </div>
+                    <div class="col-sm-9">
+                        <ul>
+                            <li ng-show="condition.editors.length > 0 && condition.editors.length < service.sites.length"
+                                ng-repeat="editor in condition.editors">
+                                {$ editor.name $}
+                            </li>
+                            <li ng-show="condition.editors.length == 0 || condition.editors.length == service.sites.length">
+                                {% trans 'All sites' %}
+                            </li>
+                        </ul>
+                    </div>
+                </div>
+            </div>
+            {% endif %}
+
         </div>
     </div>
 
diff --git a/rdmo/conditions/templates/conditions/conditions_modal_delete_conditions.html b/rdmo/conditions/templates/conditions/conditions_modal_delete_conditions.html
index 62c9f79c1c..91ce2063c9 100644
--- a/rdmo/conditions/templates/conditions/conditions_modal_delete_conditions.html
+++ b/rdmo/conditions/templates/conditions/conditions_modal_delete_conditions.html
@@ -36,10 +36,14 @@ <h2 class="modal-title">
                 </div>
 
                 <div class="modal-footer">
+                    <div class="alert alert-warning pull-left" ng-show="service.errors.detail" role="alert">
+                        {$ service.errors.detail $}
+                    </div>
                     <button type="button" class="btn btn-default" data-dismiss="modal">
                         {% trans 'Close' %}
                     </button>
                     <button type="button" class="btn btn-danger"
+                            ng-class="{'disabled': service.values.read_only}"
                             ng-click="service.submitDeleteModal('conditions')">
                         {% trans 'Delete' %}
                     </button>
diff --git a/rdmo/conditions/templates/conditions/conditions_modal_form_conditions.html b/rdmo/conditions/templates/conditions/conditions_modal_form_conditions.html
index 8aac1247f9..94481dcb13 100644
--- a/rdmo/conditions/templates/conditions/conditions_modal_form_conditions.html
+++ b/rdmo/conditions/templates/conditions/conditions_modal_form_conditions.html
@@ -128,14 +128,32 @@ <h2 class="modal-title">
                             data-options-null="1"
                             data-quicksearch="true">
                         </formgroup>
+                        {% if settings.MULTISITE %}
+                        <div role="tabpanel" class="tab-pane" id="condition-editors">
+                            <formgroup
+                                data-id="condition_editors"
+                                data-type="selectmultiple"
+                                data-label="{% trans 'Editors' %}"
+                                data-model="service.values.editors"
+                                data-errors="service.errors.editors"
+                                data-options="service.sites"
+                                data-options-label="name"
+                                data-help="{% trans 'Hold down Control, or Command on a Mac, to select more than one.' %}">
+                            </formgroup>
+                        </div>
+                        {% endif %}
                     </div>
                 </div>
 
                 <div class="modal-footer">
+                    <div class="alert alert-warning pull-left" ng-show="service.errors.detail" role="alert">
+                        {$ service.errors.detail $}
+                    </div>
                     <button type="button" class="btn btn-default" data-dismiss="modal">
                         {% trans 'Close' %}
                     </button>
                     <button type="button" class="btn btn-primary"
+                            ng-class="{'disabled': service.values.read_only}"
                             ng-click="service.submitFormModal('conditions')">
                         {% trans 'Save' %}
                     </button>
diff --git a/rdmo/conditions/viewsets.py b/rdmo/conditions/viewsets.py
index 42d4974542..6334c30c6d 100644
--- a/rdmo/conditions/viewsets.py
+++ b/rdmo/conditions/viewsets.py
@@ -6,7 +6,7 @@
 from rest_framework.viewsets import ModelViewSet
 
 from rdmo.core.exports import XMLResponse
-from rdmo.core.permissions import HasModelPermission
+from rdmo.core.permissions import HasModelPermission, HasObjectPermission
 from rdmo.core.utils import is_truthy, render_to_format
 from rdmo.core.views import ChoicesViewSet
 from rdmo.core.viewsets import CopyModelMixin
@@ -19,9 +19,10 @@
 
 
 class ConditionViewSet(CopyModelMixin, ModelViewSet):
-    permission_classes = (HasModelPermission, )
+    permission_classes = (HasModelPermission | HasObjectPermission, )
     queryset = Condition.objects.select_related('source', 'target_option') \
-                                .prefetch_related('optionsets', 'pages', 'questionsets', 'questions', 'tasks')
+                                .prefetch_related('optionsets', 'pages', 'questionsets',
+                                                  'questions', 'tasks', 'editors')
 
     filter_backends = (SearchFilter, DjangoFilterBackend)
     search_fields = ('uri', )
@@ -44,7 +45,7 @@ def index(self, request):
         serializer = ConditionIndexSerializer(queryset, many=True)
         return Response(serializer.data)
 
-    @action(detail=False, url_path='export(/(?P<export_format>[a-z]+))?', permission_classes=[HasModelPermission])
+    @action(detail=False, url_path='export(/(?P<export_format>[a-z]+))?')
     def export(self, request, export_format='xml'):
         queryset = self.filter_queryset(self.get_queryset())
         if export_format == 'xml':
@@ -56,7 +57,7 @@ def export(self, request, export_format='xml'):
                 'conditions': queryset
             })
 
-    @action(detail=True, url_path='export(/(?P<export_format>[a-z]+))?', permission_classes=[HasModelPermission])
+    @action(detail=True, url_path='export(/(?P<export_format>[a-z]+))?')
     def detail_export(self, request, pk=None, export_format='xml'):
         if export_format == 'xml':
             serializer = ConditionExportSerializer(self.get_object())
diff --git a/rdmo/core/assets/js/api/BaseApi.js b/rdmo/core/assets/js/api/BaseApi.js
index bb12a748c6..0f12feacb9 100644
--- a/rdmo/core/assets/js/api/BaseApi.js
+++ b/rdmo/core/assets/js/api/BaseApi.js
@@ -16,14 +16,14 @@ function ValidationError(errors) {
 class BaseApi {
 
   static get(url) {
-    return fetch(url).then(response => {
+    return fetch(url).catch(error => {
+      throw new ApiError(error.message)
+    }).then(response => {
       if (response.ok) {
         return response.json()
       } else {
         throw new ApiError(response.statusText, response.status)
       }
-    }).catch(error => {
-      throw new ApiError(error.message)
     })
   }
 
@@ -35,6 +35,8 @@ class BaseApi {
         'X-CSRFToken': Cookies.get('csrftoken')
       },
       body: JSON.stringify(data)
+    }).catch(error => {
+      throw new ApiError(error.message)
     }).then(response => {
       if (response.ok) {
         return response.json()
@@ -45,8 +47,6 @@ class BaseApi {
       } else {
         throw new ApiError(response.statusText, response.status)
       }
-    }).catch(error => {
-      throw new ApiError(error.message)
     })
   }
 
@@ -58,6 +58,8 @@ class BaseApi {
         'X-CSRFToken': Cookies.get('csrftoken')
       },
       body: JSON.stringify(data)
+    }).catch(error => {
+      throw new ApiError(error.message)
     }).then(response => {
       if (response.ok) {
         return response.json()
@@ -68,8 +70,6 @@ class BaseApi {
       } else {
         throw new ApiError(response.statusText, response.status)
       }
-    }).catch(error => {
-      throw new ApiError(error.message)
     })
   }
 
@@ -80,6 +80,8 @@ class BaseApi {
         'Content-Type': 'application/json',
         'X-CSRFToken': Cookies.get('csrftoken')
       }
+    }).catch(error => {
+      throw new ApiError(error.message)
     }).then(response => {
       if (response.ok) {
         return null
@@ -90,8 +92,6 @@ class BaseApi {
       } else {
         throw new ApiError(response.statusText, response.status)
       }
-    }).catch(error => {
-      throw new ApiError(error.message)
     })
   }
 
@@ -105,6 +105,8 @@ class BaseApi {
         'X-CSRFToken': Cookies.get('csrftoken')
       },
       body: formData
+    }).catch(error => {
+      throw new ApiError(error.message)
     }).then(response => {
       if (response.ok) {
         return response.json()
@@ -115,8 +117,6 @@ class BaseApi {
       } else {
         throw new ApiError(response.statusText, response.status)
       }
-    }).catch(error => {
-      throw new ApiError(error.message)
     })
   }
 
diff --git a/rdmo/core/assets/js/components/Link.js b/rdmo/core/assets/js/components/Link.js
index 670e6e08b7..220eceb4db 100644
--- a/rdmo/core/assets/js/components/Link.js
+++ b/rdmo/core/assets/js/components/Link.js
@@ -1,14 +1,21 @@
 import React from 'react'
 import PropTypes from 'prop-types'
+import classNames from 'classnames'
 
-const Link = ({ href='', title, className, onClick, children }) => {
+const Link = ({ href='', title, className, disabled=false, onClick, children }) => {
     const handleClick = (event) => {
         event.preventDefault()
-        onClick()
+        if (!disabled) onClick()
     }
 
+    const classnames = classNames({
+        [className]: true,
+        disabled: disabled
+    })
+
     return (
-        <a href={href} title={title} className={className} onClick={event => handleClick(event)}>
+        <a href={href} title={title} className={classnames}
+           onClick={event => handleClick(event)}>
             {children}
         </a>
     )
@@ -18,6 +25,7 @@ Link.propTypes = {
   href: PropTypes.string,
   title: PropTypes.string,
   className: PropTypes.string,
+  disabled: PropTypes.bool,
   onClick: PropTypes.func.isRequired,
   children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node])
 }
diff --git a/rdmo/core/permissions.py b/rdmo/core/permissions.py
index 7e87b8cedf..d86bcb1b44 100644
--- a/rdmo/core/permissions.py
+++ b/rdmo/core/permissions.py
@@ -1,6 +1,5 @@
 import logging
 
-from django.core.exceptions import ObjectDoesNotExist
 from rest_framework.permissions import (DjangoModelPermissions,
                                         DjangoObjectPermissions)
 
@@ -38,6 +37,9 @@ class HasModelPermission(DjangoModelPermissions):
 
     @log_result
     def has_permission(self, request, view):
+        if not (request.user and request.user.is_authenticated):
+            return False
+
         return super().has_permission(request, view)
 
     @log_result
@@ -72,28 +74,4 @@ def has_permission(self, request, view):
             # the permission will be checked on object level (in the next step)
             return True
         else:
-            try:
-                # for list or create we need to check the permission object from the view
-                # and check that the user has the correct permission on this object
-                permission_object = view.get_list_permission_object()
-                return super().has_object_permission(request, view, permission_object)
-            except ObjectDoesNotExist:
-                # return False if the database query fails
-                return False
-            except AttributeError:
-                # return True if the function is not defined in the view
-                # the permission will be checked on object level (in the next step)
-                return True
-
-    @log_result
-    def has_object_permission(self, request, view, obj):
-        # get the permission object from the view
-        try:
-            permission_object = view.get_detail_permission_object(obj)
-            return super().has_object_permission(request, view, permission_object)
-        except ObjectDoesNotExist:
-            # return False if the database query fails
             return False
-        except AttributeError:
-            # just take the input obj if the function is not defined in the view
-            return super().has_object_permission(request, view, obj)
diff --git a/rdmo/core/serializers.py b/rdmo/core/serializers.py
index e1a07cee79..d56859ff55 100644
--- a/rdmo/core/serializers.py
+++ b/rdmo/core/serializers.py
@@ -1,3 +1,5 @@
+import logging
+
 from django.contrib.auth.models import Group
 from django.contrib.sites.models import Site
 from django.db.models import Max
@@ -7,6 +9,7 @@
 
 from rdmo.core.utils import get_language_warning, get_languages, markdown2html
 
+logger = logging.getLogger(__name__)
 
 class RecursiveField(serializers.Serializer):
 
@@ -189,6 +192,38 @@ def get_warning(self, obj):
         return any([get_language_warning(obj, field_name) for field_name in self.Meta.warning_fields])
 
 
+class ReadOnlyObjectPermissionsSerializerMixin:
+    '''
+    A mixin class for Serializers that adds a boolean field with the name read_only.
+    It checks the object permissions based on the model of the serializer.
+    
+    Requires:
+        - the request object in the context kwargs of the Serializer call:
+            ..., context={'request': request}
+        - so that this mixin has self.context['request']
+
+    In the Serializer class add:
+        read_only = serializers.SerializerMethodField(read_only=True)
+    and the field to fields:
+        read_only
+    '''
+    
+    OBJECT_PERMISSION_ACTION_NAMES = ('change', 'delete')
+    
+    @staticmethod
+    def construct_object_permission(model, action_name: str) -> str:
+        model_app_label = model._meta.app_label
+        model_name = model._meta.model_name
+        perm = '%s.%s_%s_object' % (model_app_label, action_name, model_name)
+        return perm
+
+    def get_read_only(self, obj) -> bool:
+        user = self.context['request'].user
+        perms = (self.construct_object_permission(self.Meta.model, action_name)
+                    for action_name in self.OBJECT_PERMISSION_ACTION_NAMES)
+        return not all(user.has_perm(perm, obj) for perm in perms)
+
+
 class SiteSerializer(serializers.ModelSerializer):
 
     class Meta:
diff --git a/rdmo/core/templates/core/base_navigation.html b/rdmo/core/templates/core/base_navigation.html
index 1cb7a2da4a..48fafd5498 100644
--- a/rdmo/core/templates/core/base_navigation.html
+++ b/rdmo/core/templates/core/base_navigation.html
@@ -14,18 +14,14 @@
             </button>
             <a class="navbar-brand" href="{% url 'home' %}">{{ request.site.name }}</a>
         </div>
+
         <div class="collapse navbar-collapse" id="navbar-collapse">
             <ul class="nav navbar-nav">
                 {% back_to_project_link %}
 
-                {% has_perm 'questions.view_catalog' request.user as can_view_catalog %}
-                {% has_perm 'domain.view_attribute' request.user as can_view_attribute %}
-                {% has_perm 'options.view_optionset' request.user as can_view_optionset %}
-                {% has_perm 'conditions.view_condition' request.user as can_view_condition %}
-                {% has_perm 'tasks.view_task' request.user as can_view_task %}
-                {% has_perm 'views.view_view' request.user as can_view_view %}
+                {% test_rule 'can_view_management' request.user as can_view_management %}
 
-                {% if can_view_catalog or can_view_attribute or can_view_optionset or can_view_condition or can_view_task or can_view_view %}
+                {% if user.is_superuser or can_view_management %}
                 <li>
                     <a href="{% url 'management' %}">{% trans 'Management' %}</a>
                 </li>
diff --git a/rdmo/core/views.py b/rdmo/core/views.py
index c42c0934d8..8bd03e92c2 100644
--- a/rdmo/core/views.py
+++ b/rdmo/core/views.py
@@ -4,6 +4,7 @@
 from django.contrib.auth.decorators import login_required
 from django.contrib.auth.mixins import \
     PermissionRequiredMixin as DjangoPermissionRequiredMixin
+from django.contrib.auth.mixins import LoginRequiredMixin
 from django.contrib.auth.views import redirect_to_login
 from django.core.exceptions import PermissionDenied
 from django.http import HttpResponseRedirect
@@ -109,11 +110,10 @@ class PermissionRedirectMixin(object):
     def handle_no_permission(self):
         if self.request.user.is_authenticated:
             raise PermissionDenied(self.get_permission_denied_message())
-
         return redirect_to_login(self.request.get_full_path(), self.get_login_url(), self.get_redirect_field_name())
 
 
-class ModelPermissionMixin(PermissionRedirectMixin, DjangoPermissionRequiredMixin, object):
+class ModelPermissionMixin(LoginRequiredMixin, PermissionRedirectMixin, DjangoPermissionRequiredMixin, object):
     pass
 
 
diff --git a/rdmo/core/viewsets.py b/rdmo/core/viewsets.py
index 842f8413fa..487d4fe370 100644
--- a/rdmo/core/viewsets.py
+++ b/rdmo/core/viewsets.py
@@ -35,7 +35,7 @@ class GroupViewSet(viewsets.ReadOnlyModelViewSet):
 
 class CopyModelMixin:
 
-    @action(detail=True, methods=['PUT'], permission_classes=[HasModelPermission])
+    @action(detail=True, methods=['PUT'])
     def copy(self, request, pk=None):
         # get the instance to be copied
         instance = self.get_object()
diff --git a/rdmo/domain/admin.py b/rdmo/domain/admin.py
index e078a8b8d0..b7abc1552b 100644
--- a/rdmo/domain/admin.py
+++ b/rdmo/domain/admin.py
@@ -26,6 +26,7 @@ class AttributeAdmin(admin.ModelAdmin):
     list_display = ('uri', 'projects_count', 'values_count')
     search_fields = ('uri', )
     readonly_fields = ('uri', 'path', 'projects_count', 'values_count')
+    filter_horizontal = ('editors', )
 
     def get_queryset(self, request):
         return super().get_queryset(request) \
diff --git a/rdmo/domain/imports.py b/rdmo/domain/imports.py
index 62411c238b..83fff81663 100644
--- a/rdmo/domain/imports.py
+++ b/rdmo/domain/imports.py
@@ -1,5 +1,7 @@
 import logging
 
+from django.contrib.sites.models import Site
+
 from rdmo.core.imports import (set_common_fields, set_foreign_field,
                                validate_instance)
 
@@ -34,5 +36,6 @@ def import_attribute(element, save=False):
             logger.debug('Attribute created with uri %s.', element.get('uri'))
 
         attribute.save()
+        attribute.editors.add(Site.objects.get_current())
 
     return attribute
diff --git a/rdmo/domain/migrations/0049_attribute_add_editors.py b/rdmo/domain/migrations/0049_attribute_add_editors.py
new file mode 100644
index 0000000000..aacdb750d0
--- /dev/null
+++ b/rdmo/domain/migrations/0049_attribute_add_editors.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.16 on 2023-02-21 11:11
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('sites', '0002_alter_domain_unique'),
+        ('domain', '0048_meta'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='attribute',
+            name='editors',
+            field=models.ManyToManyField(blank=True, help_text='The sites that can edit this attribute (in a multi site setup).', related_name='attribute_editors', to='sites.Site', verbose_name='Editors'),
+        ),
+    ]
diff --git a/rdmo/domain/models.py b/rdmo/domain/models.py
index cf1e62954a..9b08b2ce5e 100644
--- a/rdmo/domain/models.py
+++ b/rdmo/domain/models.py
@@ -1,4 +1,5 @@
 from django.conf import settings
+from django.contrib.sites.models import Site
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 from mptt.models import MPTTModel, TreeForeignKey
@@ -38,6 +39,11 @@ class Attribute(MPTTModel):
         verbose_name=_('Locked'),
         help_text=_('Designates whether this attribute (and its descendants) can be changed.')
     )
+    editors = models.ManyToManyField(
+        Site, related_name='%(class)s_editors', blank=True,
+        verbose_name=_('Editors'),
+        help_text=_('The sites that can edit this attribute (in a multi site setup).')
+    )
     parent = TreeForeignKey(
         'self', null=True, blank=True,
         on_delete=models.CASCADE, related_name='children', db_index=True,
diff --git a/rdmo/domain/serializers/v1.py b/rdmo/domain/serializers/v1.py
index 85ceadb4c3..cc1769a12e 100644
--- a/rdmo/domain/serializers/v1.py
+++ b/rdmo/domain/serializers/v1.py
@@ -4,7 +4,8 @@
 
 from rdmo.conditions.models import Condition
 from rdmo.core.serializers import (ElementExportSerializerMixin,
-                                   ElementModelSerializerMixin)
+                                   ElementModelSerializerMixin,
+                                   ReadOnlyObjectPermissionsSerializerMixin)
 from rdmo.questions.models import Page, Question, QuestionSet
 
 from ..models import Attribute
@@ -14,9 +15,11 @@
 log = logging.getLogger(__name__)
 
 
-class BaseAttributeSerializer(ElementModelSerializerMixin, serializers.ModelSerializer):
+class BaseAttributeSerializer(ElementModelSerializerMixin, ReadOnlyObjectPermissionsSerializerMixin,
+                              serializers.ModelSerializer):
 
     model = serializers.SerializerMethodField()
+    read_only = serializers.SerializerMethodField(read_only=True)
 
     class Meta:
         model = Attribute
@@ -28,6 +31,8 @@ class Meta:
             'key',
             'comment',
             'locked',
+            'read_only',
+            'editors',
             'parent'
         )
 
@@ -74,7 +79,7 @@ def get_attributes(self, obj):
 class AttributeListSerializer(ElementExportSerializerMixin, BaseAttributeSerializer):
 
     xml_url = serializers.SerializerMethodField()
-
+    
     class Meta(BaseAttributeSerializer.Meta):
         fields = BaseAttributeSerializer.Meta.fields + (
             'xml_url',
diff --git a/rdmo/domain/static/domain/js/domain.js b/rdmo/domain/static/domain/js/domain.js
index 58f56f256c..a3af6a9954 100644
--- a/rdmo/domain/static/domain/js/domain.js
+++ b/rdmo/domain/static/domain/js/domain.js
@@ -11,6 +11,7 @@ angular.module('domain', ['core'])
     var resources = {
         attributes: $resource(baseurl + 'api/v1/domain/attributes/:list_action/:id/:detail_action/'),
         settings: $resource(baseurl + 'api/v1/core/settings/'),
+        sites: $resource(baseurl + 'api/v1/core/sites/'),
     };
 
     /* configure factories */
@@ -30,6 +31,7 @@ angular.module('domain', ['core'])
 
     service.init = function(options) {
         service.settings = resources.settings.get();
+        service.sites = resources.sites.query();
         service.uri_prefixes = []
         service.uri_prefix = ''
         service.filter = sessionStorage.getItem('domain_filter') || '';
diff --git a/rdmo/domain/templates/domain/domain.html b/rdmo/domain/templates/domain/domain.html
index d4cdaea738..1182530240 100644
--- a/rdmo/domain/templates/domain/domain.html
+++ b/rdmo/domain/templates/domain/domain.html
@@ -115,7 +115,9 @@ <h1>{% trans 'Domain' %}</h1>
                 {% include 'domain/domain_options.html' %}
             </div>
 
-            <strong>{% trans 'Attribute' %}</strong>
+            <strong ng-class="{'text-muted': attribute.read_only}">
+                {% trans 'Attribute' %}
+            </strong>
 
             <code>{$ attribute.uri $}</code>
         </div>
diff --git a/rdmo/domain/templates/domain/domain_modal_delete_attributes.html b/rdmo/domain/templates/domain/domain_modal_delete_attributes.html
index daa15898e6..f2a63ece1c 100644
--- a/rdmo/domain/templates/domain/domain_modal_delete_attributes.html
+++ b/rdmo/domain/templates/domain/domain_modal_delete_attributes.html
@@ -64,10 +64,14 @@ <h2 class="modal-title">
                 </div>
 
                 <div class="modal-footer">
+                    <div class="alert alert-warning pull-left" ng-show="service.errors.detail" role="alert">
+                        {$ service.errors.detail $}
+                    </div>
                     <button type="button" class="btn btn-default" data-dismiss="modal">
                         {% trans 'Close' %}
                     </button>
                     <button type="button" class="btn btn-danger"
+                            ng-class="{'disabled': attribute.read_only}"
                             ng-click="service.submitDeleteModal('attributes')">
                         {% trans 'Delete' %}
                     </button>
diff --git a/rdmo/domain/templates/domain/domain_modal_form_attributes.html b/rdmo/domain/templates/domain/domain_modal_form_attributes.html
index c845e5c91c..ee43510e5b 100644
--- a/rdmo/domain/templates/domain/domain_modal_form_attributes.html
+++ b/rdmo/domain/templates/domain/domain_modal_form_attributes.html
@@ -100,13 +100,30 @@ <h2 class="modal-title">
                             data-quicksearch="true">
                         </formgroup>
                     </div>
+                    {% if settings.MULTISITE %}
+                    <div>
+                        <formgroup
+                            data-id="attribute_editors"
+                            data-type="selectmultiple"
+                            data-label="{% trans 'Editors' %}"
+                            data-model="service.values.editors"
+                            data-errors="service.errors.editors"
+                            data-options="service.sites"
+                            data-options-label="name"
+                            data-help="{% trans 'Hold down Control, or Command on a Mac, to select more than one.' %}">
+                        </formgroup>
+                    </div>
+                    {% endif %}
                 </div>
-
                 <div class="modal-footer">
+                    <div class="alert alert-warning pull-left" ng-show="service.errors.detail" role="alert">
+                        {$ service.errors.detail $}
+                    </div>
                     <button type="button" class="btn btn-default" data-dismiss="modal">
                         {% trans 'Close' %}
                     </button>
-                    <button type="button" class="btn btn-primary"
+                    <button type="button" class="btn btn-primary" data-dismiss="alert" 
+                            ng-class="{'disabled': service.values.read_only}"
                             ng-click="service.submitFormModal('attributes')">
                         {% trans 'Save' %}
                     </button>
diff --git a/rdmo/domain/templates/domain/domain_options.html b/rdmo/domain/templates/domain/domain_options.html
index e95110215c..c04be18824 100644
--- a/rdmo/domain/templates/domain/domain_options.html
+++ b/rdmo/domain/templates/domain/domain_options.html
@@ -1,6 +1,9 @@
 {% load i18n %}
 
 <i class="fa fa-lock text-danger" ng-show="attribute.locked"></i>
+<i class="fa fa-ban text-muted" ng-show="attribute.read_only"
+    title="{% trans 'Read only' %}">
+</i>
 
 <a href="" class="fa fa-plus"
     title="{% trans 'Add attribute' %}"
diff --git a/rdmo/domain/viewsets.py b/rdmo/domain/viewsets.py
index 57af153a06..83a4eaeef6 100644
--- a/rdmo/domain/viewsets.py
+++ b/rdmo/domain/viewsets.py
@@ -6,7 +6,7 @@
 from rest_framework.viewsets import ModelViewSet
 
 from rdmo.core.exports import XMLResponse
-from rdmo.core.permissions import HasModelPermission
+from rdmo.core.permissions import HasModelPermission, HasObjectPermission
 from rdmo.core.utils import render_to_csv, render_to_format
 from rdmo.core.viewsets import CopyModelMixin
 
@@ -18,11 +18,12 @@
 
 
 class AttributeViewSet(CopyModelMixin, ModelViewSet):
-    permission_classes = (HasModelPermission, )
+    permission_classes = (HasModelPermission | HasObjectPermission, )
     queryset = Attribute.objects.order_by('path') \
                         .annotate(values_count=models.Count('values')) \
                         .annotate(projects_count=models.Count('values__project', distinct=True)) \
-                        .prefetch_related('conditions', 'questionsets', 'questions', 'tasks_as_start', 'tasks_as_end')
+                        .prefetch_related('conditions', 'pages', 'questionsets', 'questions', 
+                                          'tasks_as_start', 'tasks_as_end', 'editors')
 
     filter_backends = (SearchFilter, DjangoFilterBackend)
     search_fields = ('uri', )
@@ -48,7 +49,7 @@ def nested(self, request, pk):
         serializer = AttributeNestedSerializer(self.get_object(), context={'request': request})
         return Response(serializer.data)
 
-    @action(detail=False, url_path='export(/(?P<export_format>[a-z]+))?', permission_classes=[HasModelPermission])
+    @action(detail=False, url_path='export(/(?P<export_format>[a-z]+))?')
     def export(self, request, export_format='xml'):
         queryset = self.filter_queryset(self.get_queryset())
         if export_format == 'xml':
@@ -64,7 +65,7 @@ def export(self, request, export_format='xml'):
                 'attributes': queryset
             })
 
-    @action(detail=True, url_path='export(/(?P<export_format>[a-z]+))?', permission_classes=[HasModelPermission])
+    @action(detail=True, url_path='export(/(?P<export_format>[a-z]+))?')
     def detail_export(self, request, pk=None, export_format='xml'):
         attributes = self.get_object().get_descendants(include_self=True)
         if export_format == 'xml':
diff --git a/rdmo/management/apps.py b/rdmo/management/apps.py
index b2a1f9ab09..60aed1e190 100644
--- a/rdmo/management/apps.py
+++ b/rdmo/management/apps.py
@@ -5,3 +5,6 @@
 class ManagementConfig(AppConfig):
     name = 'rdmo.management'
     verbose_name = _('Management')
+
+    def ready(self):
+        from . import rules
diff --git a/rdmo/management/assets/js/components/common/Buttons.js b/rdmo/management/assets/js/components/common/Buttons.js
index 9bd6ea682b..9b8afb9530 100644
--- a/rdmo/management/assets/js/components/common/Buttons.js
+++ b/rdmo/management/assets/js/components/common/Buttons.js
@@ -7,7 +7,7 @@ const BackButton = () => (
   </button>
 )
 
-const SaveButton = ({ elementAction, onClick, back }) => {
+const SaveButton = ({ elementAction, onClick, disabled=false, back=false }) => {
   let text, className = 'element-button btn btn-xs'
   if (elementAction == 'create') {
     text = back ? gettext('Create') : gettext('Create and continue editing')
@@ -21,7 +21,7 @@ const SaveButton = ({ elementAction, onClick, back }) => {
   }
 
   return (
-    <button className={className} onClick={() => onClick(back)}>
+    <button className={className} onClick={() => onClick(back)} disabled={disabled}>
       {text}
     </button>
   )
@@ -30,6 +30,7 @@ const SaveButton = ({ elementAction, onClick, back }) => {
 SaveButton.propTypes = {
   elementAction: PropTypes.string,
   onClick: PropTypes.func.isRequired,
+  disabled: PropTypes.bool,
   back: PropTypes.bool
 }
 
@@ -43,14 +44,15 @@ NewButton.propTypes = {
   onClick: PropTypes.func.isRequired
 }
 
-const DeleteButton = ({ onClick }) => (
-  <button className="element-button btn btn-xs btn-danger" onClick={() => onClick()}>
+const DeleteButton = ({ onClick, disabled=false }) => (
+  <button className="element-button btn btn-xs btn-danger" onClick={() => onClick()} disabled={disabled}>
     {gettext('Delete')}
   </button>
 )
 
 DeleteButton.propTypes = {
-  onClick: PropTypes.func.isRequired
+  onClick: PropTypes.func.isRequired,
+  disabled: PropTypes.bool
 }
 
 export { BackButton, SaveButton, NewButton, DeleteButton }
diff --git a/rdmo/management/assets/js/components/common/DragAndDrop.js b/rdmo/management/assets/js/components/common/DragAndDrop.js
index 9abd89c476..5281290b46 100644
--- a/rdmo/management/assets/js/components/common/DragAndDrop.js
+++ b/rdmo/management/assets/js/components/common/DragAndDrop.js
@@ -3,7 +3,7 @@ import { useDrag, useDrop } from 'react-dnd'
 import PropTypes from 'prop-types'
 import classNames from 'classnames'
 
-const Drag = ({ element }) => {
+const Drag = ({ element, show=true }) => {
   const dragRef = useRef(null)
 
   const [{}, drag] = useDrag(() => ({
@@ -13,13 +13,14 @@ const Drag = ({ element }) => {
 
   drag(dragRef)
 
-  return <span className="element-link drag">
+  return show && <span className="element-link drag">
     <i className="fa fa-arrows drag" ref={dragRef}></i>
   </span>
 }
 
 Drag.propTypes = {
-  element: PropTypes.object.isRequired
+  element: PropTypes.object.isRequired,
+  show: PropTypes.bool
 }
 
 const Drop = ({ element, elementActions, indent=0, mode='in', children=null }) => {
diff --git a/rdmo/management/assets/js/components/common/Icons.js b/rdmo/management/assets/js/components/common/Icons.js
new file mode 100644
index 0000000000..6456be6de4
--- /dev/null
+++ b/rdmo/management/assets/js/components/common/Icons.js
@@ -0,0 +1,15 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+
+const ReadOnlyIcon = ({ title, show }) => {
+  return show && (
+    <i className="fa fa-ban" title={title}></i>
+  )
+}
+
+ReadOnlyIcon.propTypes = {
+  title: PropTypes.string.isRequired,
+  show: PropTypes.bool
+}
+
+export { ReadOnlyIcon }
diff --git a/rdmo/management/assets/js/components/common/Links.js b/rdmo/management/assets/js/components/common/Links.js
index 22c33d9ce6..df0c99b0f5 100644
--- a/rdmo/management/assets/js/components/common/Links.js
+++ b/rdmo/management/assets/js/components/common/Links.js
@@ -7,14 +7,15 @@ import isUndefined from 'lodash/isUndefined'
 import Link from 'rdmo/core/assets/js/components/Link'
 import LinkButton from 'rdmo/core/assets/js/components/LinkButton'
 
-const NestedLink = ({ href, title, onClick }) => {
-  return <Link href={href} className="element-link fa fa-align-right flip" title={title} onClick={onClick} />
+const NestedLink = ({ href, title, onClick, show=true }) => {
+  return show && <Link href={href} className="element-link fa fa-align-right flip" title={title} onClick={onClick} />
 }
 
 NestedLink.propTypes = {
   href: PropTypes.string.isRequired,
   title: PropTypes.string.isRequired,
-  onClick: PropTypes.func.isRequired
+  onClick: PropTypes.func.isRequired,
+  show: PropTypes.bool
 }
 
 const EditLink = ({ href, title, onClick }) => {
@@ -27,7 +28,7 @@ EditLink.propTypes = {
   onClick: PropTypes.func.isRequired
 }
 
-const CopyLink = ({ href, title, onClick}) => {
+const CopyLink = ({ href, title, onClick }) => {
   return <Link href={href} className="element-link fa fa-copy" title={title} onClick={onClick} />
 }
 
@@ -37,9 +38,9 @@ CopyLink.propTypes = {
   onClick: PropTypes.func.isRequired
 }
 
-const AddLink = ({ title, altTitle, onClick, onAltClick }) => {
+const AddLink = ({ title, altTitle, onClick, onAltClick, disabled }) => {
   if (isUndefined(onAltClick)) {
-    return <LinkButton className="element-btn-link fa fa-plus" title={title} onClick={onClick} />
+    return <LinkButton className="element-btn-link fa fa-plus" title={title} onClick={onClick} disabled={disabled} />
   } else {
     return (
       <span className="dropdown">
@@ -61,10 +62,11 @@ AddLink.propTypes = {
   title: PropTypes.string.isRequired,
   altTitle: PropTypes.string,
   onClick: PropTypes.func.isRequired,
-  onAltClick: PropTypes.func
+  onAltClick: PropTypes.func,
+  disabled: PropTypes.bool
 }
 
-const AvailableLink = ({ available, locked, title, onClick }) => {
+const AvailableLink = ({ available, locked, title, onClick, disabled }) => {
   const className = classNames({
     'element-btn-link fa': true,
     'fa-toggle-on': available,
@@ -72,30 +74,32 @@ const AvailableLink = ({ available, locked, title, onClick }) => {
   })
 
   return <LinkButton className={className} title={locked ? gettext('Locked') : title}
-                     disabled={locked} onClick={onClick} />
+                     disabled={locked || disabled} onClick={onClick} />
 }
 
 AvailableLink.propTypes = {
   available: PropTypes.bool.isRequired,
   locked: PropTypes.bool.isRequired,
   title: PropTypes.string.isRequired,
-  onClick: PropTypes.func.isRequired
+  onClick: PropTypes.func.isRequired,
+  disabled: PropTypes.bool
 }
 
-const LockedLink = ({ locked, title, onClick }) => {
+const LockedLink = ({ locked, title, onClick, disabled }) => {
   const className = classNames({
     'element-btn-link fa': true,
     'fa-lock text-danger': locked,
     'fa-unlock-alt': !locked
   })
 
-  return <LinkButton className={className} title={title} onClick={onClick} />
+  return <LinkButton className={className} title={title} onClick={onClick} disabled={disabled} />
 }
 
 LockedLink.propTypes = {
   locked: PropTypes.bool.isRequired,
   title: PropTypes.string.isRequired,
-  onClick: PropTypes.func.isRequired
+  onClick: PropTypes.func.isRequired,
+  disabled: PropTypes.bool
 }
 
 const ExportLink = ({ exportUrl, title, exportFormats, csv=false, full=false }) => {
diff --git a/rdmo/management/assets/js/components/edit/EditAttribute.js b/rdmo/management/assets/js/components/edit/EditAttribute.js
index 39893fbbb2..8355caf91c 100644
--- a/rdmo/management/assets/js/components/edit/EditAttribute.js
+++ b/rdmo/management/assets/js/components/edit/EditAttribute.js
@@ -1,5 +1,6 @@
 import React from 'react'
 import PropTypes from 'prop-types'
+import get from 'lodash/get'
 
 import Checkbox from './common/Checkbox'
 import Select from './common/Select'
@@ -8,6 +9,7 @@ import Textarea from './common/Textarea'
 import UriPrefix from './common/UriPrefix'
 
 import { BackButton, SaveButton, DeleteButton } from '../common/Buttons'
+import { ReadOnlyIcon } from '../common/Icons'
 
 import AttributeInfo from '../info/AttributeInfo'
 import DeleteAttributeModal from '../modals/DeleteAttributeModal'
@@ -16,6 +18,7 @@ import useDeleteModal from '../../hooks/useDeleteModal'
 
 const EditAttribute = ({ config, attribute, elements, elementActions }) => {
 
+  const { sites } = config
   const { elementAction, parent, attributes } = elements
 
   const editAttribute = (attribute) => elementActions.fetchElement('attributes', attribute)
@@ -31,9 +34,10 @@ const EditAttribute = ({ config, attribute, elements, elementActions }) => {
     <div className="panel panel-default panel-edit">
       <div className="panel-heading">
         <div className="pull-right">
+          <ReadOnlyIcon title={gettext('This attribute is read only')} show={attribute.read_only} />
           <BackButton />
-          <SaveButton elementAction={elementAction} onClick={storeAttribute} />
-          <SaveButton elementAction={elementAction} onClick={storeAttribute} back={true}/>
+          <SaveButton elementAction={elementAction} onClick={storeAttribute} disabled={attribute.read_only} />
+          <SaveButton elementAction={elementAction} onClick={storeAttribute} disabled={attribute.read_only} back={true}/>
         </div>
         {
           attribute.id ? <>
@@ -106,15 +110,18 @@ const EditAttribute = ({ config, attribute, elements, elementActions }) => {
 
         <Select config={config} element={attribute} field="parent"
                 options={attributes} onChange={updateAttribute} onEdit={editAttribute} />
+
+        {get(config, 'settings.multisite') && <Select config={config} element={attribute} field="editors"
+                                                      options={sites} onChange={updateAttribute} isMulti />}
       </div>
 
       <div className="panel-footer">
         <div className="pull-right">
           <BackButton />
-          <SaveButton elementAction={elementAction} onClick={storeAttribute} />
-          <SaveButton elementAction={elementAction} onClick={storeAttribute} back={true}/>
+          <SaveButton elementAction={elementAction} onClick={storeAttribute} disabled={attribute.read_only} />
+          <SaveButton elementAction={elementAction} onClick={storeAttribute} disabled={attribute.read_only} back={true}/>
         </div>
-        {attribute.id && <DeleteButton onClick={openDeleteModal} />}
+        {attribute.id && <DeleteButton onClick={openDeleteModal} disabled={attribute.read_only} />}
       </div>
 
       <DeleteAttributeModal attribute={attribute} info={info} show={showDeleteModal}
diff --git a/rdmo/management/assets/js/components/edit/EditCatalog.js b/rdmo/management/assets/js/components/edit/EditCatalog.js
index 71b49192a0..e179456943 100644
--- a/rdmo/management/assets/js/components/edit/EditCatalog.js
+++ b/rdmo/management/assets/js/components/edit/EditCatalog.js
@@ -12,6 +12,7 @@ import Textarea from './common/Textarea'
 import UriPrefix from './common/UriPrefix'
 
 import { BackButton, SaveButton, DeleteButton } from '../common/Buttons'
+import { ReadOnlyIcon } from '../common/Icons'
 
 import CatalogInfo from '../info/CatalogInfo'
 import DeleteCatalogModal from '../modals/DeleteCatalogModal'
@@ -38,9 +39,10 @@ const EditCatalog = ({ config, catalog, elements, elementActions }) => {
     <div className="panel panel-default panel-edit">
       <div className="panel-heading">
         <div className="pull-right">
+          <ReadOnlyIcon title={gettext('This catalog is read only')} show={catalog.read_only} />
           <BackButton />
-          <SaveButton elementAction={elementAction} onClick={storeCatalog}  />
-          <SaveButton elementAction={elementAction} onClick={storeCatalog} back={true}/>
+          <SaveButton elementAction={elementAction} onClick={storeCatalog} disabled={catalog.read_only} />
+          <SaveButton elementAction={elementAction} onClick={storeCatalog} disabled={catalog.read_only} back={true}/>
         </div>
         {
           catalog.id ? <>
@@ -102,15 +104,18 @@ const EditCatalog = ({ config, catalog, elements, elementActions }) => {
 
         {get(config, 'settings.multisite') && <Select config={config} element={catalog} field="sites"
                                                       options={sites} onChange={updateCatalog} isMulti />}
+
+        {get(config, 'settings.multisite') && <Select config={config} element={catalog} field="editors"
+                                                      options={sites} onChange={updateCatalog} isMulti />}
       </div>
 
       <div className="panel-footer">
         <div className="pull-right">
           <BackButton />
-          <SaveButton elementAction={elementAction} onClick={storeCatalog} />
-          <SaveButton elementAction={elementAction} onClick={storeCatalog} back={true} />
+          <SaveButton elementAction={elementAction} onClick={storeCatalog} disabled={catalog.read_only} />
+          <SaveButton elementAction={elementAction} onClick={storeCatalog} disabled={catalog.read_only} back={true} />
         </div>
-        {catalog.id && <DeleteButton onClick={openDeleteModal} />}
+        {catalog.id && <DeleteButton onClick={openDeleteModal} disabled={catalog.read_only} />}
       </div>
 
       <DeleteCatalogModal catalog={catalog} info={info} show={showDeleteModal}
diff --git a/rdmo/management/assets/js/components/edit/EditCondition.js b/rdmo/management/assets/js/components/edit/EditCondition.js
index 2603c9e91d..9a609b10e1 100644
--- a/rdmo/management/assets/js/components/edit/EditCondition.js
+++ b/rdmo/management/assets/js/components/edit/EditCondition.js
@@ -1,5 +1,6 @@
 import React from 'react'
 import PropTypes from 'prop-types'
+import get from 'lodash/get'
 
 import Checkbox from './common/Checkbox'
 import Select from './common/Select'
@@ -8,6 +9,7 @@ import Textarea from './common/Textarea'
 import UriPrefix from './common/UriPrefix'
 
 import { BackButton, SaveButton, DeleteButton } from '../common/Buttons'
+import { ReadOnlyIcon } from '../common/Icons'
 
 import ConditionInfo from '../info/ConditionInfo'
 import DeleteConditionModal from '../modals/DeleteConditionModal'
@@ -16,7 +18,7 @@ import useDeleteModal from '../../hooks/useDeleteModal'
 
 const EditCondition = ({ config, condition, elements, elementActions }) => {
 
-  const { relations } = config
+  const { sites, relations } = config
   const { elementAction, parent, attributes, options } = elements
 
   const updateCondition = (key, value) => elementActions.updateElement(condition, {[key]: value})
@@ -36,9 +38,10 @@ const EditCondition = ({ config, condition, elements, elementActions }) => {
     <div className="panel panel-default panel-edit">
       <div className="panel-heading">
         <div className="pull-right">
+          <ReadOnlyIcon title={gettext('This condition is read only')} show={condition.read_only} />
           <BackButton />
-          <SaveButton elementAction={elementAction} onClick={storeCondition} />
-          <SaveButton elementAction={elementAction} onClick={storeCondition} back={true}/>
+          <SaveButton elementAction={elementAction} onClick={storeCondition} disabled={condition.read_only} />
+          <SaveButton elementAction={elementAction} onClick={storeCondition} disabled={condition.read_only} back={true}/>
         </div>
         {
           condition.id ? <>
@@ -118,15 +121,18 @@ const EditCondition = ({ config, condition, elements, elementActions }) => {
 
         <Select config={config} element={condition} field="target_option"
                 options={options} onChange={updateCondition} onEdit={editOption} />
+
+        {get(config, 'settings.multisite') && <Select config={config} element={condition} field="editors"
+                                                      options={sites} onChange={updateCondition} isMulti />}
       </div>
 
       <div className="panel-footer">
         <div className="pull-right">
           <BackButton />
-          <SaveButton elementAction={elementAction} onClick={storeCondition} />
-          <SaveButton elementAction={elementAction} onClick={storeCondition} back={true}/>
+          <SaveButton elementAction={elementAction} onClick={storeCondition} disabled={condition.read_only} />
+          <SaveButton elementAction={elementAction} onClick={storeCondition} disabled={condition.read_only} back={true}/>
         </div>
-        {condition.id && <DeleteButton onClick={openDeleteModal} />}
+        {condition.id && <DeleteButton onClick={openDeleteModal} disabled={condition.read_only} />}
       </div>
 
       <DeleteConditionModal condition={condition} info={info} show={showDeleteModal}
diff --git a/rdmo/management/assets/js/components/edit/EditOption.js b/rdmo/management/assets/js/components/edit/EditOption.js
index caccd2e737..9f011d6fbc 100644
--- a/rdmo/management/assets/js/components/edit/EditOption.js
+++ b/rdmo/management/assets/js/components/edit/EditOption.js
@@ -1,13 +1,16 @@
 import React from 'react'
 import PropTypes from 'prop-types'
 import { Tabs, Tab } from 'react-bootstrap'
+import get from 'lodash/get'
 
 import Checkbox from './common/Checkbox'
+import Select from './common/Select'
 import Text from './common/Text'
 import Textarea from './common/Textarea'
 import UriPrefix from './common/UriPrefix'
 
 import { BackButton, SaveButton, DeleteButton } from '../common/Buttons'
+import { ReadOnlyIcon } from '../common/Icons'
 
 import OptionInfo from '../info/OptionInfo'
 import DeleteOptionModal from '../modals/DeleteOptionModal'
@@ -16,6 +19,7 @@ import useDeleteModal from '../../hooks/useDeleteModal'
 
 const EditOption = ({ config, option, elements, elementActions }) => {
 
+  const { sites } = config
   const { elementAction, parent } = elements
 
   const updateOption = (key, value) => elementActions.updateElement(option, {[key]: value})
@@ -30,9 +34,10 @@ const EditOption = ({ config, option, elements, elementActions }) => {
     <div className="panel panel-default panel-edit">
       <div className="panel-heading">
         <div className="pull-right">
+          <ReadOnlyIcon title={gettext('This option is read only')} show={option.read_only} />
           <BackButton />
-          <SaveButton elementAction={elementAction} onClick={storeOption} />
-          <SaveButton elementAction={elementAction} onClick={storeOption} back={true}/>
+          <SaveButton elementAction={elementAction} onClick={storeOption} disabled={option.read_only} />
+          <SaveButton elementAction={elementAction} onClick={storeOption} disabled={option.read_only} back={true}/>
         </div>
         {
           option.id ? <>
@@ -96,15 +101,18 @@ const EditOption = ({ config, option, elements, elementActions }) => {
             ))
           }
         </Tabs>
+
+        {get(config, 'settings.multisite') && <Select config={config} element={option} field="editors"
+                                                      options={sites} onChange={updateOption} isMulti />}
       </div>
 
       <div className="panel-footer">
         <div className="pull-right">
           <BackButton />
-          <SaveButton elementAction={elementAction} onClick={storeOption} />
-          <SaveButton elementAction={elementAction} onClick={storeOption} back={true}/>
+          <SaveButton elementAction={elementAction} onClick={storeOption} disabled={option.read_only} />
+          <SaveButton elementAction={elementAction} onClick={storeOption} disabled={option.read_only} back={true}/>
         </div>
-        {option.id && <DeleteButton onClick={openDeleteModal} />}
+        {option.id && <DeleteButton onClick={openDeleteModal} disabled={option.read_only} />}
       </div>
 
       <DeleteOptionModal option={option} info={info} show={showDeleteModal}
diff --git a/rdmo/management/assets/js/components/edit/EditOptionSet.js b/rdmo/management/assets/js/components/edit/EditOptionSet.js
index 2e173a0024..4da6f00d1c 100644
--- a/rdmo/management/assets/js/components/edit/EditOptionSet.js
+++ b/rdmo/management/assets/js/components/edit/EditOptionSet.js
@@ -1,5 +1,6 @@
 import React from 'react'
 import PropTypes from 'prop-types'
+import get from 'lodash/get'
 
 import Checkbox from './common/Checkbox'
 import Number from './common/Number'
@@ -11,6 +12,7 @@ import Textarea from './common/Textarea'
 import UriPrefix from './common/UriPrefix'
 
 import { BackButton, SaveButton, DeleteButton } from '../common/Buttons'
+import { ReadOnlyIcon } from '../common/Icons'
 
 import OptionSetInfo from '../info/OptionSetInfo'
 import DeleteOptionSetModal from '../modals/DeleteOptionSetModal'
@@ -19,7 +21,7 @@ import useDeleteModal from '../../hooks/useDeleteModal'
 
 const EditOptionSet = ({ config, optionset, elements, elementActions }) => {
 
-  const { providers } = config
+  const { sites, providers } = config
   const { elementAction, parent, conditions, options } = elements
 
   const updateOptionSet = (key, value) => elementActions.updateElement(optionset, {[key]: value})
@@ -40,9 +42,10 @@ const EditOptionSet = ({ config, optionset, elements, elementActions }) => {
     <div className="panel panel-default panel-edit">
       <div className="panel-heading">
         <div className="pull-right">
+          <ReadOnlyIcon title={gettext('This option set is read only')} show={optionset.read_only} />
           <BackButton />
-          <SaveButton elementAction={elementAction} onClick={storeOptionSet} />
-          <SaveButton elementAction={elementAction} onClick={storeOptionSet} back={true}/>
+          <SaveButton elementAction={elementAction} onClick={storeOptionSet} disabled={optionset.read_only} />
+          <SaveButton elementAction={elementAction} onClick={storeOptionSet} disabled={optionset.read_only} back={true}/>
         </div>
         {
           optionset.id ? <>
@@ -102,15 +105,18 @@ const EditOptionSet = ({ config, optionset, elements, elementActions }) => {
 
         <Select config={config} element={optionset} field="provider_key"
                 options={providers} onChange={updateOptionSet} />
+
+        {get(config, 'settings.multisite') && <Select config={config} element={optionset} field="editors"
+                                                      options={sites} onChange={updateOptionSet} isMulti />}
       </div>
 
       <div className="panel-footer">
         <div className="pull-right">
           <BackButton />
-          <SaveButton elementAction={elementAction} onClick={storeOptionSet} />
-          <SaveButton elementAction={elementAction} onClick={storeOptionSet} back={true}/>
+          <SaveButton elementAction={elementAction} onClick={storeOptionSet} disabled={optionset.read_only} />
+          <SaveButton elementAction={elementAction} onClick={storeOptionSet} disabled={optionset.read_only} back={true}/>
         </div>
-        {optionset.id && <DeleteButton onClick={openDeleteModal} />}
+        {optionset.id && <DeleteButton onClick={openDeleteModal} disabled={optionset.read_only} />}
       </div>
 
       <DeleteOptionSetModal optionset={optionset} info={info} show={showDeleteModal}
diff --git a/rdmo/management/assets/js/components/edit/EditPage.js b/rdmo/management/assets/js/components/edit/EditPage.js
index 068b0eaa62..3593d578b7 100644
--- a/rdmo/management/assets/js/components/edit/EditPage.js
+++ b/rdmo/management/assets/js/components/edit/EditPage.js
@@ -1,6 +1,7 @@
 import React from 'react'
 import PropTypes from 'prop-types'
 import { Tabs, Tab } from 'react-bootstrap'
+import get from 'lodash/get'
 import isUndefined from 'lodash/isUndefined'
 import orderBy from 'lodash/orderBy'
 
@@ -13,6 +14,7 @@ import Textarea from './common/Textarea'
 import UriPrefix from './common/UriPrefix'
 
 import { BackButton, SaveButton, DeleteButton } from '../common/Buttons'
+import { ReadOnlyIcon } from '../common/Icons'
 
 import PageInfo from '../info/PageInfo'
 import DeletePageModal from '../modals/DeletePageModal'
@@ -21,6 +23,7 @@ import useDeleteModal from '../../hooks/useDeleteModal'
 
 const EditPage = ({ config, page, elements, elementActions }) => {
 
+  const { sites } = config
   const { elementAction, parent, attributes, conditions } = elements
 
   const elementValues = orderBy(page.questions.concat(page.questionsets), ['order', 'uri'])
@@ -69,9 +72,10 @@ const EditPage = ({ config, page, elements, elementActions }) => {
     <div className="panel panel-default panel-edit">
       <div className="panel-heading">
         <div className="pull-right">
+          <ReadOnlyIcon title={gettext('This page is read only')} show={page.read_only} />
           <BackButton />
-          <SaveButton elementAction={elementAction} onClick={storePage} />
-          <SaveButton elementAction={elementAction} onClick={storePage} back={true}/>
+          <SaveButton elementAction={elementAction} onClick={storePage} disabled={page.read_only} />
+          <SaveButton elementAction={elementAction} onClick={storePage} disabled={page.read_only} back={true}/>
         </div>
         {
           page.id ? <>
@@ -160,15 +164,18 @@ const EditPage = ({ config, page, elements, elementActions }) => {
         <MultiSelect config={config} element={page} field="conditions"
                      options={conditions} verboseName="condition"
                      onChange={updatePage} onCreate={createCondition} onEdit={editCondition} />
+
+        {get(config, 'settings.multisite') && <Select config={config} element={page} field="editors"
+                                                      options={sites} onChange={updatePage} isMulti />}
       </div>
 
       <div className="panel-footer">
         <div className="pull-right">
           <BackButton />
-          <SaveButton elementAction={elementAction} onClick={storePage} />
-          <SaveButton elementAction={elementAction} onClick={storePage} back={true}/>
+          <SaveButton elementAction={elementAction} onClick={storePage} disabled={page.read_only} />
+          <SaveButton elementAction={elementAction} onClick={storePage} disabled={page.read_only} back={true}/>
         </div>
-        {page.id && <DeleteButton onClick={openDeleteModal} />}
+        {page.id && <DeleteButton onClick={openDeleteModal} disabled={page.read_only} />}
       </div>
 
       <DeletePageModal page={page} info={info} show={showDeleteModal}
diff --git a/rdmo/management/assets/js/components/edit/EditQuestion.js b/rdmo/management/assets/js/components/edit/EditQuestion.js
index 261c8c0c59..70c8be6357 100644
--- a/rdmo/management/assets/js/components/edit/EditQuestion.js
+++ b/rdmo/management/assets/js/components/edit/EditQuestion.js
@@ -1,6 +1,7 @@
 import React from 'react'
 import PropTypes from 'prop-types'
 import { Tabs, Tab } from 'react-bootstrap'
+import get from 'lodash/get'
 
 import Checkbox from './common/Checkbox'
 import MultiSelect from './common/MultiSelect'
@@ -10,6 +11,7 @@ import Textarea from './common/Textarea'
 import UriPrefix from './common/UriPrefix'
 
 import { BackButton, SaveButton, DeleteButton } from '../common/Buttons'
+import { ReadOnlyIcon } from '../common/Icons'
 
 import QuestionInfo from '../info/QuestionInfo'
 import DeleteQuestionModal from '../modals/DeleteQuestionModal'
@@ -18,7 +20,7 @@ import useDeleteModal from '../../hooks/useDeleteModal'
 
 const EditQuestion = ({ config, question, elements, elementActions}) => {
 
-  const { widgetTypes, valueTypes } = config
+  const { sites, widgetTypes, valueTypes } = config
   const { elementAction, parent, attributes, optionsets, options, conditions } = elements
 
   const updateQuestion = (key, value) => elementActions.updateElement(question, {[key]: value})
@@ -42,9 +44,10 @@ const EditQuestion = ({ config, question, elements, elementActions}) => {
     <div className="panel panel-default panel-edit">
       <div className="panel-heading">
         <div className="pull-right">
+          <ReadOnlyIcon title={gettext('This question is read only')} show={question.read_only} />
           <BackButton />
-          <SaveButton elementAction={elementAction} onClick={storeQuestion} />
-          <SaveButton elementAction={elementAction} onClick={storeQuestion} back={true}/>
+          <SaveButton elementAction={elementAction} onClick={storeQuestion} disabled={question.read_only} />
+          <SaveButton elementAction={elementAction} onClick={storeQuestion} disabled={question.read_only} back={true}/>
         </div>
         {
           question.id ? <>
@@ -199,16 +202,23 @@ const EditQuestion = ({ config, question, elements, elementActions}) => {
               </div>
             </div>
           </Tab>
+          {
+            get(config, 'settings.multisite') &&
+            <Tab key={4} eventKey={4} title={gettext('Editors')}>
+              <Select config={config} element={question} field="editors"
+                      options={sites} onChange={updateQuestion} isMulti />
+            </Tab>
+          }
         </Tabs>
       </div>
 
       <div className="panel-footer">
         <div className="pull-right">
           <BackButton />
-          <SaveButton elementAction={elementAction} onClick={storeQuestion} />
-          <SaveButton elementAction={elementAction} onClick={storeQuestion} back={true}/>
+          <SaveButton elementAction={elementAction} onClick={storeQuestion} disabled={question.read_only} />
+          <SaveButton elementAction={elementAction} onClick={storeQuestion} disabled={question.read_only} back={true}/>
         </div>
-        {question.id && <DeleteButton onClick={openDeleteModal} />}
+        {question.id && <DeleteButton onClick={openDeleteModal} disabled={question.read_only} />}
       </div>
 
       <DeleteQuestionModal question={question} info={info} show={showDeleteModal}
diff --git a/rdmo/management/assets/js/components/edit/EditQuestionSet.js b/rdmo/management/assets/js/components/edit/EditQuestionSet.js
index beeb9a5301..aabadc7285 100644
--- a/rdmo/management/assets/js/components/edit/EditQuestionSet.js
+++ b/rdmo/management/assets/js/components/edit/EditQuestionSet.js
@@ -1,6 +1,7 @@
 import React from 'react'
 import PropTypes from 'prop-types'
 import { Tabs, Tab } from 'react-bootstrap'
+import get from 'lodash/get'
 import isUndefined from 'lodash/isUndefined'
 import orderBy from 'lodash/orderBy'
 
@@ -13,6 +14,7 @@ import Textarea from './common/Textarea'
 import UriPrefix from './common/UriPrefix'
 
 import { BackButton, SaveButton, DeleteButton } from '../common/Buttons'
+import { ReadOnlyIcon } from '../common/Icons'
 
 import QuestionSetInfo from '../info/QuestionSetInfo'
 import DeleteQuestionSetModal from '../modals/DeleteQuestionSetModal'
@@ -21,6 +23,7 @@ import useDeleteModal from '../../hooks/useDeleteModal'
 
 const EditQuestionSet = ({ config, questionset, elements, elementActions }) => {
 
+  const { sites } = config
   const { elementAction, parent, attributes, conditions } = elements
 
   const elementValues = orderBy(questionset.questions.concat(questionset.questionsets), ['order', 'uri'])
@@ -69,9 +72,10 @@ const EditQuestionSet = ({ config, questionset, elements, elementActions }) => {
     <div className="panel panel-default panel-edit">
       <div className="panel-heading">
         <div className="pull-right">
+          <ReadOnlyIcon title={gettext('This question set is read only')} show={questionset.read_only} />
           <BackButton />
-          <SaveButton elementAction={elementAction} onClick={storeQuestionSet} />
-          <SaveButton elementAction={elementAction} onClick={storeQuestionSet} back={true}/>
+          <SaveButton elementAction={elementAction} onClick={storeQuestionSet} disabled={questionset.read_only} />
+          <SaveButton elementAction={elementAction} onClick={storeQuestionSet} disabled={questionset.read_only} back={true}/>
         </div>
         {
           questionset.id ? <>
@@ -169,15 +173,18 @@ const EditQuestionSet = ({ config, questionset, elements, elementActions }) => {
         <MultiSelect config={config} element={questionset} field="conditions"
                      options={conditions} verboseName="condition"
                      onChange={updateQuestionSet} onCreate={createCondition} onEdit={editCondition} />
+
+        {get(config, 'settings.multisite') && <Select config={config} element={questionset} field="editors"
+                                                      options={sites} onChange={updateQuestionSet} isMulti />}
       </div>
 
       <div className="panel-footer">
         <div className="pull-right">
           <BackButton />
-          <SaveButton elementAction={elementAction} onClick={storeQuestionSet} />
-          <SaveButton elementAction={elementAction} onClick={storeQuestionSet} back={true}/>
+          <SaveButton elementAction={elementAction} onClick={storeQuestionSet} disabled={questionset.read_only} />
+          <SaveButton elementAction={elementAction} onClick={storeQuestionSet} disabled={questionset.read_only} back={true}/>
         </div>
-        {questionset.id && <DeleteButton onClick={openDeleteModal} />}
+        {questionset.id && <DeleteButton onClick={openDeleteModal} disabled={questionset.read_only} />}
       </div>
 
       <DeleteQuestionSetModal questionset={questionset} info={info} show={showDeleteModal}
diff --git a/rdmo/management/assets/js/components/edit/EditSection.js b/rdmo/management/assets/js/components/edit/EditSection.js
index 83ff8a5273..0cedd4688a 100644
--- a/rdmo/management/assets/js/components/edit/EditSection.js
+++ b/rdmo/management/assets/js/components/edit/EditSection.js
@@ -1,14 +1,17 @@
 import React from 'react'
 import PropTypes from 'prop-types'
 import { Tabs, Tab } from 'react-bootstrap'
+import get from 'lodash/get'
 
 import Checkbox from './common/Checkbox'
 import OrderedMultiSelect from './common/OrderedMultiSelect'
+import Select from './common/Select'
 import Text from './common/Text'
 import Textarea from './common/Textarea'
 import UriPrefix from './common/UriPrefix'
 
 import { BackButton, SaveButton, DeleteButton } from '../common/Buttons'
+import { ReadOnlyIcon } from '../common/Icons'
 
 import SectionInfo from '../info/SectionInfo'
 import DeleteSectionModal from '../modals/DeleteSectionModal'
@@ -17,6 +20,7 @@ import useDeleteModal from '../../hooks/useDeleteModal'
 
 const EditSection = ({ config, section, elements, elementActions }) => {
 
+  const { sites } = config
   const { elementAction, parent, pages } = elements
 
   const updateSection = (key, value) => elementActions.updateElement(section, {[key]: value})
@@ -34,9 +38,10 @@ const EditSection = ({ config, section, elements, elementActions }) => {
     <div className="panel panel-default panel-edit">
       <div className="panel-heading">
         <div className="pull-right">
+          <ReadOnlyIcon title={gettext('This section is read only')} show={section.read_only} />
           <BackButton />
-          <SaveButton elementAction={elementAction} onClick={storeSection} />
-          <SaveButton elementAction={elementAction} onClick={storeSection} back={true}/>
+          <SaveButton elementAction={elementAction} onClick={storeSection} disabled={section.read_only} />
+          <SaveButton elementAction={elementAction} onClick={storeSection} disabled={section.read_only} back={true}/>
         </div>
         {
           section.id ? <>
@@ -92,15 +97,18 @@ const EditSection = ({ config, section, elements, elementActions }) => {
         <OrderedMultiSelect config={config} element={section} field="pages"
                             options={pages} verboseName="page"
                             onChange={updateSection} onCreate={createPage} onEdit={editPage} />
+
+        {get(config, 'settings.multisite') && <Select config={config} element={section} field="editors"
+                                                      options={sites} onChange={updateSection} isMulti />}
       </div>
 
       <div className="panel-footer">
         <div className="pull-right">
           <BackButton />
-          <SaveButton elementAction={elementAction} onClick={storeSection} />
-          <SaveButton elementAction={elementAction} onClick={storeSection} back={true}/>
+          <SaveButton elementAction={elementAction} onClick={storeSection} disabled={section.read_only}  />
+          <SaveButton elementAction={elementAction} onClick={storeSection} disabled={section.read_only} back={true}/>
         </div>
-          {section.id && <DeleteButton onClick={openDeleteModal} />}
+          {section.id && <DeleteButton onClick={openDeleteModal} disabled={section.read_only} />}
       </div>
 
       <DeleteSectionModal section={section} info={info} show={showDeleteModal}
diff --git a/rdmo/management/assets/js/components/edit/EditTask.js b/rdmo/management/assets/js/components/edit/EditTask.js
index 6ff5ab07ce..f4913769f1 100644
--- a/rdmo/management/assets/js/components/edit/EditTask.js
+++ b/rdmo/management/assets/js/components/edit/EditTask.js
@@ -12,6 +12,7 @@ import Textarea from './common/Textarea'
 import UriPrefix from './common/UriPrefix'
 
 import { BackButton, SaveButton, DeleteButton } from '../common/Buttons'
+import { ReadOnlyIcon } from '../common/Icons'
 
 import TaskInfo from '../info/TaskInfo'
 import DeleteTaskModal from '../modals/DeleteTaskModal'
@@ -40,9 +41,10 @@ const EditTask = ({ config, task, elements, elementActions}) => {
     <div className="panel panel-default panel-edit">
       <div className="panel-heading">
         <div className="pull-right">
+          <ReadOnlyIcon title={gettext('This task is read only')} show={task.read_only} />
           <BackButton />
-          <SaveButton elementAction={elementAction} onClick={storeTask} />
-          <SaveButton elementAction={elementAction} onClick={storeTask} back={true}/>
+          <SaveButton elementAction={elementAction} onClick={storeTask} disabled={task.read_only} />
+          <SaveButton elementAction={elementAction} onClick={storeTask} disabled={task.read_only} back={true}/>
         </div>
         {
           task.id ? <>
@@ -126,15 +128,18 @@ const EditTask = ({ config, task, elements, elementActions}) => {
 
         {get(config, 'settings.multisite') && <Select config={config} element={task} field="sites"
                                                       options={sites} onChange={updateTask} isMulti />}
+
+        {get(config, 'settings.multisite') && <Select config={config} element={task} field="editors"
+                                                      options={sites} onChange={updateTask} isMulti />}
       </div>
 
       <div className="panel-footer">
         <div className="pull-right">
           <BackButton />
-          <SaveButton elementAction={elementAction} onClick={storeTask} />
-          <SaveButton elementAction={elementAction} onClick={storeTask} back={true}/>
+          <SaveButton elementAction={elementAction} onClick={storeTask} disabled={task.read_only} />
+          <SaveButton elementAction={elementAction} onClick={storeTask} disabled={task.read_only} back={true}/>
         </div>
-        {task.id && <DeleteButton onClick={openDeleteModal} />}
+        {task.id && <DeleteButton onClick={openDeleteModal} disabled={task.read_only} />}
       </div>
 
       <DeleteTaskModal task={task} info={info} show={showDeleteModal}
diff --git a/rdmo/management/assets/js/components/edit/EditView.js b/rdmo/management/assets/js/components/edit/EditView.js
index bc5a1e987b..62fb9ba3ea 100644
--- a/rdmo/management/assets/js/components/edit/EditView.js
+++ b/rdmo/management/assets/js/components/edit/EditView.js
@@ -11,6 +11,7 @@ import Textarea from './common/Textarea'
 import UriPrefix from './common/UriPrefix'
 
 import { BackButton, SaveButton, DeleteButton } from '../common/Buttons'
+import { ReadOnlyIcon } from '../common/Icons'
 
 import ViewInfo from '../info/ViewInfo'
 import DeleteViewModal from '../modals/DeleteViewModal'
@@ -34,9 +35,10 @@ const EditView = ({ config, view, elements, elementActions }) => {
     <div className="panel panel-default panel-edit">
       <div className="panel-heading">
         <div className="pull-right">
+          <ReadOnlyIcon title={gettext('This view is read only')} show={view.read_only} />
           <BackButton />
-          <SaveButton elementAction={elementAction} onClick={storeView} />
-          <SaveButton elementAction={elementAction} onClick={storeView} back={true}/>
+          <SaveButton elementAction={elementAction} onClick={storeView} disabled={view.read_only} />
+          <SaveButton elementAction={elementAction} onClick={storeView} disabled={view.read_only} back={true}/>
         </div>
         {
           view.id ? <>
@@ -100,6 +102,9 @@ const EditView = ({ config, view, elements, elementActions }) => {
         {get(config, 'settings.multisite') && <Select config={config} element={view} field="sites"
                                                       options={sites} onChange={updateView} isMulti />}
 
+        {get(config, 'settings.multisite') && <Select config={config} element={view} field="editors"
+                                                      options={sites} onChange={updateView} isMulti />}
+
         <CodeMirror config={config} element={view} field="template"
                     onChange={updateView} />
       </div>
@@ -107,10 +112,10 @@ const EditView = ({ config, view, elements, elementActions }) => {
       <div className="panel-footer">
         <div className="pull-right">
           <BackButton />
-          <SaveButton elementAction={elementAction} onClick={storeView} />
-          <SaveButton elementAction={elementAction} onClick={storeView} back={true}/>
+          <SaveButton elementAction={elementAction} onClick={storeView} disabled={view.read_only} />
+          <SaveButton elementAction={elementAction} onClick={storeView} disabled={view.read_only} back={true}/>
         </div>
-        {view.id && <DeleteButton onClick={openDeleteModal} />}
+        {view.id && <DeleteButton onClick={openDeleteModal} disabled={view.read_only} />}
       </div>
 
       <DeleteViewModal view={view} info={info} show={showDeleteModal}
diff --git a/rdmo/management/assets/js/components/edit/common/Checkbox.js b/rdmo/management/assets/js/components/edit/common/Checkbox.js
index c5acd17967..7cb6fee960 100644
--- a/rdmo/management/assets/js/components/edit/common/Checkbox.js
+++ b/rdmo/management/assets/js/components/edit/common/Checkbox.js
@@ -26,7 +26,7 @@ const Checkbox = ({ config, element, field, onChange }) => {
     <div className={className}>
       <div className="checkbox">
           <label>
-              <input id={id} type="checkbox" checked={checked}
+              <input id={id} type="checkbox" checked={checked} disabled={element.read_only}
                      onChange={() => onChange(field, !checked)} />
               <span>{label}</span>
           </label>
diff --git a/rdmo/management/assets/js/components/edit/common/CodeMirror.js b/rdmo/management/assets/js/components/edit/common/CodeMirror.js
index 08f54502cb..9c402b8b27 100644
--- a/rdmo/management/assets/js/components/edit/common/CodeMirror.js
+++ b/rdmo/management/assets/js/components/edit/common/CodeMirror.js
@@ -29,7 +29,7 @@ const CodeMirror = ({ config, element, field, onChange }) => {
       <label className="control-label" htmlFor={id}>{label}</label>
 
       <ReactCodeMirror className="codemirror form-control" id={id} value={value} extensions={[html()]}
-                       onChange={(value) => onChange(field, value)} />
+                       onChange={(value) => onChange(field, value)} disabled={element.read_only} />
 
       {help && <p className="help-block">{help}</p>}
 
diff --git a/rdmo/management/assets/js/components/edit/common/MultiSelect.js b/rdmo/management/assets/js/components/edit/common/MultiSelect.js
index 908240e43f..0feb6a2d38 100644
--- a/rdmo/management/assets/js/components/edit/common/MultiSelect.js
+++ b/rdmo/management/assets/js/components/edit/common/MultiSelect.js
@@ -67,12 +67,13 @@ const MultiSelect = ({ config, element, field, options, verboseName, onChange, o
               {
                 onEdit && <div className="multi-select-item-options">
                   <Link className="fa fa-pencil" title={gettext('Edit')} onClick={() => handleEdit(index)} />
-                  <Link className="fa fa-times" title={gettext('Remove')} onClick={() => handleRemove(index)} />
+                  <Link className="fa fa-times" title={gettext('Remove')} onClick={() => handleRemove(index)}
+                        disabled={element.read_only} />
                 </div>
               }
 
               <ReactSelect classNamePrefix="react-select" className="react-select" styles={styles}
-                           options={selectOptions} value={selectValue}
+                           options={selectOptions} value={selectValue} isDisabled={element.read_only}
                            onChange={option => handleChange(option, index)} />
             </div>
           )
@@ -80,12 +81,12 @@ const MultiSelect = ({ config, element, field, options, verboseName, onChange, o
       }
       </div>
 
-      <button className="btn btn-primary btn-xs" onClick={() => handleAdd()}>
+      <button className="btn btn-primary btn-xs" onClick={() => handleAdd()} disabled={element.read_only}>
         {interpolate(gettext('Add %s'), [verboseName])}
       </button>
 
       {
-        onCreate && <button className="btn btn-success btn-xs ml-10" onClick={onCreate}>
+        onCreate && <button className="btn btn-success btn-xs ml-10" onClick={onCreate} disabled={element.read_only}>
           {interpolate(gettext('Create new %s'), [verboseName])}
         </button>
       }
diff --git a/rdmo/management/assets/js/components/edit/common/Number.js b/rdmo/management/assets/js/components/edit/common/Number.js
index 5a47330764..10488d3ac4 100644
--- a/rdmo/management/assets/js/components/edit/common/Number.js
+++ b/rdmo/management/assets/js/components/edit/common/Number.js
@@ -26,7 +26,7 @@ const Number = ({ config, element, field, onChange }) => {
     <div className={className}>
       <label className="control-label" htmlFor={id}>{label}</label>
 
-      <input className="form-control" id={id} type="number"
+      <input className="form-control" id={id} type="number" disabled={element.read_only}
              value={value} onChange={event => onChange(field, event.target.value)} />
 
       {help && <p className="help-block">{help}</p>}
diff --git a/rdmo/management/assets/js/components/edit/common/OrderedMultiSelect.js b/rdmo/management/assets/js/components/edit/common/OrderedMultiSelect.js
index 504ae806f5..7609663927 100644
--- a/rdmo/management/assets/js/components/edit/common/OrderedMultiSelect.js
+++ b/rdmo/management/assets/js/components/edit/common/OrderedMultiSelect.js
@@ -15,7 +15,7 @@ import Link from 'rdmo/core/assets/js/components/Link'
 
 import { getId, getLabel, getHelp } from 'rdmo/management/assets/js/utils/forms'
 
-const OrderedMultiSelectItem = ({ index, field, selectValue, selectOptions, errors,
+const OrderedMultiSelectItem = ({ index, field, selectValue, selectOptions, errors, disabled,
                                   handleChange, handleEdit, handleRemove, handleDrag }) => {
   const dragRef = useRef(null)
   const dropRef = useRef(null)
@@ -42,8 +42,15 @@ const OrderedMultiSelectItem = ({ index, field, selectValue, selectOptions, erro
     'over': isOver
   })
 
-  drag(dragRef)
-  drop(dropRef)
+  const dragClassName = classNames({
+    'fa fa-arrows drag': true,
+    disabled: disabled
+  })
+
+  if (!disabled) {
+    drag(dragRef)
+    drop(dropRef)
+  }
 
   const styles = {
     container: provided => ({...provided, marginRight: 8 + 12 + 4 + 11 + 4 + 14})
@@ -53,15 +60,17 @@ const OrderedMultiSelectItem = ({ index, field, selectValue, selectOptions, erro
     <>
       <div className="ordered-multi-select-item">
         <div className="ordered-multi-select-item-options">
-          <Link className="fa fa-pencil" title={gettext('Edit')} onClick={() => handleEdit(index)} />
-          <Link className="fa fa-times" title={gettext('Remove')} onClick={() => handleRemove(index)} />
-          <i className="fa fa-arrows drag" ref={dragRef}></i>
+          <Link className="fa fa-pencil" title={gettext('Edit')}
+                onClick={() => handleEdit(index)} />
+          <Link className="fa fa-times" title={gettext('Remove')} disabled={disabled}
+                onClick={() => !disabled && handleRemove(index)} />
+          <i className={dragClassName} ref={dragRef}></i>
         </div>
         <div className="ordered-multi-select-item-select">
           <ReactSelect classNamePrefix="react-select" className="react-select"
                        options={selectOptions} value={selectValue}
                        onChange={option => handleChange(option, index)}
-                       menuPortalTarget={document.body} styles={styles} />
+                       menuPortalTarget={document.body} styles={styles} isDisabled={disabled} />
         </div>
         {
           errors && errors[index] &&
@@ -222,22 +231,25 @@ class OrderedMultiSelect extends Component {
                                       selectValue={selectValue} selectOptions={selectOptions}
                                       errors={errors} handleChange={this.handleChange}
                                       handleEdit={this.handleEdit} handleRemove={this.handleRemove}
-                                      handleDrag={this.handleDrag} />
+                                      handleDrag={this.handleDrag} disabled={element.read_only} />
             )
           })
         }
         </div>
 
-        <button className="btn btn-primary btn-xs" onClick={() => this.handleAdd()}>
+        <button className="btn btn-primary btn-xs" onClick={() => this.handleAdd()}
+                disabled={element.read_only}>
           {interpolate(gettext('Add existing %s'), [verboseName])}
         </button>
         {
-          onCreate && <button className="btn btn-success btn-xs ml-10" onClick={onCreate}>
+          onCreate && <button className="btn btn-success btn-xs ml-10" onClick={onCreate}
+                              disabled={element.read_only}>
             {interpolate(gettext('Create new %s'), [verboseNameCreate || verboseName])}
           </button>
         }
         {
-          onAltCreate && <button className="btn btn-success btn-xs ml-10" onClick={onAltCreate}>
+          onAltCreate && <button className="btn btn-success btn-xs ml-10" onClick={onAltCreate}
+                                 disabled={element.read_only}>
             {interpolate(gettext('Create new %s'), [verboseNameAltCreate || verboseName])}
           </button>
         }
@@ -254,6 +266,7 @@ OrderedMultiSelectItem.propTypes = {
   selectValue: PropTypes.object,
   selectOptions: PropTypes.array,
   errors: PropTypes.object,
+  disabled: PropTypes.bool,
   handleChange: PropTypes.func,
   handleEdit: PropTypes.func,
   handleRemove: PropTypes.func,
diff --git a/rdmo/management/assets/js/components/edit/common/Select.js b/rdmo/management/assets/js/components/edit/common/Select.js
index 9fa916ae73..8998798047 100644
--- a/rdmo/management/assets/js/components/edit/common/Select.js
+++ b/rdmo/management/assets/js/components/edit/common/Select.js
@@ -60,7 +60,7 @@ const Select = ({ config, element, field, options, verboseName, isMulti, onChang
 
         <ReactSelect classNamePrefix="react-select" className="react-select" isClearable={true}
                      options={selectOptions} value={selectValue} isMulti={isMulti}
-                     onChange={handleChange} styles={styles} />
+                     onChange={handleChange} styles={styles} isDisabled={element.read_only} />
       </div>
 
       {
diff --git a/rdmo/management/assets/js/components/edit/common/Text.js b/rdmo/management/assets/js/components/edit/common/Text.js
index 20f20fd6d1..c249bd11fb 100644
--- a/rdmo/management/assets/js/components/edit/common/Text.js
+++ b/rdmo/management/assets/js/components/edit/common/Text.js
@@ -26,7 +26,7 @@ const Text = ({ config, element, field, onChange }) => {
     <div className={className}>
       <label className="control-label" htmlFor={id}>{label}</label>
 
-      <input className="form-control" id={id} type="text"
+      <input className="form-control" id={id} type="text" disabled={element.read_only}
              value={value} onChange={event => onChange(field, event.target.value)} />
 
       {help && <p className="help-block">{help}</p>}
diff --git a/rdmo/management/assets/js/components/edit/common/Textarea.js b/rdmo/management/assets/js/components/edit/common/Textarea.js
index 4690a2260d..b6530f2c74 100644
--- a/rdmo/management/assets/js/components/edit/common/Textarea.js
+++ b/rdmo/management/assets/js/components/edit/common/Textarea.js
@@ -26,7 +26,7 @@ const Textarea = ({ config, element, field, rows, onChange }) => {
     <div className={className}>
       <label className="control-label" htmlFor={id}>{label}</label>
 
-      <textarea className="form-control" id={id} rows={rows} type="text"
+      <textarea className="form-control" id={id} rows={rows} type="text" disabled={element.read_only}
                 value={value} onChange={event => onChange(field, event.target.value)}  />
 
       {help && <p className="help-block">{help}</p>}
diff --git a/rdmo/management/assets/js/components/edit/common/UriPrefix.js b/rdmo/management/assets/js/components/edit/common/UriPrefix.js
index 31fd0eb1ae..3f495df881 100644
--- a/rdmo/management/assets/js/components/edit/common/UriPrefix.js
+++ b/rdmo/management/assets/js/components/edit/common/UriPrefix.js
@@ -27,11 +27,11 @@ const UriPrefix = ({ config, element, field, onChange }) => {
       <label className="control-label" htmlFor={id}>{label}</label>
 
       <div className="input-group">
-        <input className="form-control" id={id} type="text"
+        <input className="form-control" id={id} type="text" disabled={element.read_only}
                value={value} onChange={event => onChange(field, event.target.value)} />
 
         <span className="input-group-btn">
-          <button type="button" className="btn btn-default"
+          <button type="button" className="btn btn-default" disabled={element.read_only}
             title={gettext('Insert default URI Prefix')}
             onClick={() => onChange(field, config.settings.default_uri_prefix)}>
             <span className="fa fa-magic"></span>
diff --git a/rdmo/management/assets/js/components/element/Attribute.js b/rdmo/management/assets/js/components/element/Attribute.js
index a79f0cbe4b..1acd40bc57 100644
--- a/rdmo/management/assets/js/components/element/Attribute.js
+++ b/rdmo/management/assets/js/components/element/Attribute.js
@@ -6,6 +6,7 @@ import { buildPath } from '../../utils/location'
 
 import { ElementErrors } from '../common/Errors'
 import { EditLink, CopyLink, AddLink, LockedLink, NestedLink, ExportLink, CodeLink } from '../common/Links'
+import { ReadOnlyIcon } from '../common/Icons'
 
 const Attribute = ({ config, attribute, elementActions, display='list', filter=null, indent=0 }) => {
 
@@ -26,15 +27,14 @@ const Attribute = ({ config, attribute, elementActions, display='list', filter=n
   const elementNode = (
     <div className="element">
       <div className="pull-right">
-        {
-          !attribute.is_leaf_node &&
-          <NestedLink title={gettext('View attribute nested')} href={nestedUrl} onClick={fetchNested} />
-        }
+        <ReadOnlyIcon title={gettext('This attribute is read only')} show={attribute.read_only} />
+        <NestedLink title={gettext('View attribute nested')} href={nestedUrl} onClick={fetchNested} 
+                    show={!attribute.is_leaf_node} />
         <EditLink title={gettext('Edit attribute')} href={editUrl} onClick={fetchEdit} />
         <CopyLink title={gettext('Copy attribute')} href={copyUrl} onClick={fetchCopy} />
-        <AddLink title={gettext('Add attribute')} onClick={createAttribute} />
+        <AddLink title={gettext('Add attribute')} onClick={createAttribute} disabled={attribute.read_only} />
         <LockedLink title={attribute.locked ? gettext('Unlock attribute') : gettext('Lock attribute')}
-                    locked={attribute.locked} onClick={toggleLocked} />
+                    locked={attribute.locked} onClick={toggleLocked} disabled={attribute.read_only} />
         <ExportLink title={gettext('Export attribute')} exportUrl={exportUrl}
                     exportFormats={config.settings.export_formats} csv={true} />
       </div>
diff --git a/rdmo/management/assets/js/components/element/Catalog.js b/rdmo/management/assets/js/components/element/Catalog.js
index 08571f3da2..e6ae177006 100644
--- a/rdmo/management/assets/js/components/element/Catalog.js
+++ b/rdmo/management/assets/js/components/element/Catalog.js
@@ -7,6 +7,7 @@ import { buildPath } from '../../utils/location'
 import { ElementErrors } from '../common/Errors'
 import { EditLink, CopyLink, AddLink, AvailableLink, LockedLink, NestedLink,
          ExportLink, CodeLink } from '../common/Links'
+import { ReadOnlyIcon } from '../common/Icons'
 
 const Catalog = ({ config, catalog, elementActions, display='list', filter=null }) => {
 
@@ -29,15 +30,17 @@ const Catalog = ({ config, catalog, elementActions, display='list', filter=null
   const elementNode = (
     <div className="element">
       <div className="pull-right">
+        <ReadOnlyIcon title={gettext('This catalog is read only')} show={catalog.read_only} />
         <NestedLink title={gettext('View catalog nested')} href={nestedUrl} onClick={fetchNested} />
         <EditLink title={gettext('Edit catalog')} href={editUrl} onClick={fetchEdit} />
         <CopyLink title={gettext('Copy catalog')} href={copyUrl} onClick={fetchCopy} />
-        <AddLink title={gettext('Add section')} onClick={createSection} />
+        <AddLink title={gettext('Add section')} onClick={createSection} disabled={catalog.read_only} />
         <AvailableLink title={catalog.available ? gettext('Make catalog unavailable')
                                                 : gettext('Make catalog available')}
-                       available={catalog.available} locked={catalog.locked} onClick={toggleAvailable} />
+                       available={catalog.available} locked={catalog.locked} onClick={toggleAvailable}
+                       disabled={catalog.read_only} />
         <LockedLink title={catalog.locked ? gettext('Unlock catalog') : gettext('Lock catalog')}
-                    locked={catalog.locked} onClick={toggleLocked} />
+                    locked={catalog.locked} onClick={toggleLocked} disabled={catalog.read_only} />
         <ExportLink title={gettext('Export catalog')} exportUrl={exportUrl}
                     exportFormats={config.settings.export_formats} full={true} />
       </div>
diff --git a/rdmo/management/assets/js/components/element/Condition.js b/rdmo/management/assets/js/components/element/Condition.js
index fcc722226c..2a7c6dfc22 100644
--- a/rdmo/management/assets/js/components/element/Condition.js
+++ b/rdmo/management/assets/js/components/element/Condition.js
@@ -6,6 +6,7 @@ import { buildPath } from '../../utils/location'
 
 import { ElementErrors } from '../common/Errors'
 import { EditLink, CopyLink, LockedLink, ExportLink, CodeLink } from '../common/Links'
+import { ReadOnlyIcon } from '../common/Icons'
 
 const Condition = ({ config, condition, elementActions, filter=null }) => {
 
@@ -23,10 +24,11 @@ const Condition = ({ config, condition, elementActions, filter=null }) => {
     <li className="list-group-item">
       <div className="element">
         <div className="pull-right">
+          <ReadOnlyIcon title={gettext('This condition is read only')} show={condition.read_only} />
           <EditLink title={gettext('Edit condition')} href={editUrl} onClick={fetchEdit} />
           <CopyLink title={gettext('Copy condition')} href={copyUrl} onClick={fetchCopy} />
           <LockedLink title={condition.locked ? gettext('Unlock condition') : gettext('Lock condition')}
-                      locked={condition.locked} onClick={toggleLocked} />
+                      locked={condition.locked} onClick={toggleLocked} disabled={condition.read_only} />
           <ExportLink title={gettext('Export condition')} exportUrl={exportUrl}
                       exportFormats={config.settings.export_formats} full={true} />
         </div>
diff --git a/rdmo/management/assets/js/components/element/Option.js b/rdmo/management/assets/js/components/element/Option.js
index aba8174c3c..2fe2355388 100644
--- a/rdmo/management/assets/js/components/element/Option.js
+++ b/rdmo/management/assets/js/components/element/Option.js
@@ -6,6 +6,7 @@ import { buildPath } from '../../utils/location'
 
 import { ElementErrors } from '../common/Errors'
 import { EditLink, CopyLink, LockedLink, ExportLink, CodeLink } from '../common/Links'
+import { ReadOnlyIcon } from '../common/Icons'
 
 const Option = ({ config, option, elementActions, display='list', indent=0, filter=null }) => {
 
@@ -22,10 +23,11 @@ const Option = ({ config, option, elementActions, display='list', indent=0, filt
   const elementNode = (
     <div className="element">
       <div className="pull-right">
+        <ReadOnlyIcon title={gettext('This option is read only')} show={option.read_only} />
         <EditLink title={gettext('Edit option')} href={editUrl} onClick={fetchEdit} />
         <CopyLink title={gettext('Copy option')} href={copyUrl} onClick={fetchCopy} />
         <LockedLink title={option.locked ? gettext('Unlock option') : gettext('Lock option')}
-                    locked={option.locked} onClick={toggleLocked} />
+                    locked={option.locked} onClick={toggleLocked} disabled={option.read_only} />
         <ExportLink title={gettext('Export option')} exportUrl={exportUrl}
                     exportFormats={config.settings.export_formats} />
       </div>
diff --git a/rdmo/management/assets/js/components/element/OptionSet.js b/rdmo/management/assets/js/components/element/OptionSet.js
index 1652babab1..fa43465cc0 100644
--- a/rdmo/management/assets/js/components/element/OptionSet.js
+++ b/rdmo/management/assets/js/components/element/OptionSet.js
@@ -7,6 +7,7 @@ import { buildPath } from '../../utils/location'
 import { ElementErrors } from '../common/Errors'
 import { EditLink, CopyLink, AddLink, LockedLink, NestedLink,
          ExportLink, CodeLink } from '../common/Links'
+import { ReadOnlyIcon } from '../common/Icons'
 
 const OptionSet = ({ config, optionset, elementActions, display='list', filter=null }) => {
 
@@ -27,12 +28,13 @@ const OptionSet = ({ config, optionset, elementActions, display='list', filter=n
   const elementNode = (
     <div className="element">
       <div className="pull-right">
+        <ReadOnlyIcon title={gettext('This option set is read only')} show={optionset.read_only} />
         <NestedLink title={gettext('View option set nested')} href={nestedUrl} onClick={fetchNested} />
         <EditLink title={gettext('Edit option set')} href={editUrl} onClick={fetchEdit} />
         <CopyLink title={gettext('Copy option set')} href={copyUrl} onClick={fetchCopy} />
-        <AddLink title={gettext('Add option')} onClick={createOption} />
+        <AddLink title={gettext('Add option')} onClick={createOption} disabled={optionset.read_only} />
         <LockedLink title={optionset.locked ? gettext('Unlock option set') : gettext('Lock option set')}
-                    locked={optionset.locked} onClick={toggleLocked} />
+                    locked={optionset.locked} onClick={toggleLocked} disabled={optionset.read_only} />
         <ExportLink title={gettext('Export option set')} exportUrl={exportUrl}
                     exportFormats={config.settings.export_formats} full={true} />
       </div>
diff --git a/rdmo/management/assets/js/components/element/Page.js b/rdmo/management/assets/js/components/element/Page.js
index 6d67bc4842..15b2f264e6 100644
--- a/rdmo/management/assets/js/components/element/Page.js
+++ b/rdmo/management/assets/js/components/element/Page.js
@@ -9,6 +9,7 @@ import Question from './Question'
 import { ElementErrors } from '../common/Errors'
 import { EditLink, CopyLink, AddLink, LockedLink, NestedLink,
          ExportLink, CodeLink } from '../common/Links'
+import { ReadOnlyIcon } from '../common/Icons'
 import { Drag, Drop } from '../common/DragAndDrop'
 
 const Page = ({ config, page, elementActions, display='list', filter=null, indent=0 }) => {
@@ -34,16 +35,17 @@ const Page = ({ config, page, elementActions, display='list', filter=null, inden
   const elementNode = (
     <div className="element">
       <div className="pull-right">
+        <ReadOnlyIcon title={gettext('This page is read only')} show={page.read_only} />
         <NestedLink title={gettext('View page nested')} href={nestedUrl} onClick={fetchNested} />
         <EditLink title={gettext('Edit page')} href={editUrl} onClick={fetchEdit} />
         <CopyLink title={gettext('Copy page')} href={copyUrl} onClick={fetchCopy} />
         <AddLink title={gettext('Add question')} altTitle={gettext('Add question set')}
-                 onClick={createQuestion} onAltClick={createQuestionSet} />
+                 onClick={createQuestion} onAltClick={createQuestionSet} disabled={page.read_only} />
         <LockedLink title={page.locked ? gettext('Unlock page') : gettext('Lock page')}
-                    locked={page.locked} onClick={toggleLocked} />
+                    locked={page.locked} onClick={toggleLocked} disabled={page.read_only} />
         <ExportLink title={gettext('Export page')} exportUrl={exportUrl}
                     exportFormats={config.settings.export_formats} full={true} />
-        {display == 'nested' && <Drag element={page} />}
+        <Drag element={page} show={display == 'nested'} />
       </div>
       <div>
         <p>
diff --git a/rdmo/management/assets/js/components/element/Question.js b/rdmo/management/assets/js/components/element/Question.js
index f42938fb01..e80cc9ceff 100644
--- a/rdmo/management/assets/js/components/element/Question.js
+++ b/rdmo/management/assets/js/components/element/Question.js
@@ -6,6 +6,7 @@ import { buildPath } from '../../utils/location'
 
 import { ElementErrors } from '../common/Errors'
 import { EditLink, CopyLink, LockedLink, ExportLink, CodeLink } from '../common/Links'
+import { ReadOnlyIcon } from '../common/Icons'
 import { Drag, Drop } from '../common/DragAndDrop'
 
 const Question = ({ config, question, elementActions, display='list', filter=null, indent=0 }) => {
@@ -27,13 +28,14 @@ const Question = ({ config, question, elementActions, display='list', filter=nul
   const elementNode = (
     <div className="element">
       <div className="pull-right">
+        <ReadOnlyIcon title={gettext('This question is read only')} show={question.read_only} />
         <EditLink title={gettext('Edit question')} href={editUrl} onClick={fetchEdit} />
         <CopyLink title={gettext('Copy question')} href={copyUrl} onClick={fetchCopy} />
         <LockedLink title={question.locked ? gettext('Unlock question') : gettext('Lock question')}
-                    locked={question.locked} onClick={toggleLocked} />
+                    locked={question.locked} onClick={toggleLocked} disabled={question.read_only} />
         <ExportLink title={gettext('Export question')} exportUrl={exportUrl}
                     exportFormats={config.settings.export_formats} full={true} />
-        {display == 'nested' && <Drag element={question} />}
+        <Drag element={question} show={display == 'nested'} />
       </div>
       <div>
         <p>
diff --git a/rdmo/management/assets/js/components/element/QuestionSet.js b/rdmo/management/assets/js/components/element/QuestionSet.js
index 56d5a799b2..980855ccb1 100644
--- a/rdmo/management/assets/js/components/element/QuestionSet.js
+++ b/rdmo/management/assets/js/components/element/QuestionSet.js
@@ -8,6 +8,7 @@ import Question from './Question'
 import { ElementErrors } from '../common/Errors'
 import { EditLink, CopyLink, AddLink, LockedLink,
          NestedLink, ExportLink, CodeLink } from '../common/Links'
+import { ReadOnlyIcon } from '../common/Icons'
 import { Drag, Drop } from '../common/DragAndDrop'
 
 const QuestionSet = ({ config, questionset, elementActions, display='list', filter=null, indent=0 }) => {
@@ -33,16 +34,17 @@ const QuestionSet = ({ config, questionset, elementActions, display='list', filt
   const elementNode = (
     <div className="element">
       <div className="pull-right">
+        <ReadOnlyIcon title={gettext('This question set is read only')} show={questionset.read_only} />
         <NestedLink title={gettext('View question set nested')} href={nestedUrl} onClick={fetchNested} />
         <EditLink title={gettext('Edit question set')} href={editUrl} onClick={fetchEdit} />
         <CopyLink title={gettext('Copy question set')} href={copyUrl} onClick={fetchCopy} />
         <AddLink title={gettext('Add question')} altTitle={gettext('Add question set')}
-                 onClick={createQuestion} onAltClick={createQuestionSet} />
+                 onClick={createQuestion} onAltClick={createQuestionSet} disabled={questionset.read_only} />
         <LockedLink title={questionset.locked ? gettext('Unlock question set') : gettext('Lock question set')}
-                    locked={questionset.locked} onClick={toggleLocked} />
+                    locked={questionset.locked} onClick={toggleLocked} disabled={questionset.read_only} />
         <ExportLink title={gettext('Export question set')} exportUrl={exportUrl}
                     exportFormats={config.settings.export_formats} full={true} />
-        {display == 'nested' && <Drag element={questionset} />}
+        <Drag element={questionset} show={display == 'nested'} />
       </div>
       <div>
         <p>
diff --git a/rdmo/management/assets/js/components/element/Section.js b/rdmo/management/assets/js/components/element/Section.js
index 4cc935331c..27a1671148 100644
--- a/rdmo/management/assets/js/components/element/Section.js
+++ b/rdmo/management/assets/js/components/element/Section.js
@@ -8,6 +8,7 @@ import { buildPath } from '../../utils/location'
 import Page from './Page'
 import { ElementErrors } from '../common/Errors'
 import { EditLink, CopyLink, AddLink, LockedLink, NestedLink, ExportLink, CodeLink } from '../common/Links'
+import { ReadOnlyIcon } from '../common/Icons'
 import { Drag, Drop } from '../common/DragAndDrop'
 
 const Section = ({ config, section, elementActions, display='list', filter=null, indent=0 }) => {
@@ -29,16 +30,17 @@ const Section = ({ config, section, elementActions, display='list', filter=null,
   const elementNode = (
     <div className="element">
       <div className="pull-right">
+        <ReadOnlyIcon title={gettext('This section is read only')} show={section.read_only} />
         <NestedLink title={gettext('View section nested')} href={nestedUrl} onClick={fetchNested} />
         <EditLink title={gettext('Edit section')} href={editUrl} onClick={fetchEdit} />
         <CopyLink title={gettext('Copy section')} href={copyUrl} onClick={fetchCopy} />
-        <AddLink title={gettext('Add page')} onClick={createPage} />
+        <AddLink title={gettext('Add page')} onClick={createPage} disabled={section.read_only} />
         <LockedLink title={section.locked ? gettext('Unlock section')
                                           : gettext('Lock section')}
-                    locked={section.locked} onClick={toggleLocked} />
+                    locked={section.locked} onClick={toggleLocked} disabled={section.read_only} />
         <ExportLink title={gettext('Export section')} exportUrl={exportUrl}
                     exportFormats={config.settings.export_formats} full={true} />
-        {display == 'nested' && <Drag element={section} />}
+        <Drag element={section} show={display == 'nested'} />
       </div>
       <div>
         <p>
diff --git a/rdmo/management/assets/js/components/element/Task.js b/rdmo/management/assets/js/components/element/Task.js
index 0073ae27d7..2095fbf5f4 100644
--- a/rdmo/management/assets/js/components/element/Task.js
+++ b/rdmo/management/assets/js/components/element/Task.js
@@ -6,6 +6,7 @@ import { buildPath } from '../../utils/location'
 
 import { ElementErrors } from '../common/Errors'
 import { EditLink, CopyLink, AvailableLink, LockedLink, ExportLink, CodeLink } from '../common/Links'
+import { ReadOnlyIcon } from '../common/Icons'
 
 const Task = ({ config, task, elementActions, filter=null }) => {
 
@@ -24,13 +25,15 @@ const Task = ({ config, task, elementActions, filter=null }) => {
     <li className="list-group-item">
       <div className="element">
         <div className="pull-right">
+          <ReadOnlyIcon title={gettext('This task is read only')} show={task.read_only} />
           <EditLink title={gettext('Edit task')} href={editUrl} onClick={fetchEdit} />
           <CopyLink title={gettext('Copy task')} href={copyUrl} onClick={fetchCopy} />
           <AvailableLink title={task.available ? gettext('Make task unavailable')
                                                : gettext('Make task available')}
-                         available={task.available} locked={task.locked} onClick={toggleAvailable} />
+                         available={task.available} locked={task.locked} onClick={toggleAvailable}
+                         disabled={task.read_only} />
           <LockedLink title={task.locked ? gettext('Unlock task') : gettext('Lock task')}
-                      locked={task.locked} onClick={toggleLocked} />
+                      locked={task.locked} onClick={toggleLocked} disabled={task.read_only} />
           <ExportLink title={gettext('Export task')} exportUrl={exportUrl}
                       exportFormats={config.settings.export_formats} full={true} />
         </div>
diff --git a/rdmo/management/assets/js/components/element/View.js b/rdmo/management/assets/js/components/element/View.js
index cf0689c935..5bec6647bc 100644
--- a/rdmo/management/assets/js/components/element/View.js
+++ b/rdmo/management/assets/js/components/element/View.js
@@ -6,6 +6,7 @@ import { buildPath } from '../../utils/location'
 
 import { ElementErrors } from '../common/Errors'
 import { EditLink, CopyLink, AvailableLink, LockedLink, ExportLink, CodeLink } from '../common/Links'
+import { ReadOnlyIcon } from '../common/Icons'
 
 const View = ({ config, view, elementActions, filter=null }) => {
 
@@ -24,13 +25,15 @@ const View = ({ config, view, elementActions, filter=null }) => {
     <li className="list-group-item">
       <div className="element">
         <div className="pull-right">
+          <ReadOnlyIcon title={gettext('This view is read only')} show={view.read_only} />
           <EditLink title={gettext('Edit view')} href={editUrl} onClick={fetchEdit} />
           <CopyLink title={gettext('Copy view')} href={copyUrl} onClick={fetchCopy} />
           <AvailableLink title={view.available ? gettext('Make view unavailable')
                                                : gettext('Make view available')}
-                         available={view.available} locked={view.locked} onClick={toggleAvailable} />
+                         available={view.available} locked={view.locked} onClick={toggleAvailable}
+                         disabled={view.read_only} />
           <LockedLink title={view.locked ? gettext('Unlock view') : gettext('Lock view')}
-                      locked={view.locked} onClick={toggleLocked} />
+                      locked={view.locked} onClick={toggleLocked} disabled={view.read_only} />
           <ExportLink title={gettext('Export view')} exportUrl={exportUrl}
                       exportFormats={config.settings.export_formats} />
         </div>
diff --git a/rdmo/management/assets/js/containers/Main.js b/rdmo/management/assets/js/containers/Main.js
index a1297639b0..93126c4c44 100644
--- a/rdmo/management/assets/js/containers/Main.js
+++ b/rdmo/management/assets/js/containers/Main.js
@@ -2,6 +2,7 @@ import React from 'react'
 import PropTypes from 'prop-types'
 import { bindActionCreators } from 'redux'
 import { connect } from 'react-redux'
+import get from 'lodash/get'
 import isEmpty from 'lodash/isEmpty'
 import isNil from 'lodash/isNil'
 
@@ -27,9 +28,9 @@ const Main = ({ config, elements, imports, configActions, elementActions, import
   // check if an an error occured
   if (!isNil(elements.errors.api)) {
     return <MainErrors errors={elements.errors.api} />
-  }
-
-  if (!isNil(imports.errors.file)) {
+  } else if (get(elements, 'element.errors.api')) {
+    return <MainErrors errors={get(elements, 'element.errors.api')} />
+  } else if (!isNil(imports.errors.file)) {
     return <MainErrors errors={imports.errors.file} />
   }
 
diff --git a/rdmo/management/assets/js/reducers/elementsReducer.js b/rdmo/management/assets/js/reducers/elementsReducer.js
index 43705b3c1a..d30ecdb328 100644
--- a/rdmo/management/assets/js/reducers/elementsReducer.js
+++ b/rdmo/management/assets/js/reducers/elementsReducer.js
@@ -52,6 +52,7 @@ export default function elementsReducer(state = initialState, action) {
       // remove the id when copying
       if (state.elementAction == 'copy') {
         action.elements.element.id = null
+        action.elements.element.read_only = false
       }
 
       return {...state, ...action.elements}
diff --git a/rdmo/management/assets/scss/management.scss b/rdmo/management/assets/scss/management.scss
index a1383f36c1..be8787c4ea 100644
--- a/rdmo/management/assets/scss/management.scss
+++ b/rdmo/management/assets/scss/management.scss
@@ -162,19 +162,23 @@ a.disabled {
         }
     }
     .pull-right {
-        .element-link {
+        .element-link,
+        .element-btn-link,
+        .element-button {
             margin-left: 4px;
+
+            &.disabled,
+            &:disabled {
+                color: #ccc !important;
+                cursor: not-allowed !important;
+            }
         }
         .element-btn-link {
-            margin-left: 4px;
-
             padding: 0;
             border: 0;
             text-decoration: none;
         }
         .element-button {
-            margin-left: 4px;
-
             &:first-child {
                 margin-left: 8px;
             }
@@ -273,6 +277,11 @@ a.disabled {
     display: flex;
     gap: 4px;
     padding: 10px 0;
+
+    .disabled {
+        color: #ccc !important;
+        cursor: not-allowed !important;
+    }
 }
 
 .ordered-multi-select-item {
diff --git a/rdmo/management/rules.py b/rdmo/management/rules.py
new file mode 100644
index 0000000000..f48bb0380d
--- /dev/null
+++ b/rdmo/management/rules.py
@@ -0,0 +1,186 @@
+import logging
+
+from django.contrib.sites.models import Site
+
+import rules
+from rules.predicates import is_authenticated
+
+logger = logging.getLogger(__name__)
+
+
+@rules.predicate
+def is_editor(user) -> bool:
+    ''' Checks if any editor role exists for the user '''
+    return user.role.editor.exists()
+
+
+@rules.predicate
+def is_multisite_editor(user) -> bool:
+    ''' Checks if the user is an editor for all the sites '''
+    if not is_editor(user):
+        return False
+
+    return user.role.editor.count() == Site.objects.count()
+
+
+@rules.predicate
+def is_element_editor(user, obj) -> bool:
+    ''' Checks if the user is an editor for the sites to which this element is editable '''
+    if obj is None:
+        return is_editor(user)
+
+    if not obj.editors.exists():
+        # if the element has no editors, it is editable by all users with an editor role
+        return is_editor(user)
+
+    # else, return whether the user is an editor for the object
+    return user.role.editor.filter(id__in=obj.editors.all()).exists()
+
+
+@rules.predicate
+def is_reviewer(user) -> bool:
+    ''' Checks if any reviewer role exists for the user '''
+    return user.role.reviewer.exists()
+
+
+@rules.predicate
+def is_multisite_reviewer(user) -> bool:
+    ''' Checks if the user is an reviewer for all the sites '''
+    if not is_reviewer(user):
+        return False
+
+    return user.role.reviewer.count() == Site.objects.count()
+
+
+@rules.predicate
+def is_element_reviewer(user, obj) -> bool:
+    ''' Checks if the user is an reviewer for the sites to which this element is editable '''
+    if obj is None:
+        return is_reviewer(user)
+
+    # if the element has no editors, it is reviewable by all reviewers
+    if not obj.editors.exists():
+        return is_reviewer(user)
+
+    # else, return whether the user is a reviewer for of the object
+    return user.role.reviewer.filter(id__in=obj.editors.all()).exists()
+
+
+# Add rules
+rules.add_rule('can_view_management', is_authenticated & ((is_editor | is_reviewer)))
+
+
+# Model Permissions for sites and group
+rules.add_perm('sites.view_site', is_editor | is_reviewer)
+rules.add_perm('auth.view_group', is_editor | is_reviewer)
+
+# Model Permissions for domain
+rules.add_perm('domain.view_attribute', is_editor | is_reviewer)
+rules.add_perm('domain.add_attribute', is_editor)
+
+
+# Object permissions domain attribute objects
+rules.add_perm('domain.view_attribute_object', is_element_editor | is_element_reviewer)
+rules.add_perm('domain.add_attribute_object', is_element_editor)
+rules.add_perm('domain.change_attribute_object', is_element_editor)
+rules.add_perm('domain.delete_attribute_object', is_element_editor)
+
+# Model Permissions for options
+rules.add_perm('options.view_option', is_editor | is_reviewer)
+rules.add_perm('options.add_option', is_editor)
+
+# Object Permissions for options option objects
+rules.add_perm('options.view_option_object', is_element_editor | is_element_reviewer)
+rules.add_perm('options.add_option_object', is_element_editor)
+rules.add_perm('options.change_option_object', is_element_editor)
+rules.add_perm('options.delete_option_object', is_element_editor)
+
+# Model Permissions for optionsets
+rules.add_perm('options.view_optionset', is_editor | is_reviewer)
+rules.add_perm('options.add_optionset', is_editor)
+
+# Object Permissions for options optionset objects
+rules.add_perm('options.view_optionset_object', is_element_editor | is_element_reviewer)
+rules.add_perm('options.add_optionset_object', is_element_editor)
+rules.add_perm('options.change_optionset_object', is_element_editor)
+rules.add_perm('options.delete_optionset_object', is_element_editor)
+
+# Model Permissions for conditions
+rules.add_perm('conditions.view_condition', is_editor | is_reviewer)
+rules.add_perm('conditions.add_condition', is_editor)
+
+# Object Permissions for conditions
+rules.add_perm('conditions.view_condition_object', is_element_editor | is_element_reviewer)
+rules.add_perm('conditions.add_condition_object', is_element_editor)
+rules.add_perm('conditions.change_condition_object', is_element_editor)
+rules.add_perm('conditions.delete_condition_object', is_element_editor)
+
+# Model Permissions for tasks
+rules.add_perm('tasks.view_task', is_editor | is_reviewer)
+rules.add_perm('tasks.add_task', is_editor)
+
+# Object Permissions for tasks
+rules.add_perm('tasks.view_task_object', is_element_editor | is_element_reviewer)
+rules.add_perm('tasks.add_task_object', is_element_editor)
+rules.add_perm('tasks.change_task_object', is_element_editor)
+rules.add_perm('tasks.delete_task_object', is_element_editor)
+
+# Model Permissions for views
+rules.add_perm('views.view_view', is_editor | is_reviewer)
+rules.add_perm('views.add_view', is_editor)
+
+# Object Permissions for views
+rules.add_perm('views.view_view_object', is_element_editor | is_element_reviewer)
+rules.add_perm('views.add_view_object', is_element_editor)
+rules.add_perm('views.change_view_object', is_element_editor)
+rules.add_perm('views.delete_view_object', is_element_editor)
+
+# Model permissions for catalogs
+rules.add_perm('questions.view_catalog', is_editor | is_reviewer)
+rules.add_perm('questions.add_catalog', is_editor)
+
+# Object permissions for catalogs
+rules.add_perm('questions.view_catalog_object', is_element_editor | is_element_reviewer)
+rules.add_perm('questions.add_catalog_object', is_element_editor)
+rules.add_perm('questions.change_catalog_object', is_element_editor)
+rules.add_perm('questions.delete_catalog_object', is_element_editor)
+
+# Model permissions for sections
+rules.add_perm('questions.view_section', is_editor | is_reviewer)
+rules.add_perm('questions.add_section', is_editor)
+
+# Object permissions for sections
+rules.add_perm('questions.view_section_object', is_element_editor | is_element_reviewer)
+rules.add_perm('questions.add_section_object', is_element_editor)
+rules.add_perm('questions.change_section_object', is_element_editor)
+rules.add_perm('questions.delete_section_object', is_element_editor)
+
+# Model permissions for pages
+rules.add_perm('questions.view_page', is_editor | is_reviewer)
+rules.add_perm('questions.add_page', is_editor)
+
+# Object permissions for pages
+rules.add_perm('questions.view_page_object', is_element_editor | is_element_reviewer)
+rules.add_perm('questions.add_page_object', is_element_editor)
+rules.add_perm('questions.change_page_object', is_element_editor)
+rules.add_perm('questions.delete_page_object', is_element_editor)
+
+# Model permissions for questionsets
+rules.add_perm('questions.view_questionset', is_editor | is_reviewer)
+rules.add_perm('questions.add_questionset', is_editor)
+
+# Object permissions for questionsets
+rules.add_perm('questions.view_questionset_object', is_element_editor | is_element_reviewer)
+rules.add_perm('questions.add_questionset_object', is_element_editor)
+rules.add_perm('questions.change_questionset_object', is_element_editor)
+rules.add_perm('questions.delete_questionset_object', is_element_editor)
+
+# Model permissions for questions
+rules.add_perm('questions.view_question', is_editor | is_reviewer)
+rules.add_perm('questions.add_question', is_editor)
+
+# Object permissions for questions
+rules.add_perm('questions.view_question_object', is_element_editor | is_element_reviewer)
+rules.add_perm('questions.add_question_object', is_element_editor)
+rules.add_perm('questions.change_question_object', is_element_editor)
+rules.add_perm('questions.delete_question_object', is_element_editor)
diff --git a/rdmo/options/admin.py b/rdmo/options/admin.py
index e23fa7b7ca..baaffdafde 100644
--- a/rdmo/options/admin.py
+++ b/rdmo/options/admin.py
@@ -42,7 +42,7 @@ class OptionSetAdmin(admin.ModelAdmin):
     search_fields = ('uri', )
     list_display = ('uri', )
     readonly_fields = ('uri', )
-    filter_horizontal = ('options', 'conditions')
+    filter_horizontal = ('editors', 'conditions')
 
 
 class OptionAdmin(admin.ModelAdmin):
@@ -51,7 +51,8 @@ class OptionAdmin(admin.ModelAdmin):
     search_fields = ['uri'] + get_language_fields('text')
     list_display = ('uri', 'text', 'additional_input')
     readonly_fields = ('uri', )
-    list_filter = ('optionsets', 'additional_input')
+    list_filter = ('editors', 'optionsets', 'additional_input')
+    filter_horizontal = ('editors', )
 
 
 admin.site.register(OptionSet, OptionSetAdmin)
diff --git a/rdmo/options/imports.py b/rdmo/options/imports.py
index 917044745d..dcd24d8173 100644
--- a/rdmo/options/imports.py
+++ b/rdmo/options/imports.py
@@ -1,5 +1,7 @@
 import logging
 
+from django.contrib.sites.models import Site
+
 from rdmo.core.imports import (set_common_fields, set_lang_field,
                                set_m2m_instances, set_m2m_through_instances,
                                set_reverse_m2m_through_instance,
@@ -36,6 +38,7 @@ def import_optionset(element, save=False):
         optionset.save()
         set_m2m_instances(optionset, 'conditions', element)
         set_m2m_through_instances(optionset, 'options', element, 'optionset', 'option', 'optionset_options')
+        optionset.editors.add(Site.objects.get_current())
 
     return optionset
 
@@ -64,5 +67,6 @@ def import_option(element, save=False):
 
         option.save()
         set_reverse_m2m_through_instance(option, 'optionset', element, 'option', 'optionset', 'option_optionsets')
+        option.editors.add(Site.objects.get_current())
 
     return option
diff --git a/rdmo/options/migrations/0031_add_editors.py b/rdmo/options/migrations/0031_add_editors.py
new file mode 100644
index 0000000000..c5b70098b4
--- /dev/null
+++ b/rdmo/options/migrations/0031_add_editors.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.2.16 on 2023-02-24 09:30
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('sites', '0002_alter_domain_unique'),
+        ('options', '0030_optionset_options'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='option',
+            name='editors',
+            field=models.ManyToManyField(blank=True, help_text='The sites that can edit this option (in a multi site setup).', related_name='option_editors', to='sites.Site', verbose_name='Editors'),
+        ),
+        migrations.AddField(
+            model_name='optionset',
+            name='editors',
+            field=models.ManyToManyField(blank=True, help_text='The sites that can edit this option set (in a multi site setup).', related_name='optionset_editors', to='sites.Site', verbose_name='Editors'),
+        ),
+    ]
diff --git a/rdmo/options/models.py b/rdmo/options/models.py
index 82d8beb522..99292c517a 100644
--- a/rdmo/options/models.py
+++ b/rdmo/options/models.py
@@ -1,4 +1,5 @@
 from django.conf import settings
+from django.contrib.sites.models import Site
 from django.db import models
 from django.utils.functional import cached_property
 from django.utils.translation import gettext_lazy as _
@@ -41,6 +42,11 @@ class OptionSet(models.Model):
         verbose_name=_('Order'),
         help_text=_('The position of this option set in lists.')
     )
+    editors = models.ManyToManyField(
+        Site, related_name='%(class)s_editors', blank=True,
+        verbose_name=_('Editors'),
+        help_text=_('The sites that can edit this option set (in a multi site setup).')
+    )
     provider_key = models.SlugField(
         max_length=128, blank=True,
         verbose_name=_('Provider'),
@@ -162,6 +168,11 @@ class Option(models.Model, TranslationMixin):
         verbose_name=_('Locked'),
         help_text=_('Designates whether this option can be changed.')
     )
+    editors = models.ManyToManyField(
+        Site, related_name='%(class)s_editors', blank=True,
+        verbose_name=_('Editors'),
+        help_text=_('The sites that can edit this option (in a multi site setup).')
+    )
     text_lang1 = models.CharField(
         max_length=256, blank=True,
         verbose_name=_('Text (primary)'),
@@ -210,6 +221,7 @@ def copy(self, uri_prefix, uri_path):
 
         # set m2m fields for copy
         option.optionsets.set(self.optionsets.all())
+        option.editors.set([Site.objects.get_current()])
 
         return option
 
diff --git a/rdmo/options/serializers/v1/option.py b/rdmo/options/serializers/v1/option.py
index d3b481e5a5..26a8f70643 100644
--- a/rdmo/options/serializers/v1/option.py
+++ b/rdmo/options/serializers/v1/option.py
@@ -3,6 +3,7 @@
 from rdmo.core.serializers import (ElementExportSerializerMixin,
                                    ElementModelSerializerMixin,
                                    ElementWarningSerializerMixin,
+                                   ReadOnlyObjectPermissionsSerializerMixin,
                                    ThroughModelSerializerMixin,
                                    TranslationSerializerMixin)
 
@@ -11,13 +12,15 @@
 
 
 class OptionSerializer(ThroughModelSerializerMixin, TranslationSerializerMixin,
-                       ElementModelSerializerMixin, serializers.ModelSerializer):
+                       ElementModelSerializerMixin, ReadOnlyObjectPermissionsSerializerMixin,
+                       serializers.ModelSerializer):
 
     model = serializers.SerializerMethodField()
     optionsets = serializers.PrimaryKeyRelatedField(queryset=OptionSet.objects.all(), required=False, many=True)
     conditions = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
     values_count = serializers.IntegerField(read_only=True)
     projects_count = serializers.IntegerField(read_only=True)
+    read_only = serializers.SerializerMethodField()
 
     class Meta:
         model = Option
@@ -35,7 +38,9 @@ class Meta:
             'optionsets',
             'conditions',
             'values_count',
-            'projects_count'
+            'projects_count',
+            'editors',
+            'read_only'
         )
         trans_fields = (
             'text',
diff --git a/rdmo/options/serializers/v1/optionset.py b/rdmo/options/serializers/v1/optionset.py
index 08d090c2d5..5d656881bb 100644
--- a/rdmo/options/serializers/v1/optionset.py
+++ b/rdmo/options/serializers/v1/optionset.py
@@ -2,6 +2,7 @@
 
 from rdmo.core.serializers import (ElementExportSerializerMixin,
                                    ElementModelSerializerMixin,
+                                   ReadOnlyObjectPermissionsSerializerMixin,
                                    ThroughModelSerializerMixin)
 from rdmo.questions.models import Question
 
@@ -21,12 +22,13 @@ class Meta:
 
 
 class OptionSetSerializer(ThroughModelSerializerMixin, ElementModelSerializerMixin,
-                          serializers.ModelSerializer):
+                          ReadOnlyObjectPermissionsSerializerMixin, serializers.ModelSerializer):
 
     model = serializers.SerializerMethodField()
     uri_path = serializers.CharField(required=True)
     options = OptionSetOptionSerializer(source='optionset_options', read_only=False, required=False, many=True)
     questions = serializers.PrimaryKeyRelatedField(queryset=Question.objects.all(), required=False, many=True)
+    read_only = serializers.SerializerMethodField()
 
     class Meta:
         model = OptionSet
@@ -38,11 +40,14 @@ class Meta:
             'uri_path',
             'comment',
             'locked',
+            'read_only',
             'order',
             'provider_key',
             'options',
             'conditions',
-            'questions'
+            'questions',
+            'editors',
+            'read_only'
         )
         through_fields = (
             ('options', 'optionset', 'option', 'optionset_options'),
diff --git a/rdmo/options/static/options/js/options.js b/rdmo/options/static/options/js/options.js
index 51dc2051a9..cf46453ac6 100644
--- a/rdmo/options/static/options/js/options.js
+++ b/rdmo/options/static/options/js/options.js
@@ -14,6 +14,7 @@ angular.module('options', ['core'])
         conditions: $resource(baseurl + 'api/v1/conditions/conditions/:list_action/:id/'),
         providers: $resource(baseurl + 'api/v1/options/providers/:id/'),
         settings: $resource(baseurl + 'api/v1/core/settings/'),
+        sites: $resource(baseurl + 'api/v1/core/sites/')
     };
 
     /* configure factories */
@@ -41,6 +42,7 @@ angular.module('options', ['core'])
     service.init = function(options) {
         service.providers = resources.providers.query();
         service.settings = resources.settings.get();
+        service.sites = resources.sites.query();
         service.uri_prefixes = [];
         service.uri_prefix = '';
         service.filter = sessionStorage.getItem('options_filter') || '';
diff --git a/rdmo/options/templates/options/options.html b/rdmo/options/templates/options/options.html
index 6ec700cb95..bd5883f510 100644
--- a/rdmo/options/templates/options/options.html
+++ b/rdmo/options/templates/options/options.html
@@ -110,7 +110,9 @@ <h1>{% trans 'Options' %}</h1>
             <div class="panel-heading">
                 <div class="pull-right">
                     <i class="fa fa-lock text-danger" ng-show="optionset.locked"></i>
-
+                    <i class="fa fa-ban text-muted" ng-show="optionset.read_only"
+                        title="{% trans 'Read only' %}">
+                    </i>
                     <a href="" class="fa fa-plus"
                         title="{% trans 'Add option' %}"
                         ng-click="service.openFormModal('options', optionset, true)">
@@ -140,8 +142,8 @@ <h1>{% trans 'Options' %}</h1>
                         ng-click="service.openDeleteModal('optionsets', optionset)">
                     </a>
                 </div>
-                <p>
-                    <strong>{% trans 'Option set' %}</strong>
+                <p ng-class="{'text-muted': optionset.read_only}">
+                    <strong >{% trans 'Option set' %}</strong>
                 </p>
                 <div>
                     <code class="code-options" title="{% trans 'URI' %}">{$ optionset.uri $}</code>
@@ -150,7 +152,28 @@ <h1>{% trans 'Options' %}</h1>
                 <div ng-show="optionset.conditions" ng-repeat="condition in optionset.conditions">
                     <code class="code-conditions" title="{% trans 'Condition' %}">{$ condition.uri $}</code>
                 </div>
-            </div>
+                </div>
+                {% if settings.MULTISITE %}
+                <div class="panel-body">
+                    <div class="row">
+                        <div class="col-sm-3">
+                            <strong>{% trans 'Editors for this Option Set' %}</strong>
+                        </div>
+                        <div class="col-sm-9">
+                            <ul>
+                                <li ng-show="optionset.editors.length > 0 && optionset.editors.length < service.sites.length"
+                                    ng-repeat="editor in optionset.editors">
+                                    {$ editor.name $}
+                                </li>
+                                <li ng-show="optionset.editors.length == 0 || optionset.editors.length == service.sites.length">
+                                    {% trans 'All sites' %}
+                                </li>
+                            </ul>
+                        </div>
+                    </div>
+                </div>
+                {% endif %}
+                
 
             <ul class="list-group" ng-if="optionset.provider">
                 <li class="list-group-item">
@@ -171,6 +194,9 @@ <h1>{% trans 'Options' %}</h1>
 
                         <i class="fa fa-lock text-muted" ng-show="optionset.locked"></i>
                         <i class="fa fa-lock text-danger" ng-show="option.locked"></i>
+                        <i class="fa fa-ban text-muted" ng-show="option.read_only"
+                        title="{% trans 'Read only' %}">
+                        </i>
 
                         <a href="" class="fa fa-pencil"
                             title="{% trans 'Update option' %}"
@@ -193,7 +219,7 @@ <h1>{% trans 'Options' %}</h1>
                             ng-click="service.openDeleteModal('options', option)">
                         </a>
                     </div>
-                    <p>
+                    <p ng-class="{'text-muted': option.read_only}">
                         <strong>{% trans 'Option' %}</strong>
                         <span>{$ option.text $}</span>
                     </p>
diff --git a/rdmo/options/templates/options/options_modal_delete_options.html b/rdmo/options/templates/options/options_modal_delete_options.html
index 66d53e71dd..76e80d4847 100644
--- a/rdmo/options/templates/options/options_modal_delete_options.html
+++ b/rdmo/options/templates/options/options_modal_delete_options.html
@@ -37,10 +37,14 @@ <h2 class="modal-title">
                 </div>
 
                 <div class="modal-footer">
+                    <div class="alert alert-warning pull-left" ng-show="service.errors.detail" role="alert">
+                        {$ service.errors.detail $}
+                    </div>
                     <button type="button" class="btn btn-default" data-dismiss="modal">
                         {% trans 'Close' %}
                     </button>
                     <button type="button" class="btn btn-danger"
+                            ng-disabled="service.values.read_only"
                             ng-click="service.submitDeleteModal('options')">
                         {% trans 'Delete' %}
                     </button>
diff --git a/rdmo/options/templates/options/options_modal_delete_optionsets.html b/rdmo/options/templates/options/options_modal_delete_optionsets.html
index 488cb0aa2a..b2f31e2128 100644
--- a/rdmo/options/templates/options/options_modal_delete_optionsets.html
+++ b/rdmo/options/templates/options/options_modal_delete_optionsets.html
@@ -50,10 +50,14 @@ <h2 class="modal-title">
                 </div>
 
                 <div class="modal-footer">
+                    <div class="alert alert-warning pull-left" ng-show="service.errors.detail" role="alert">
+                        {$ service.errors.detail $}
+                    </div>
                     <button type="button" class="btn btn-default" data-dismiss="modal">
                         {% trans 'Close' %}
                     </button>
                     <button type="button" class="btn btn-danger"
+                            ng-class="{'disabled': service.values.read_only}"
                             ng-click="service.submitDeleteModal('optionsets')">
                         {% trans 'Delete' %}
                     </button>
diff --git a/rdmo/options/templates/options/options_modal_form_conditions.html b/rdmo/options/templates/options/options_modal_form_conditions.html
index d038b99790..38472020fd 100644
--- a/rdmo/options/templates/options/options_modal_form_conditions.html
+++ b/rdmo/options/templates/options/options_modal_form_conditions.html
@@ -26,10 +26,14 @@ <h2 class="modal-title">
                 </div>
 
                 <div class="modal-footer">
+                    <div class="alert alert-warning pull-left" ng-show="service.errors.detail" role="alert">
+                        {$ service.errors.detail $}
+                    </div>
                     <button type="button" class="btn btn-default" data-dismiss="modal">
                         {% trans 'Close' %}
                     </button>
                     <button type="button" class="btn btn-primary"
+                            ng-class="{'disabled': service.catalog.read_only}"
                             ng-click="service.submitFormModal('conditions')">
                         {% trans 'Save' %}
                     </button>
diff --git a/rdmo/options/templates/options/options_modal_form_options.html b/rdmo/options/templates/options/options_modal_form_options.html
index 909b063e52..3253c02c6e 100644
--- a/rdmo/options/templates/options/options_modal_form_options.html
+++ b/rdmo/options/templates/options/options_modal_form_options.html
@@ -151,6 +151,18 @@ <h2 class="modal-title">
                                     </a>
                                 </li>
                                 {% endfor %}
+                                {% if settings.MULTISITE %}
+                                <li role="presentation" ng-class="{
+                                    'has-error': service.errors.editors,
+                                }">
+                                    <a class="control-label" href="#options-editors"
+                                        role="tab" data-toggle="tab" aria-controls="options-editors">
+
+                                        {% trans 'Editors' %}
+                                        <i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
+                                    </a>
+                                </li>
+                                {% endif %}
                             </ul>
                             <div class="tab-content">
                                 {% for lang_code, lang in languages %}
@@ -168,16 +180,34 @@ <h2 class="modal-title">
                                     </formgroup>
                                 </div>
                                 {% endfor %}
+                            {% if settings.MULTISITE %}
+                            <div role="tabpanel" class="tab-pane" id="options-editors">
+                                <formgroup
+                                    data-id="options_editors"
+                                    data-type="selectmultiple"
+                                    data-label="{% trans 'Editors' %}"
+                                    data-model="service.values.editors"
+                                    data-errors="service.errors.editors"
+                                    data-options="service.sites"
+                                    data-options-label="name"
+                                    data-help="{% trans 'Hold down Control, or Command on a Mac, to select more than one.' %}">
+                                </formgroup>
+                            </div>
+                            {% endif %}
                             </div>
                         </div>
                     </div>
                 </div>
 
                 <div class="modal-footer">
+                    <div class="alert alert-warning pull-left" ng-show="service.errors.detail" role="alert">
+                        {$ service.errors.detail $}
+                    </div>
                     <button type="button" class="btn btn-default" data-dismiss="modal">
                         {% trans 'Close' %}
                     </button>
                     <button type="button" class="btn btn-primary"
+                            ng-class="{'disabled': service.values.read_only}"
                             ng-click="service.submitFormModal('options')">
                         {% trans 'Save' %}
                     </button>
diff --git a/rdmo/options/templates/options/options_modal_form_optionsets.html b/rdmo/options/templates/options/options_modal_form_optionsets.html
index 755cec34d5..427209df61 100644
--- a/rdmo/options/templates/options/options_modal_form_optionsets.html
+++ b/rdmo/options/templates/options/options_modal_form_optionsets.html
@@ -106,14 +106,32 @@ <h2 class="modal-title">
                             data-options-label="text"
                             data-options-null="1">
                         </formgroup>
+                        {% if settings.MULTISITE %}
+                        <div role="tabpanel" class="tab-pane" id="optionset-editors">
+                            <formgroup
+                                data-id="optionset_editors"
+                                data-type="selectmultiple"
+                                data-label="{% trans 'Editors' %}"
+                                data-model="service.values.editors"
+                                data-errors="service.errors.editors"
+                                data-options="service.sites"
+                                data-options-label="name"
+                                data-help="{% trans 'Hold down Control, or Command on a Mac, to select more than one.' %}">
+                            </formgroup>
+                        </div>
+                        {% endif %}
                     </div>
                 </div>
 
                 <div class="modal-footer">
+                    <div class="alert alert-warning pull-left" ng-show="service.errors.detail" role="alert">
+                        {$ service.errors.detail $}
+                    </div>
                     <button type="button" class="btn btn-default" data-dismiss="modal">
                         {% trans 'Close' %}
                     </button>
                     <button type="button" class="btn btn-primary"
+                            ng-class="{'disabled': service.values.read_only}"
                             ng-click="service.submitFormModal('optionsets')">
                         {% trans 'Save' %}
                     </button>
diff --git a/rdmo/options/viewsets.py b/rdmo/options/viewsets.py
index 06143027be..d249702b27 100644
--- a/rdmo/options/viewsets.py
+++ b/rdmo/options/viewsets.py
@@ -8,7 +8,7 @@
 from rest_framework.viewsets import ModelViewSet
 
 from rdmo.core.exports import XMLResponse
-from rdmo.core.permissions import HasModelPermission
+from rdmo.core.permissions import HasModelPermission, HasObjectPermission
 from rdmo.core.utils import is_truthy, render_to_format
 from rdmo.core.views import ChoicesViewSet
 from rdmo.core.viewsets import CopyModelMixin
@@ -23,11 +23,12 @@
 
 
 class OptionSetViewSet(CopyModelMixin, ModelViewSet):
-    permission_classes = (HasModelPermission, )
+    permission_classes = (HasModelPermission | HasObjectPermission, )
     queryset = OptionSet.objects.prefetch_related(
         'optionset_options__option',
         'conditions',
-        'questions'
+        'questions',
+        'editors'
     )
     serializer_class = OptionSetSerializer
 
@@ -54,7 +55,7 @@ def nested(self, request, pk):
         serializer = OptionSetNestedSerializer(self.get_object(), context={'request': request})
         return Response(serializer.data)
 
-    @action(detail=False, url_path='export(/(?P<export_format>[a-z]+))?', permission_classes=[HasModelPermission])
+    @action(detail=False, url_path='export(/(?P<export_format>[a-z]+))?')
     def export(self, request, export_format='xml'):
         queryset = self.filter_queryset(self.get_queryset())
         if export_format == 'xml':
@@ -66,7 +67,7 @@ def export(self, request, export_format='xml'):
                 'optionsets': queryset
             })
 
-    @action(detail=True, url_path='export(/(?P<export_format>[a-z]+))?', permission_classes=[HasModelPermission])
+    @action(detail=True, url_path='export(/(?P<export_format>[a-z]+))?')
     def detail_export(self, request, pk=None, export_format='xml'):
         if export_format == 'xml':
             serializer = OptionSetExportSerializer(self.get_object())
@@ -87,10 +88,10 @@ def get_export_renderer_context(self, request):
 
 
 class OptionViewSet(CopyModelMixin, ModelViewSet):
-    permission_classes = (HasModelPermission, )
+    permission_classes = (HasModelPermission | HasObjectPermission, )
     queryset = Option.objects.annotate(values_count=models.Count('values')) \
                              .annotate(projects_count=models.Count('values__project', distinct=True)) \
-                             .prefetch_related('optionsets', 'conditions')
+                             .prefetch_related('optionsets', 'conditions', 'editors')
     serializer_class = OptionSerializer
 
     filter_backends = (SearchFilter, DjangoFilterBackend)
@@ -111,7 +112,7 @@ def index(self, request):
         serializer = OptionIndexSerializer(queryset, many=True)
         return Response(serializer.data)
 
-    @action(detail=False, url_path='export(/(?P<export_format>[a-z]+))?', permission_classes=[HasModelPermission])
+    @action(detail=False, url_path='export(/(?P<export_format>[a-z]+))?')
     def export(self, request, export_format='xml'):
         queryset = self.filter_queryset(self.get_queryset())
         if export_format == 'xml':
@@ -123,7 +124,7 @@ def export(self, request, export_format='xml'):
                 'options': queryset
             })
 
-    @action(detail=True, url_path='export(/(?P<export_format>[a-z]+))?', permission_classes=[HasModelPermission])
+    @action(detail=True, url_path='export(/(?P<export_format>[a-z]+))?')
     def detail_export(self, request, pk=None, export_format='xml'):
         if export_format == 'xml':
             serializer = OptionExportSerializer(self.get_object())
diff --git a/rdmo/projects/permissions.py b/rdmo/projects/permissions.py
new file mode 100644
index 0000000000..54aaaf8e8e
--- /dev/null
+++ b/rdmo/projects/permissions.py
@@ -0,0 +1,64 @@
+from rdmo.core.permissions import log_result, HasObjectPermission
+
+
+class HasProjectsPermission(HasObjectPermission):
+
+    @log_result
+    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
+
+    @log_result
+    def has_object_permission(self, request, view, obj):
+        if not (request.user and request.user.is_authenticated):
+            return False
+
+        # get the project object from the obj (or the take the obj) and check its permissions
+        try:
+            return super().has_object_permission(request, view, obj.project)
+        except AttributeError:
+            return super().has_object_permission(request, view, obj)
+
+
+class HasProjectPermission(HasObjectPermission):
+
+    @log_result
+    def has_permission(self, request, view):
+        if not (request.user and request.user.is_authenticated):
+            return False
+
+        # check if this is a detail view (retrieve, update, partial_update, destroy) or not (list, create)
+        if view.detail:
+            # for retrieve, update, partial_update, or destroy return True
+            # the permission will be checked on object level (in the next step)
+            return True
+        else:
+            # for list or create we need to get the project from the view
+            # and check that the user has the correct permission
+            try:
+                return super().has_object_permission(request, view, view.project)
+            except AttributeError:  # needed for swagger /api/v1
+                return super().has_permission(request, view)
+
+    @log_result
+    def has_object_permission(self, request, view, obj):
+        if not (request.user and request.user.is_authenticated):
+            return False
+
+        # get the project object from the view (or the take the obj) and check its permissions
+        try:
+            return super().has_object_permission(request, view, view.project)
+        except AttributeError:
+            return super().has_object_permission(request, view, obj)
+
+
+class HasProjectPagePermission(HasProjectPermission):
+
+    def get_required_object_permissions(self, method, model_cls):
+        return ('projects.view_page_object', )
diff --git a/rdmo/projects/rules.py b/rdmo/projects/rules.py
index 93ffd98d76..898867aecf 100644
--- a/rdmo/projects/rules.py
+++ b/rdmo/projects/rules.py
@@ -1,5 +1,7 @@
 import rules
+from rules.predicates import is_superuser
 
+from django.contrib.sites.shortcuts import get_current_site
 
 @rules.predicate
 def is_project_member(user, project):
@@ -39,6 +41,19 @@ def is_site_manager(user, project):
         return False
 
 
+@rules.predicate
+def is_site_manager_for_current_site(user, request):
+    if user.is_authenticated:
+        current_site = get_current_site(request)
+        return user.role.manager.filter(pk=current_site.pk).exists()
+    else:
+        return False
+
+
+# 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.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.delete_project_object', is_project_owner | is_site_manager)
@@ -76,7 +91,7 @@ def is_site_manager(user, project):
 rules.add_perm('projects.change_value_object', is_project_author | is_project_manager | is_project_owner | is_site_manager)
 rules.add_perm('projects.delete_value_object', is_project_author | is_project_manager | is_project_owner | is_site_manager)
 
-rules.add_perm('questions.view_page_object', is_project_member | is_site_manager)
+rules.add_perm('projects.view_page_object', is_project_member | is_site_manager)
 
 # TODO: use one of the permissions above
 rules.add_perm('projects.is_project_owner', is_project_owner)
diff --git a/rdmo/projects/templates/projects/projects.html b/rdmo/projects/templates/projects/projects.html
index 7f26af6c54..0edfaace36 100644
--- a/rdmo/projects/templates/projects/projects.html
+++ b/rdmo/projects/templates/projects/projects.html
@@ -34,7 +34,8 @@ <h2>{% trans 'Options' %}</h2>
         </li>
     </ul>
 
-    {% if is_site_manager %}
+    {% test_rule 'projects.can_view_all_projects' request.user request as can_view_all_projects %}
+    {% if can_view_all_projects %}
     <ul class="list-unstyled">
         <li>
             <a href="{% url 'site_projects' %}">
diff --git a/rdmo/projects/viewsets.py b/rdmo/projects/viewsets.py
index 2da0505127..d0e98f92cc 100644
--- a/rdmo/projects/viewsets.py
+++ b/rdmo/projects/viewsets.py
@@ -18,7 +18,7 @@
 from rest_framework_extensions.mixins import NestedViewSetMixin
 
 from rdmo.conditions.models import Condition
-from rdmo.core.permissions import HasModelPermission, HasObjectPermission
+from rdmo.core.permissions import HasModelPermission
 from rdmo.core.utils import human2bytes, return_file_response
 from rdmo.options.models import OptionSet
 from rdmo.questions.models import Page, Question, QuestionSet
@@ -28,6 +28,7 @@
 from .filters import SnapshotFilterBackend, ValueFilterBackend
 from .models import (Continuation, Integration, Issue, Membership, Project,
                      Snapshot, Value)
+from .permissions import HasProjectPagePermission, HasProjectPermission, HasProjectsPermission
 from .serializers.v1 import (IntegrationSerializer, IssueSerializer,
                              MembershipSerializer,
                              ProjectIntegrationSerializer,
@@ -43,7 +44,7 @@
 
 
 class ProjectViewSet(ModelViewSet):
-    permission_classes = (HasModelPermission | HasObjectPermission, )
+    permission_classes = (HasModelPermission | HasProjectsPermission, )
     serializer_class = ProjectSerializer
 
     filter_backends = (DjangoFilterBackend,)
@@ -69,7 +70,7 @@ def overview(self, request, pk=None):
         serializer = ProjectOverviewSerializer(project, context={'request': request})
         return Response(serializer.data)
 
-    @action(detail=True, permission_classes=(HasModelPermission | HasObjectPermission, ))
+    @action(detail=True, permission_classes=(HasModelPermission | HasProjectPermission, ))
     def resolve(self, request, pk=None):
         snapshot_id = request.GET.get('snapshot')
         set_prefix = request.GET.get('set_prefix')
@@ -128,7 +129,7 @@ def resolve(self, request, pk=None):
 
         return Response({'result': False})
 
-    @action(detail=True, permission_classes=(HasModelPermission | HasObjectPermission, ))
+    @action(detail=True, permission_classes=(HasModelPermission | HasProjectPermission, ))
     def options(self, request, pk=None):
         project = self.get_object()
 
@@ -194,18 +195,12 @@ def get_project_from_parent_viewset(self):
         except Project.DoesNotExist:
             raise Http404
 
-    def get_list_permission_object(self):
-        return self.project
-
-    def get_detail_permission_object(self, obj):
-        return self.project
-
     def perform_create(self, serializer):
         serializer.save(project=self.project)
 
 
 class ProjectMembershipViewSet(ProjectNestedViewSetMixin, ModelViewSet):
-    permission_classes = (HasModelPermission | HasObjectPermission, )
+    permission_classes = (HasModelPermission | HasProjectPermission, )
 
     filter_backends = (DjangoFilterBackend, )
     filterset_fields = (
@@ -229,7 +224,7 @@ def get_serializer_class(self):
 
 
 class ProjectIntegrationViewSet(ProjectNestedViewSetMixin, ModelViewSet):
-    permission_classes = (HasModelPermission | HasObjectPermission, )
+    permission_classes = (HasModelPermission | HasProjectPermission, )
     serializer_class = ProjectIntegrationSerializer
 
     filter_backends = (DjangoFilterBackend, )
@@ -247,7 +242,7 @@ def get_queryset(self):
 
 class ProjectIssueViewSet(ProjectNestedViewSetMixin, ListModelMixin, RetrieveModelMixin,
                           UpdateModelMixin, GenericViewSet):
-    permission_classes = (HasModelPermission | HasObjectPermission, )
+    permission_classes = (HasModelPermission | HasProjectPermission, )
     serializer_class = ProjectIssueSerializer
 
     filter_backends = (DjangoFilterBackend, )
@@ -267,7 +262,7 @@ def get_queryset(self):
 
 class ProjectSnapshotViewSet(ProjectNestedViewSetMixin, CreateModelMixin, RetrieveModelMixin,
                              UpdateModelMixin, ListModelMixin, GenericViewSet):
-    permission_classes = (HasModelPermission | HasObjectPermission, )
+    permission_classes = (HasModelPermission | HasProjectPermission, )
     serializer_class = ProjectSnapshotSerializer
 
     def get_queryset(self):
@@ -279,7 +274,7 @@ def get_queryset(self):
 
 
 class ProjectValueViewSet(ProjectNestedViewSetMixin, ModelViewSet):
-    permission_classes = (HasModelPermission | HasObjectPermission, )
+    permission_classes = (HasModelPermission | HasProjectPermission, )
     serializer_class = ProjectValueSerializer
 
     filter_backends = (ValueFilterBackend, DjangoFilterBackend)
@@ -298,7 +293,7 @@ def get_queryset(self):
             return Value.objects.none()
 
     @action(detail=True, methods=['DELETE'],
-            permission_classes=(HasModelPermission | HasObjectPermission, ))
+            permission_classes=(HasModelPermission | HasProjectPermission, ))
     def set(self, request, parent_lookup_project, pk=None):
         # delete all values for questions in questionset collections with the attribute
         # for this value and the same set_prefix and set_index
@@ -322,7 +317,7 @@ def set(self, request, parent_lookup_project, pk=None):
         return Response(status=204)
 
     @action(detail=True, methods=['GET', 'POST'],
-            permission_classes=(HasModelPermission | HasObjectPermission, ))
+            permission_classes=(HasModelPermission | HasProjectPermission, ))
     def file(self, request, parent_lookup_project, pk=None):
         value = self.get_object()
 
@@ -348,7 +343,7 @@ def file(self, request, parent_lookup_project, pk=None):
 
 
 class ProjectPageViewSet(ProjectNestedViewSetMixin, RetrieveModelMixin, GenericViewSet):
-    permission_classes = (HasModelPermission | HasObjectPermission, )
+    permission_classes = (HasModelPermission | HasProjectPagePermission, )
     serializer_class = PageSerializer
 
     def get_queryset(self):
@@ -402,7 +397,7 @@ def retrieve(self, request, *args, **kwargs):
             # indicate end of catalog
             return Response(status=204)
 
-    @action(detail=False, url_path='continue', permission_classes=(HasModelPermission | HasObjectPermission, ))
+    @action(detail=False, url_path='continue', permission_classes=(HasModelPermission | HasProjectPagePermission, ))
     def get_continue(self, request, pk=None, parent_lookup_project=None):
         try:
             continuation = Continuation.objects.get(project=self.project, user=self.request.user)
@@ -420,7 +415,7 @@ def get_continue(self, request, pk=None, parent_lookup_project=None):
 
 
 class MembershipViewSet(ReadOnlyModelViewSet):
-    permission_classes = (HasModelPermission | HasObjectPermission, )
+    permission_classes = (HasModelPermission | HasProjectsPermission, )
     serializer_class = MembershipSerializer
 
     filter_backends = (DjangoFilterBackend,)
@@ -433,12 +428,9 @@ class MembershipViewSet(ReadOnlyModelViewSet):
     def get_queryset(self):
         return Membership.objects.filter_user(self.request.user)
 
-    def get_detail_permission_object(self, obj):
-        return obj.project
-
 
 class IntegrationViewSet(ReadOnlyModelViewSet):
-    permission_classes = (HasModelPermission | HasObjectPermission, )
+    permission_classes = (HasModelPermission | HasProjectsPermission, )
     serializer_class = IntegrationSerializer
 
     filter_backends = (DjangoFilterBackend, )
@@ -450,12 +442,9 @@ class IntegrationViewSet(ReadOnlyModelViewSet):
     def get_queryset(self):
         return Integration.objects.filter_user(self.request.user)
 
-    def get_detail_permission_object(self, obj):
-        return obj.project
-
 
 class IssueViewSet(ReadOnlyModelViewSet):
-    permission_classes = (HasModelPermission | HasObjectPermission, )
+    permission_classes = (HasModelPermission | HasProjectsPermission, )
     serializer_class = IssueSerializer
 
     filter_backends = (DjangoFilterBackend, )
@@ -468,12 +457,9 @@ class IssueViewSet(ReadOnlyModelViewSet):
     def get_queryset(self):
         return Issue.objects.filter_user(self.request.user).prefetch_related('resources')
 
-    def get_detail_permission_object(self, obj):
-        return obj.project
-
 
 class SnapshotViewSet(ReadOnlyModelViewSet):
-    permission_classes = (HasModelPermission | HasObjectPermission, )
+    permission_classes = (HasModelPermission | HasProjectsPermission, )
     serializer_class = SnapshotSerializer
 
     filter_backends = (DjangoFilterBackend,)
@@ -485,12 +471,9 @@ class SnapshotViewSet(ReadOnlyModelViewSet):
     def get_queryset(self):
         return Snapshot.objects.filter_user(self.request.user)
 
-    def get_detail_permission_object(self, obj):
-        return obj.project
-
 
 class ValueViewSet(ReadOnlyModelViewSet):
-    permission_classes = (HasModelPermission | HasObjectPermission, )
+    permission_classes = (HasModelPermission | HasProjectsPermission, )
     serializer_class = ValueSerializer
 
     filter_backends = (SnapshotFilterBackend, DjangoFilterBackend)
@@ -506,10 +489,7 @@ class ValueViewSet(ReadOnlyModelViewSet):
     def get_queryset(self):
         return Value.objects.filter_user(self.request.user).select_related('attribute', 'option')
 
-    def get_detail_permission_object(self, obj):
-        return obj.project
-
-    @action(detail=True, permission_classes=(HasModelPermission | HasObjectPermission, ))
+    @action(detail=True, permission_classes=(HasModelPermission | HasProjectsPermission, ))
     def file(self, request, pk=None):
         value = self.get_object()
 
diff --git a/rdmo/questions/admin.py b/rdmo/questions/admin.py
index 42e3539ddb..b4119821aa 100644
--- a/rdmo/questions/admin.py
+++ b/rdmo/questions/admin.py
@@ -89,7 +89,7 @@ class CatalogAdmin(admin.ModelAdmin):
     list_display = ('uri', 'title', 'projects_count', 'available')
     readonly_fields = ('uri', )
     list_filter = ('available', )
-    filter_horizontal = ('sections', 'sites', 'groups')
+    filter_horizontal = ('editors', 'sites', 'groups')
 
     def get_queryset(self, request):
         return super().get_queryset(request) \
@@ -112,7 +112,7 @@ class SectionAdmin(admin.ModelAdmin):
     list_display = ('uri', 'title')
     readonly_fields = ('uri', )
     list_filter = ('catalogs', )
-    filter_vertical = ('pages', )
+    filter_horizontal = ('editors', )
 
 
 class PageQuestionSetInline(admin.TabularInline):
@@ -133,8 +133,7 @@ class PageAdmin(admin.ModelAdmin):
     list_display = ('uri', 'attribute', 'is_collection')
     readonly_fields = ('uri', )
     list_filter = ('sections__catalogs', 'sections', 'is_collection')
-    filter_vertical = ('questionsets', 'questions')
-    filter_horizontal = ('conditions', )
+    filter_horizontal = ('editors', 'conditions')
 
 
 class QuestionSetQuestionSetInline(admin.TabularInline):
@@ -156,8 +155,7 @@ class QuestionSetAdmin(admin.ModelAdmin):
     list_display = ('uri', 'attribute', 'is_collection')
     readonly_fields = ('uri', )
     list_filter = ('pages__sections__catalogs', 'pages__sections', 'pages', 'is_collection')
-    filter_vertical = ('questionsets', 'questions')
-    filter_horizontal = ('conditions', )
+    filter_horizontal = ('editors', 'conditions')
 
 
 class QuestionAdmin(admin.ModelAdmin):
@@ -167,7 +165,7 @@ class QuestionAdmin(admin.ModelAdmin):
     list_display = ('uri', 'attribute', 'text', 'is_collection')
     readonly_fields = ('uri', )
     list_filter = ('pages__sections__catalogs', 'pages__sections', 'pages', 'is_collection', 'widget_type', 'value_type')
-    filter_horizontal = ('optionsets', 'conditions')
+    filter_horizontal = ('editors', 'optionsets', 'conditions')
 
 
 admin.site.register(Catalog, CatalogAdmin)
diff --git a/rdmo/questions/imports.py b/rdmo/questions/imports.py
index 30d780ece6..7a9480223c 100644
--- a/rdmo/questions/imports.py
+++ b/rdmo/questions/imports.py
@@ -46,8 +46,9 @@ def import_catalog(element, save=False):
             logger.info('Catalog created with uri %s.', element.get('uri'))
 
         catalog.save()
-        catalog.sites.add(Site.objects.get_current())
         set_m2m_through_instances(catalog, 'sections', element, 'catalog', 'section', 'catalog_sections')
+        catalog.sites.add(Site.objects.get_current())
+        catalog.editors.add(Site.objects.get_current())
 
     return catalog
 
@@ -75,6 +76,7 @@ def import_section(element, save=False):
         section.save()
         set_reverse_m2m_through_instance(section, 'catalog', element, 'section', 'catalog', 'section_catalogs')
         set_m2m_through_instances(section, 'pages', element, 'section', 'page', 'section_pages')
+        section.editors.add(Site.objects.get_current())
 
     return section
 
@@ -110,6 +112,7 @@ def import_page(element, save=False):
         set_reverse_m2m_through_instance(page, 'section', element, 'page', 'section', 'page_sections')
         set_m2m_through_instances(page, 'questionsets', element, 'page', 'questionset', 'page_questionsets')
         set_m2m_through_instances(page, 'questions', element, 'page', 'question', 'page_questions')
+        page.editors.add(Site.objects.get_current())
 
     return page
 
@@ -146,6 +149,7 @@ def import_questionset(element, save=False):
         set_reverse_m2m_through_instance(questionset, 'questionset', element, 'questionset', 'parent', 'questionset_parents')
         set_m2m_through_instances(questionset, 'questionsets', element, 'parent', 'questionset', 'questionset_questionsets')
         set_m2m_through_instances(questionset, 'questions', element, 'questionset', 'question', 'questionset_questions')
+        questionset.editors.add(Site.objects.get_current())
 
     return questionset
 
@@ -199,5 +203,6 @@ def import_question(element, save=False):
         set_reverse_m2m_through_instance(question, 'questionset', element, 'question', 'questionset', 'question_questionsets')
         set_m2m_instances(question, 'conditions', element)
         set_m2m_instances(question, 'optionsets', element)
+        question.editors.add(Site.objects.get_current())
 
     return question
diff --git a/rdmo/questions/migrations/0090_add_editors.py b/rdmo/questions/migrations/0090_add_editors.py
new file mode 100644
index 0000000000..1f8449e166
--- /dev/null
+++ b/rdmo/questions/migrations/0090_add_editors.py
@@ -0,0 +1,39 @@
+# Generated by Django 3.2.16 on 2023-03-21 09:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('sites', '0002_alter_domain_unique'),
+        ('questions', '0089_questionset_questions'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='catalog',
+            name='editors',
+            field=models.ManyToManyField(blank=True, help_text='The sites that can edit this catalog (in a multi site setup).', related_name='catalog_editors', to='sites.Site', verbose_name='Editors'),
+        ),
+        migrations.AddField(
+            model_name='page',
+            name='editors',
+            field=models.ManyToManyField(blank=True, help_text='The sites that can edit this page (in a multi site setup).', related_name='page_editors', to='sites.Site', verbose_name='Editors'),
+        ),
+        migrations.AddField(
+            model_name='question',
+            name='editors',
+            field=models.ManyToManyField(blank=True, help_text='The sites that can edit this question (in a multi site setup).', related_name='question_editors', to='sites.Site', verbose_name='Editors'),
+        ),
+        migrations.AddField(
+            model_name='questionset',
+            name='editors',
+            field=models.ManyToManyField(blank=True, help_text='The sites that can edit this questionset (in a multi site setup).', related_name='questionset_editors', to='sites.Site', verbose_name='Editors'),
+        ),
+        migrations.AddField(
+            model_name='section',
+            name='editors',
+            field=models.ManyToManyField(blank=True, help_text='The sites that can edit this section (in a multi site setup).', related_name='section_editors', to='sites.Site', verbose_name='Editors'),
+        ),
+    ]
diff --git a/rdmo/questions/models/catalog.py b/rdmo/questions/models/catalog.py
index 6a69b33022..5f7634f822 100644
--- a/rdmo/questions/models/catalog.py
+++ b/rdmo/questions/models/catalog.py
@@ -71,6 +71,11 @@ class Catalog(Model, TranslationMixin):
         verbose_name=_('Sites'),
         help_text=_('The sites this catalog belongs to (in a multi site setup).')
     )
+    editors = models.ManyToManyField(
+        Site, related_name='%(class)s_editors', blank=True,
+        verbose_name=_('Editors'),
+        help_text=_('The sites that can edit this catalog (in a multi site setup).')
+    )
     groups = models.ManyToManyField(
         Group, blank=True,
         verbose_name=_('Group'),
@@ -156,6 +161,8 @@ def copy(self, uri_prefix, uri_path):
         # copy m2m fields
         catalog.sections.set(self.sections.all())
         catalog.sites.set(self.sites.all())
+
+        # for groups, copy all
         catalog.groups.set(self.groups.all())
 
         return catalog
diff --git a/rdmo/questions/models/page.py b/rdmo/questions/models/page.py
index acd74dfbff..15c3b3f4f2 100644
--- a/rdmo/questions/models/page.py
+++ b/rdmo/questions/models/page.py
@@ -1,4 +1,5 @@
 from django.conf import settings
+from django.contrib.sites.models import Site
 from django.db import models
 from django.utils.functional import cached_property
 from django.utils.translation import gettext_lazy as _
@@ -75,6 +76,11 @@ class Page(Model, TranslationMixin):
         verbose_name=_('Questions'),
         help_text=_('The questions of this page.')
     )
+    editors = models.ManyToManyField(
+        Site, related_name='%(class)s_editors', blank=True,
+        verbose_name=_('Editors'),
+        help_text=_('The sites that can edit this page (in a multi site setup).')
+    )
     title_lang1 = models.CharField(
         max_length=256, blank=True,
         verbose_name=_('Title (primary)'),
diff --git a/rdmo/questions/models/question.py b/rdmo/questions/models/question.py
index a40ccda215..d955b17b2b 100644
--- a/rdmo/questions/models/question.py
+++ b/rdmo/questions/models/question.py
@@ -1,4 +1,5 @@
 from django.conf import settings
+from django.contrib.sites.models import Site
 from django.db import models
 from django.utils.functional import cached_property
 from django.utils.translation import gettext_lazy as _
@@ -62,6 +63,11 @@ class Question(Model, TranslationMixin):
         verbose_name=_('is optional'),
         help_text=_('Designates whether this question is optional.')
     )
+    editors = models.ManyToManyField(
+        Site, related_name='%(class)s_editors', blank=True,
+        verbose_name=_('Editors'),
+        help_text=_('The sites that can edit this question (in a multi site setup).')
+    )
     help_lang1 = models.TextField(
         null=True, blank=True,
         verbose_name=_('Help (primary)'),
diff --git a/rdmo/questions/models/questionset.py b/rdmo/questions/models/questionset.py
index b38b571769..af0d7af336 100644
--- a/rdmo/questions/models/questionset.py
+++ b/rdmo/questions/models/questionset.py
@@ -1,4 +1,5 @@
 from django.conf import settings
+from django.contrib.sites.models import Site
 from django.db import models
 from django.utils.functional import cached_property
 from django.utils.translation import gettext_lazy as _
@@ -70,6 +71,11 @@ class QuestionSet(Model, TranslationMixin):
         verbose_name=_('is collection'),
         help_text=_('Designates whether this question set is a collection.')
     )
+    editors = models.ManyToManyField(
+        Site, related_name='%(class)s_editors', blank=True,
+        verbose_name=_('Editors'),
+        help_text=_('The sites that can edit this questionset (in a multi site setup).')
+    )
     title_lang1 = models.CharField(
         max_length=256, blank=True,
         verbose_name=_('Title (primary)'),
diff --git a/rdmo/questions/models/section.py b/rdmo/questions/models/section.py
index 6c75fbd715..067dc123f2 100644
--- a/rdmo/questions/models/section.py
+++ b/rdmo/questions/models/section.py
@@ -1,4 +1,5 @@
 from django.conf import settings
+from django.contrib.sites.models import Site
 from django.db import models
 from django.utils.functional import cached_property
 from django.utils.translation import gettext_lazy as _
@@ -58,6 +59,11 @@ class Section(Model, TranslationMixin):
         verbose_name=_('Pages'),
         help_text=_('The pages of this section.')
     )
+    editors = models.ManyToManyField(
+        Site, related_name='%(class)s_editors', blank=True,
+        verbose_name=_('Editors'),
+        help_text=_('The sites that can edit this section (in a multi site setup).')
+    )
     title_lang1 = models.CharField(
         max_length=256, blank=True,
         verbose_name=_('Title (primary)'),
diff --git a/rdmo/questions/serializers/v1/catalog.py b/rdmo/questions/serializers/v1/catalog.py
index cea5bdf793..f901f52da9 100644
--- a/rdmo/questions/serializers/v1/catalog.py
+++ b/rdmo/questions/serializers/v1/catalog.py
@@ -3,6 +3,7 @@
 from rdmo.core.serializers import (ElementExportSerializerMixin,
                                    ElementModelSerializerMixin,
                                    ElementWarningSerializerMixin,
+                                   ReadOnlyObjectPermissionsSerializerMixin,
                                    ThroughModelSerializerMixin,
                                    TranslationSerializerMixin)
 
@@ -22,12 +23,14 @@ class Meta:
 
 
 class CatalogSerializer(ThroughModelSerializerMixin, TranslationSerializerMixin,
-                        ElementModelSerializerMixin, serializers.ModelSerializer):
+                        ElementModelSerializerMixin, ReadOnlyObjectPermissionsSerializerMixin,
+                        serializers.ModelSerializer):
 
     model = serializers.SerializerMethodField()
     uri_path = serializers.CharField(required=True)
     projects_count = serializers.IntegerField(read_only=True)
     sections = CatalogSectionSerializer(source='catalog_sections', read_only=False, required=False, many=True)
+    read_only = serializers.SerializerMethodField()
 
     class Meta:
         model = Catalog
@@ -43,6 +46,8 @@ class Meta:
             'available',
             'sections',
             'sites',
+            'editors',
+            'read_only',
             'groups',
             'title',
             'help',
diff --git a/rdmo/questions/serializers/v1/page.py b/rdmo/questions/serializers/v1/page.py
index 536f999e1b..7ef7743a39 100644
--- a/rdmo/questions/serializers/v1/page.py
+++ b/rdmo/questions/serializers/v1/page.py
@@ -3,6 +3,7 @@
 from rdmo.core.serializers import (ElementExportSerializerMixin,
                                    ElementModelSerializerMixin,
                                    ElementWarningSerializerMixin,
+                                   ReadOnlyObjectPermissionsSerializerMixin,
                                    ThroughModelSerializerMixin,
                                    TranslationSerializerMixin)
 
@@ -33,13 +34,15 @@ class Meta:
 
 
 class PageSerializer(ThroughModelSerializerMixin, TranslationSerializerMixin,
-                     ElementModelSerializerMixin, serializers.ModelSerializer):
+                     ElementModelSerializerMixin, ReadOnlyObjectPermissionsSerializerMixin,
+                     serializers.ModelSerializer):
 
     model = serializers.SerializerMethodField()
     uri_path = serializers.CharField(required=True)
     sections = serializers.PrimaryKeyRelatedField(queryset=Section.objects.all(), required=False, many=True)
     questionsets = PageQuestionSetSerializer(source='page_questionsets', read_only=False, required=False, many=True)
     questions = PageQuestionSerializer(source='page_questions', read_only=False, required=False, many=True)
+    read_only = serializers.SerializerMethodField()
 
     class Meta:
         model = Page
@@ -60,7 +63,9 @@ class Meta:
             'sections',
             'questionsets',
             'questions',
-            'conditions'
+            'conditions',
+            'editors',
+            'read_only'
         )
         trans_fields = (
             'title',
diff --git a/rdmo/questions/serializers/v1/question.py b/rdmo/questions/serializers/v1/question.py
index f88d9c8153..1c615edd9e 100644
--- a/rdmo/questions/serializers/v1/question.py
+++ b/rdmo/questions/serializers/v1/question.py
@@ -3,6 +3,7 @@
 from rdmo.core.serializers import (ElementExportSerializerMixin,
                                    ElementModelSerializerMixin,
                                    ElementWarningSerializerMixin,
+                                   ReadOnlyObjectPermissionsSerializerMixin,
                                    ThroughModelSerializerMixin,
                                    TranslationSerializerMixin)
 
@@ -11,12 +12,14 @@
 
 
 class QuestionSerializer(ThroughModelSerializerMixin, TranslationSerializerMixin,
-                         ElementModelSerializerMixin, serializers.ModelSerializer):
+                         ElementModelSerializerMixin, ReadOnlyObjectPermissionsSerializerMixin,
+                         serializers.ModelSerializer):
 
     model = serializers.SerializerMethodField()
     uri_path = serializers.CharField(required=True)
     pages = serializers.PrimaryKeyRelatedField(queryset=Page.objects.all(), required=False, many=True)
     questionsets = serializers.PrimaryKeyRelatedField(queryset=QuestionSet.objects.all(), required=False, many=True)
+    read_only = serializers.SerializerMethodField()
 
     class Meta:
         model = Question
@@ -48,7 +51,9 @@ class Meta:
             'pages',
             'questionsets',
             'optionsets',
-            'conditions'
+            'conditions',
+            'editors',
+            'read_only'
         )
         trans_fields = (
             'text',
diff --git a/rdmo/questions/serializers/v1/questionset.py b/rdmo/questions/serializers/v1/questionset.py
index cc7928bb55..d0f884c28a 100644
--- a/rdmo/questions/serializers/v1/questionset.py
+++ b/rdmo/questions/serializers/v1/questionset.py
@@ -3,6 +3,7 @@
 from rdmo.core.serializers import (ElementExportSerializerMixin,
                                    ElementModelSerializerMixin,
                                    ElementWarningSerializerMixin,
+                                   ReadOnlyObjectPermissionsSerializerMixin,
                                    ThroughModelSerializerMixin,
                                    TranslationSerializerMixin)
 
@@ -35,7 +36,8 @@ class Meta:
 
 
 class QuestionSetSerializer(ThroughModelSerializerMixin, TranslationSerializerMixin,
-                            ElementModelSerializerMixin, serializers.ModelSerializer):
+                            ElementModelSerializerMixin, ReadOnlyObjectPermissionsSerializerMixin,
+                            serializers.ModelSerializer):
 
     model = serializers.SerializerMethodField()
     uri_path = serializers.CharField(required=True)
@@ -43,6 +45,7 @@ class QuestionSetSerializer(ThroughModelSerializerMixin, TranslationSerializerMi
     parents = serializers.PrimaryKeyRelatedField(queryset=QuestionSet.objects.all(), required=False, many=True)
     questionsets = QuestionSetQuestionSetSerializer(source='questionset_questionsets', read_only=False, required=False, many=True)
     questions = QuestionSetQuestionSerializer(source='questionset_questions', read_only=False, required=False, many=True)
+    read_only = serializers.SerializerMethodField()
 
     class Meta:
         model = QuestionSet
@@ -64,7 +67,9 @@ class Meta:
             'parents',
             'questionsets',
             'questions',
-            'conditions'
+            'conditions',
+            'editors',
+            'read_only'
         )
         trans_fields = (
             'title',
diff --git a/rdmo/questions/serializers/v1/section.py b/rdmo/questions/serializers/v1/section.py
index ba48127b5b..be08756c89 100644
--- a/rdmo/questions/serializers/v1/section.py
+++ b/rdmo/questions/serializers/v1/section.py
@@ -3,6 +3,7 @@
 from rdmo.core.serializers import (ElementExportSerializerMixin,
                                    ElementModelSerializerMixin,
                                    ElementWarningSerializerMixin,
+                                   ReadOnlyObjectPermissionsSerializerMixin,
                                    ThroughModelSerializerMixin,
                                    TranslationSerializerMixin)
 
@@ -22,12 +23,14 @@ class Meta:
 
 
 class SectionSerializer(ThroughModelSerializerMixin, TranslationSerializerMixin,
-                        ElementModelSerializerMixin, serializers.ModelSerializer):
+                        ElementModelSerializerMixin, ReadOnlyObjectPermissionsSerializerMixin,
+                        serializers.ModelSerializer):
 
     model = serializers.SerializerMethodField()
     uri_path = serializers.CharField(required=True)
     catalogs = serializers.PrimaryKeyRelatedField(queryset=Catalog.objects.all(), required=False, many=True)
     pages = SectionPageSerializer(source='section_pages', read_only=False, required=False, many=True)
+    read_only = serializers.SerializerMethodField()
 
     class Meta:
         model = Section
@@ -41,7 +44,9 @@ class Meta:
             'locked',
             'title',
             'catalogs',
-            'pages'
+            'pages',
+            'editors',
+            'read_only'
         )
         trans_fields = (
             'title',
diff --git a/rdmo/questions/static/questions/js/catalogs.js b/rdmo/questions/static/questions/js/catalogs.js
index 4568737750..6da8a21f5e 100644
--- a/rdmo/questions/static/questions/js/catalogs.js
+++ b/rdmo/questions/static/questions/js/catalogs.js
@@ -283,6 +283,7 @@ angular.module('catalogs', ['core'])
 
     service.openDeleteModal = function(resource, obj) {
         service.values = obj;
+        service.errors = {};
         $('#' + resource + '-delete-modal').modal('show');
     };
 
@@ -305,6 +306,9 @@ angular.module('catalogs', ['core'])
             }
 
             $('#' + resource + '-delete-modal').modal('hide');
+        }
+        , function(result) {
+            service.errors = result.data;
         });
     };
 
diff --git a/rdmo/questions/templates/questions/catalogs.html b/rdmo/questions/templates/questions/catalogs.html
index 98f7491e26..1f952ed893 100644
--- a/rdmo/questions/templates/questions/catalogs.html
+++ b/rdmo/questions/templates/questions/catalogs.html
@@ -48,7 +48,9 @@ <h1>{% trans 'Questions' %}</h1>
                     {% include 'questions/catalogs_options_catalog.html' %}
                 </div>
                 <p>
-                    <strong>{% trans 'Catalog' %}</strong>
+                    <strong ng-class="{'text-muted': service.catalog.read_only}">
+                        {% trans 'Catalog' %}
+                    </strong>
                     <span>{$ service.catalog.title $}</span>
                 </p>
                 <p>
@@ -79,6 +81,24 @@ <h1>{% trans 'Questions' %}</h1>
                     </div>
                 </div>
             </div>
+            <div class="panel-body">
+                <div class="row">
+                    <div class="col-sm-3">
+                        <strong>{% trans 'Editors for this catalog' %}</strong>
+                    </div>
+                    <div class="col-sm-9">
+                         <ul>
+                            <li ng-show="service.catalog.editors.length > 0 && service.catalog.editors.length < service.sites.length"
+                                ng-repeat="site in service.catalog.editors">
+                                {$ site.name $}
+                            </li>
+                            <li ng-show="service.catalog.editors.length == 0 || service.catalog.editors.length == service.sites.length">
+                                {% trans 'All sites' %}
+                            </li>
+                        </ul>
+                    </div>
+                </div>
+            </div>
             {% endif %}
         </div>
 
@@ -94,7 +114,9 @@ <h1>{% trans 'Questions' %}</h1>
                         {% include 'questions/catalogs_options_sections.html' %}
                     </div>
                     <p ng-style="(!service.showURIs) && {'margin-bottom': '0px'}">
-                        <strong>{% trans 'Section' %}</strong>
+                        <strong ng-class="{'text-muted': section.read_only}">
+                            {% trans 'Section' %}
+                        </strong>
                         <span>{$ section.title $}</span>
                     </p>
                     <div ng-show="service.showURIs">
diff --git a/rdmo/questions/templates/questions/catalogs_element.html b/rdmo/questions/templates/questions/catalogs_element.html
index 5944b5708c..799b21020b 100644
--- a/rdmo/questions/templates/questions/catalogs_element.html
+++ b/rdmo/questions/templates/questions/catalogs_element.html
@@ -16,7 +16,9 @@
         </div>
 
         <p ng-style="!service.showURIs && {'margin-bottom':'0px'}">
-            <strong>{% trans 'Question set' %}</strong>
+            <strong ng-class="{'text-muted': questionset.read_only}">
+                {% trans 'Question set' %}
+            </strong>
             <span>{$ questionset.title $}</span>
         </p>
         <div ng-show="service.showURIs">
@@ -49,7 +51,7 @@
             {% include 'questions/catalogs_options_questions.html' %}
         </div>
         <p ng-style="(!service.showURIs) && {'margin-bottom':'0px'}">
-            <strong ng-class="{'text-muted': question.is_optional}">{% trans 'Question' %}</strong>
+            <strong ng-class="{'text-muted': question.is_optional || question.read_only}">{% trans 'Question' %}</strong>
             <span>{$ question.text $}</span>
         </p>
         <div ng-show="service.showURIs && service.showURIQuestions">
diff --git a/rdmo/questions/templates/questions/catalogs_modal_delete_catalogs.html b/rdmo/questions/templates/questions/catalogs_modal_delete_catalogs.html
index 51fd232fd1..a250937e42 100644
--- a/rdmo/questions/templates/questions/catalogs_modal_delete_catalogs.html
+++ b/rdmo/questions/templates/questions/catalogs_modal_delete_catalogs.html
@@ -61,10 +61,14 @@ <h2 class="modal-title">
                 </div>
 
                 <div class="modal-footer">
+                    <div class="alert alert-warning pull-left" ng-show="service.errors.detail" role="alert">
+                        {$ service.errors.detail $}
+                    </div>
                     <button type="button" class="btn btn-default" data-dismiss="modal">
                         {% trans 'Close' %}
                     </button>
                     <button type="button" class="btn btn-danger"
+                            ng-class="{'disabled': service.catalog.read_only}"
                             ng-click="service.submitDeleteModal('catalogs')">
                         {% trans 'Delete' %}
                     </button>
diff --git a/rdmo/questions/templates/questions/catalogs_modal_delete_questions.html b/rdmo/questions/templates/questions/catalogs_modal_delete_questions.html
index c58f686e00..e81173ebc7 100644
--- a/rdmo/questions/templates/questions/catalogs_modal_delete_questions.html
+++ b/rdmo/questions/templates/questions/catalogs_modal_delete_questions.html
@@ -24,10 +24,14 @@ <h2 class="modal-title">
                 </div>
 
                 <div class="modal-footer">
+                    <div class="alert alert-warning pull-left" ng-show="service.errors.detail" role="alert">
+                        {$ service.errors.detail $}
+                    </div>
                     <button type="button" class="btn btn-default" data-dismiss="modal">
                         {% trans 'Close' %}
                     </button>
                     <button type="button" class="btn btn-danger"
+                            ng-class="{'disabled': service.values.read_only}"
                             ng-click="service.submitDeleteModal('questions')">
                         {% trans 'Delete' %}
                     </button>
diff --git a/rdmo/questions/templates/questions/catalogs_modal_delete_questionsets.html b/rdmo/questions/templates/questions/catalogs_modal_delete_questionsets.html
index 64379f7e0b..3bb8e800c1 100644
--- a/rdmo/questions/templates/questions/catalogs_modal_delete_questionsets.html
+++ b/rdmo/questions/templates/questions/catalogs_modal_delete_questionsets.html
@@ -40,10 +40,14 @@ <h2 class="modal-title">
                 </div>
 
                 <div class="modal-footer">
+                    <div class="alert alert-warning pull-left" ng-show="service.errors.detail" role="alert">
+                        {$ service.errors.detail $}
+                    </div>
                     <button type="button" class="btn btn-default" data-dismiss="modal">
                         {% trans 'Close' %}
                     </button>
                     <button type="button" class="btn btn-danger"
+                            ng-class="{'disabled': service.values.read_only}"
                             ng-click="service.submitDeleteModal('questionsets')">
                         {% trans 'Delete' %}
                     </button>
diff --git a/rdmo/questions/templates/questions/catalogs_modal_delete_sections.html b/rdmo/questions/templates/questions/catalogs_modal_delete_sections.html
index 5d7f579e07..6c245bca48 100644
--- a/rdmo/questions/templates/questions/catalogs_modal_delete_sections.html
+++ b/rdmo/questions/templates/questions/catalogs_modal_delete_sections.html
@@ -48,10 +48,14 @@ <h2 class="modal-title">
                 </div>
 
                 <div class="modal-footer">
+                    <div class="alert alert-warning pull-left" ng-show="service.errors.detail" role="alert">
+                        {$ service.errors.detail $}
+                    </div>
                     <button type="button" class="btn btn-default" data-dismiss="modal">
                         {% trans 'Close' %}
                     </button>
                     <button type="button" class="btn btn-danger"
+                            ng-class="{'disabled': service.values.read_only}"
                             ng-click="service.submitDeleteModal('sections')">
                         {% trans 'Delete' %}
                     </button>
diff --git a/rdmo/questions/templates/questions/catalogs_modal_form_catalogs.html b/rdmo/questions/templates/questions/catalogs_modal_form_catalogs.html
index 2d2b27b5f7..1870229e64 100644
--- a/rdmo/questions/templates/questions/catalogs_modal_form_catalogs.html
+++ b/rdmo/questions/templates/questions/catalogs_modal_form_catalogs.html
@@ -133,6 +133,16 @@ <h2 class="modal-title">
                                         <i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
                                     </a>
                                 </li>
+                                <li role="presentation" ng-class="{
+                                    'has-error': service.errors.editors,
+                                }">
+                                    <a class="control-label" href="#catalogs-editors"
+                                        role="tab" data-toggle="tab" aria-controls="catalogs-editors">
+
+                                        {% trans 'Editors' %}
+                                        <i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
+                                    </a>
+                                </li>
                                 {% endif %}
                             </ul>
                             <div class="tab-content">
@@ -201,6 +211,18 @@ <h2 class="modal-title">
                                         data-help="{% trans 'Hold down Control, or Command on a Mac, to select more than one.' %}">
                                     </formgroup>
                                 </div>
+                                <div role="tabpanel" class="tab-pane" id="catalogs-editors">
+                                    <formgroup
+                                        data-id="catalog_editors"
+                                        data-type="selectmultiple"
+                                        data-label="{% trans 'Editors' %}"
+                                        data-model="service.values.editors"
+                                        data-errors="service.errors.editors"
+                                        data-options="service.sites"
+                                        data-options-label="name"
+                                        data-help="{% trans 'Hold down Control, or Command on a Mac, to select more than one.' %}">
+                                    </formgroup>
+                                </div>
                                 {% endif %}
                             </div>
                         </div>
@@ -208,10 +230,14 @@ <h2 class="modal-title">
                 </div>
 
                 <div class="modal-footer">
+                    <div class="alert alert-warning pull-left" ng-show="service.errors.detail" role="alert">
+                        {$ service.errors.detail $}
+                    </div>
                     <button type="button" class="btn btn-default" data-dismiss="modal">
                         {% trans 'Close' %}
                     </button>
-                    <button type="button" class="btn btn-primary"
+                    <button type="button" class="btn btn-primary" data-dismiss="alert" 
+                            ng-class="{'disabled': service.catalog.read_only}"
                             ng-click="service.submitFormModal('catalogs')">
                         {% trans 'Save' %}
                     </button>
diff --git a/rdmo/questions/templates/questions/catalogs_modal_form_questions.html b/rdmo/questions/templates/questions/catalogs_modal_form_questions.html
index de59a1826b..865055a985 100644
--- a/rdmo/questions/templates/questions/catalogs_modal_form_questions.html
+++ b/rdmo/questions/templates/questions/catalogs_modal_form_questions.html
@@ -188,6 +188,18 @@ <h2 class="modal-title">
                                         <i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
                                     </a>
                                 </li>
+                                {% if settings.MULTISITE %}
+                                <li role="presentation" ng-class="{
+                                    'has-error': service.errors.editors,
+                                }">
+                                    <a class="control-label" href="#questions-editors"
+                                        role="tab" data-toggle="tab" aria-controls="questions-editors">
+
+                                        {% trans 'Editors' %}
+                                        <i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
+                                    </a>
+                                </li>
+                                {% endif %}
                             </ul>
 
                             <div class="tab-content">
@@ -449,16 +461,34 @@ <h2 class="modal-title">
                                         </div>
                                     </div>
                                 </div>
+                                {% if settings.MULTISITE %}
+                                <div role="tabpanel" class="tab-pane" id="questions-editors">
+                                    <formgroup
+                                        data-id="question_editors"
+                                        data-type="selectmultiple"
+                                        data-label="{% trans 'Editors' %}"
+                                        data-model="service.values.editors"
+                                        data-errors="service.errors.editors"
+                                        data-options="service.sites"
+                                        data-options-label="name"
+                                        data-help="{% trans 'Hold down Control, or Command on a Mac, to select more than one.' %}">
+                                    </formgroup>
+                                </div>
+                                {% endif %}
                             </div>
                         </div>
                     </div>
                 </div>
 
                 <div class="modal-footer">
+                    <div class="alert alert-warning pull-left" ng-show="service.errors.detail" role="alert">
+                        {$ service.errors.detail $}
+                    </div>
                     <button type="button" class="btn btn-default" data-dismiss="modal">
                         {% trans 'Close' %}
                     </button>
                     <button type="button" class="btn btn-primary"
+                            ng-class="{'disabled': service.values.read_only}"
                             ng-click="service.submitFormModal('questions')">
                         <span ng-show="service.copy">{% trans 'Copy' %}</span>
                         <span ng-hide="service.copy">{% trans 'Save' %}</span>
diff --git a/rdmo/questions/templates/questions/catalogs_modal_form_questionsets.html b/rdmo/questions/templates/questions/catalogs_modal_form_questionsets.html
index caf6c583fe..48159634df 100644
--- a/rdmo/questions/templates/questions/catalogs_modal_form_questionsets.html
+++ b/rdmo/questions/templates/questions/catalogs_modal_form_questionsets.html
@@ -159,6 +159,18 @@ <h2 class="modal-title">
                                         <i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
                                     </a>
                                 </li>
+                                {% if settings.MULTISITE %}
+                                <li role="presentation" ng-class="{
+                                    'has-error': service.errors.editors,
+                                }">
+                                    <a class="control-label" href="#questionsets-editors"
+                                        role="tab" data-toggle="tab" aria-controls="questionsets-editors">
+
+                                        {% trans 'Editors' %}
+                                        <i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
+                                    </a>
+                                </li>
+                                {% endif %}
                             </ul>
 
                             <div class="tab-content">
@@ -293,16 +305,34 @@ <h2 class="modal-title">
                                         data-help="{% trans 'Hold down Control, or Command on a Mac, to select more than one.' %}">
                                     </formgroup>
                                 </div>
+                                {% if settings.MULTISITE %}
+                                <div role="tabpanel" class="tab-pane" id="questionsets-editors">
+                                    <formgroup
+                                        data-id="questionsets_editors"
+                                        data-type="selectmultiple"
+                                        data-label="{% trans 'Editors' %}"
+                                        data-model="service.values.editors"
+                                        data-errors="service.errors.editors"
+                                        data-options="service.sites"
+                                        data-options-label="name"
+                                        data-help="{% trans 'Hold down Control, or Command on a Mac, to select more than one.' %}">
+                                    </formgroup>
+                                </div>
+                                {% endif %}
                             </div>
                         </div>
                     </div>
                 </div>
 
                 <div class="modal-footer">
+                    <div class="alert alert-warning pull-left" ng-show="service.errors.detail" role="alert">
+                        {$ service.errors.detail $}
+                    </div>
                     <button type="button" class="btn btn-default" data-dismiss="modal">
                         {% trans 'Close' %}
                     </button>
                     <button type="button" class="btn btn-primary"
+                            ng-class="{'disabled': service.values.read_only}"
                             ng-click="service.submitFormModal('questionsets')">
                         <span ng-show="service.copy">{% trans 'Copy' %}</span>
                         <span ng-hide="service.copy">{% trans 'Save' %}</span>
diff --git a/rdmo/questions/templates/questions/catalogs_modal_form_sections.html b/rdmo/questions/templates/questions/catalogs_modal_form_sections.html
index 774b1cbe8a..23e5aee037 100644
--- a/rdmo/questions/templates/questions/catalogs_modal_form_sections.html
+++ b/rdmo/questions/templates/questions/catalogs_modal_form_sections.html
@@ -126,6 +126,18 @@ <h2 class="modal-title">
                                     </a>
                                 </li>
                                 {% endfor %}
+                                {% if settings.MULTISITE %}
+                                <li role="presentation" ng-class="{
+                                    'has-error': service.errors.editors,
+                                }">
+                                    <a class="control-label" href="#sections-editors"
+                                        role="tab" data-toggle="tab" aria-controls="sections-editors">
+
+                                        {% trans 'Editors' %}
+                                        <i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
+                                    </a>
+                                </li>
+                                {% endif %}
                             </ul>
                             <div class="tab-content">
                                 <div role="tabpanel" class="tab-pane active" id="sections-general">
@@ -169,16 +181,34 @@ <h2 class="modal-title">
                                     </formgroup>
                                 </div>
                                 {% endfor %}
+                                {% if settings.MULTISITE %}
+                                <div role="tabpanel" class="tab-pane" id="sections-editors">
+                                    <formgroup
+                                        data-id="section_editors"
+                                        data-type="selectmultiple"
+                                        data-label="{% trans 'Editors' %}"
+                                        data-model="service.values.editors"
+                                        data-errors="service.errors.editors"
+                                        data-options="service.sites"
+                                        data-options-label="name"
+                                        data-help="{% trans 'Hold down Control, or Command on a Mac, to select more than one.' %}">
+                                    </formgroup>
+                                </div>
+                                {% endif %}
                             </div>
                         </div>
                     </div>
                 </div>
 
                 <div class="modal-footer">
+                    <div class="alert alert-warning pull-left" ng-show="service.errors.detail" role="alert">
+                        {$ service.errors.detail $}
+                    </div>
                     <button type="button" class="btn btn-default" data-dismiss="modal">
                         {% trans 'Close' %}
                     </button>
                     <button type="button" class="btn btn-primary"
+                            ng-class="{'disabled': service.values.read_only}"
                             ng-click="service.submitFormModal('sections')">
                         {% trans 'Save' %}
                     </button>
diff --git a/rdmo/questions/templates/questions/catalogs_options_catalog.html b/rdmo/questions/templates/questions/catalogs_options_catalog.html
index a3c256e36f..9d787df88d 100644
--- a/rdmo/questions/templates/questions/catalogs_options_catalog.html
+++ b/rdmo/questions/templates/questions/catalogs_options_catalog.html
@@ -1,6 +1,9 @@
 {% load i18n %}
-
+    
     <i class="fa fa-lock text-muted" ng-show="service.catalog.locked"></i>
+    <i class="fa fa-ban text-muted" ng-show="service.catalog.read_only"
+        title="{% trans 'Read only' %}">
+    </i>
 
     <a href="" class="fa fa-plus"
         title="{% trans 'Add section' %}"
diff --git a/rdmo/questions/templates/questions/catalogs_options_questions.html b/rdmo/questions/templates/questions/catalogs_options_questions.html
index 1fea596126..39b0dee357 100644
--- a/rdmo/questions/templates/questions/catalogs_options_questions.html
+++ b/rdmo/questions/templates/questions/catalogs_options_questions.html
@@ -2,6 +2,9 @@
 
     <i class="fa fa-lock text-muted" ng-show="questionset.locked || section.locked || service.catalog.locked"></i>
     <i class="fa fa-lock text-danger" ng-show="question.locked"></i>
+    <i class="fa fa-ban text-muted" ng-show="question.read_only"
+        title="{% trans 'Read only' %}">
+    </i>
 
     <a href="" class="fa fa-pencil"
         title="{% trans 'Update question' %}"
diff --git a/rdmo/questions/templates/questions/catalogs_options_questionsets.html b/rdmo/questions/templates/questions/catalogs_options_questionsets.html
index 7940fe6afb..c8dc7cf86e 100644
--- a/rdmo/questions/templates/questions/catalogs_options_questionsets.html
+++ b/rdmo/questions/templates/questions/catalogs_options_questionsets.html
@@ -2,6 +2,9 @@
 
     <i class="fa fa-lock text-muted" ng-show="section.locked || service.catalog.locked"></i>
     <i class="fa fa-lock text-danger" ng-show="questionset.locked"></i>
+    <i class="fa fa-ban text-muted" ng-show="questionset.read_only"
+        title="{% trans 'Read only' %}">
+    </i>
 
     <a href="" class="fa fa-plus"
         title="{% trans 'Add question' %}"
diff --git a/rdmo/questions/templates/questions/catalogs_options_sections.html b/rdmo/questions/templates/questions/catalogs_options_sections.html
index ee6cecfc23..b8b410755e 100644
--- a/rdmo/questions/templates/questions/catalogs_options_sections.html
+++ b/rdmo/questions/templates/questions/catalogs_options_sections.html
@@ -2,6 +2,9 @@
 
     <i class="fa fa-lock text-muted" ng-show="service.catalog.locked"></i>
     <i class="fa fa-lock text-danger" ng-show="section.locked"></i>
+    <i class="fa fa-ban text-muted" ng-show="section.read_only"
+        title="{% trans 'Read only' %}">
+    </i>
 
     <a href="" class="fa fa-plus"
         title="{% trans 'Add question set' %}"
diff --git a/rdmo/questions/viewsets.py b/rdmo/questions/viewsets.py
index e63248d05c..c36f0f40b4 100644
--- a/rdmo/questions/viewsets.py
+++ b/rdmo/questions/viewsets.py
@@ -8,7 +8,7 @@
 
 from rdmo.core.constants import VALUE_TYPE_CHOICES
 from rdmo.core.exports import XMLResponse
-from rdmo.core.permissions import HasModelPermission
+from rdmo.core.permissions import HasModelPermission, HasObjectPermission
 from rdmo.core.utils import is_truthy, render_to_format
 from rdmo.core.views import ChoicesViewSet
 from rdmo.core.viewsets import CopyModelMixin
@@ -35,7 +35,7 @@
 
 
 class CatalogViewSet(CopyModelMixin, ModelViewSet):
-    permission_classes = (HasModelPermission, )
+    permission_classes = (HasModelPermission | HasObjectPermission,)
 
     filter_backends = (SearchFilter, DjangoFilterBackend)
     search_fields = ('uri', 'title')
@@ -52,7 +52,7 @@ def get_queryset(self):
         if self.action in ('nested', 'export', 'detail_export'):
             return queryset.prefetch_elements()
         else:
-            return queryset.prefetch_related('sites', 'groups', 'catalog_sections__section')
+            return queryset.prefetch_related('sites', 'editors', 'groups', 'catalog_sections__section')
 
     def get_serializer_class(self):
         return CatalogListSerializer if self.action == 'list' else CatalogSerializer
@@ -65,10 +65,10 @@ def nested(self, request, pk):
     @action(detail=False)
     def index(self, request):
         queryset = self.filter_queryset(self.get_queryset())
-        serializer = CatalogIndexSerializer(queryset, many=True)
+        serializer = CatalogIndexSerializer(queryset, many=True, context={'request': request})
         return Response(serializer.data)
 
-    @action(detail=False, url_path='export(/(?P<export_format>[a-z]+))?', permission_classes=[HasModelPermission])
+    @action(detail=False, url_path='export(/(?P<export_format>[a-z]+))?')
     def export(self, request, export_format='xml'):
         queryset = self.filter_queryset(self.get_queryset())
         if export_format == 'xml':
@@ -80,7 +80,7 @@ def export(self, request, export_format='xml'):
                 'catalogs': queryset
             })
 
-    @action(detail=True, url_path='export(/(?P<export_format>[a-z]+))?', permission_classes=[HasModelPermission])
+    @action(detail=True, url_path='export(/(?P<export_format>[a-z]+))?')
     def detail_export(self, request, pk=None, export_format='xml'):
         if export_format == 'xml':
             serializer = CatalogExportSerializer(self.get_object())
@@ -106,7 +106,7 @@ def get_export_renderer_context(self, request):
 
 
 class SectionViewSet(CopyModelMixin, ModelViewSet):
-    permission_classes = (HasModelPermission, )
+    permission_classes = (HasModelPermission | HasObjectPermission, )
 
     filter_backends = (SearchFilter, DjangoFilterBackend)
     search_fields = ('uri', 'title')
@@ -122,7 +122,7 @@ def get_queryset(self):
         if self.action in ('nested', 'export', 'detail_export'):
             return queryset.prefetch_elements()
         else:
-            return queryset.prefetch_related('catalogs', 'section_pages__page')
+            return queryset.prefetch_related('catalogs', 'editors', 'section_pages__page')
 
     def get_serializer_class(self):
         return SectionListSerializer if self.action == 'list' else SectionSerializer
@@ -138,7 +138,7 @@ def index(self, request):
         serializer = SectionIndexSerializer(queryset, many=True)
         return Response(serializer.data)
 
-    @action(detail=False, url_path='export(/(?P<export_format>[a-z]+))?', permission_classes=[HasModelPermission])
+    @action(detail=False, url_path='export(/(?P<export_format>[a-z]+))?')
     def export(self, request, export_format='xml'):
         queryset = self.filter_queryset(self.get_queryset())
         if export_format == 'xml':
@@ -150,7 +150,7 @@ def export(self, request, export_format='xml'):
                 'sections': queryset
             })
 
-    @action(detail=True, url_path='export(/(?P<export_format>[a-z]+))?', permission_classes=[HasModelPermission])
+    @action(detail=True, url_path='export(/(?P<export_format>[a-z]+))?')
     def detail_export(self, request, pk=None, export_format='xml'):
         if export_format == 'xml':
             serializer = SectionExportSerializer(self.get_object())
@@ -175,7 +175,7 @@ def get_export_renderer_context(self, request):
 
 
 class PageViewSet(CopyModelMixin, ModelViewSet):
-    permission_classes = (HasModelPermission, )
+    permission_classes = (HasModelPermission | HasObjectPermission, )
 
     filter_backends = (SearchFilter, DjangoFilterBackend)
     search_fields = ('uri', 'title')
@@ -196,6 +196,7 @@ def get_queryset(self):
             return queryset.prefetch_related(
                 'conditions',
                 'sections',
+                'editors',
                 'page_questionsets__questionset',
                 'page_questions__question'
             ).select_related('attribute')
@@ -214,7 +215,7 @@ def index(self, request):
         serializer = PageIndexSerializer(queryset, many=True)
         return Response(serializer.data)
 
-    @action(detail=False, url_path='export(/(?P<export_format>[a-z]+))?', permission_classes=[HasModelPermission])
+    @action(detail=False, url_path='export(/(?P<export_format>[a-z]+))?')
     def export(self, request, export_format='xml'):
         queryset = self.filter_queryset(self.get_queryset())
         if export_format == 'xml':
@@ -226,7 +227,7 @@ def export(self, request, export_format='xml'):
                 'pages': queryset
             })
 
-    @action(detail=True, url_path='export(/(?P<export_format>[a-z]+))?', permission_classes=[HasModelPermission])
+    @action(detail=True, url_path='export(/(?P<export_format>[a-z]+))?')
     def detail_export(self, request, pk=None, export_format='xml'):
         if export_format == 'xml':
             serializer = PageExportSerializer(self.get_object())
@@ -250,7 +251,7 @@ def get_export_renderer_context(self, request):
 
 
 class QuestionSetViewSet(CopyModelMixin, ModelViewSet):
-    permission_classes = (HasModelPermission, )
+    permission_classes = (HasModelPermission | HasObjectPermission, )
 
     filter_backends = (SearchFilter, DjangoFilterBackend)
     search_fields = ('uri', 'title')
@@ -275,6 +276,7 @@ def get_queryset(self):
                 'conditions',
                 'pages',
                 'parents',
+                'editors',
                 'questionset_questionsets__questionset',
                 'questionset_questions__question'
             ).select_related('attribute')
@@ -287,10 +289,10 @@ def nested(self, request, pk):
     @action(detail=False)
     def index(self, request):
         queryset = self.filter_queryset(self.get_queryset())
-        serializer = QuestionSetIndexSerializer(queryset, many=True)
+        serializer = QuestionSetIndexSerializer(queryset, many=True, context={'request': request})
         return Response(serializer.data)
 
-    @action(detail=False, url_path='export(/(?P<export_format>[a-z]+))?', permission_classes=[HasModelPermission])
+    @action(detail=False, url_path='export(/(?P<export_format>[a-z]+))?')
     def export(self, request, export_format='xml'):
         queryset = self.filter_queryset(self.get_queryset())
         if export_format == 'xml':
@@ -302,7 +304,7 @@ def export(self, request, export_format='xml'):
                 'questionsets': queryset
             })
 
-    @action(detail=True, url_path='export(/(?P<export_format>[a-z]+))?', permission_classes=[HasModelPermission])
+    @action(detail=True, url_path='export(/(?P<export_format>[a-z]+))?')
     def detail_export(self, request, pk=None, export_format='xml'):
         if export_format == 'xml':
             serializer = QuestionSetExportSerializer(self.get_object())
@@ -326,7 +328,7 @@ def get_export_renderer_context(self, request):
 
 
 class QuestionViewSet(CopyModelMixin, ModelViewSet):
-    permission_classes = (HasModelPermission, )
+    permission_classes = (HasModelPermission | HasObjectPermission, )
 
     filter_backends = (SearchFilter, DjangoFilterBackend)
     search_fields = ('uri', 'text')
@@ -354,7 +356,8 @@ def get_queryset(self):
                 'conditions',
                 'optionsets',
                 'pages',
-                'questionsets'
+                'questionsets',
+                'editors'
             ).select_related('attribute')
 
     @action(detail=False)
@@ -363,7 +366,7 @@ def index(self, request):
         serializer = QuestionIndexSerializer(queryset, many=True)
         return Response(serializer.data)
 
-    @action(detail=False, url_path='export(/(?P<export_format>[a-z]+))?', permission_classes=[HasModelPermission])
+    @action(detail=False, url_path='export(/(?P<export_format>[a-z]+))?')
     def export(self, request, export_format='xml'):
         queryset = self.filter_queryset(self.get_queryset())
         if export_format == 'xml':
@@ -375,7 +378,7 @@ def export(self, request, export_format='xml'):
                 'questions': queryset
             })
 
-    @action(detail=True, url_path='export(/(?P<export_format>[a-z]+))?', permission_classes=[HasModelPermission])
+    @action(detail=True, url_path='export(/(?P<export_format>[a-z]+))?')
     def detail_export(self, request, pk=None, export_format='xml'):
         if export_format == 'xml':
             serializer = QuestionExportSerializer(self.get_object())
diff --git a/rdmo/tasks/admin.py b/rdmo/tasks/admin.py
index 82884a64cc..e544fc3ffc 100644
--- a/rdmo/tasks/admin.py
+++ b/rdmo/tasks/admin.py
@@ -26,6 +26,7 @@ class TaskAdmin(admin.ModelAdmin):
     list_display = ('uri', 'title', 'text', 'available')
     readonly_fields = ('uri', )
     list_filter = ('available', )
+    filter_horizontal = ('catalogs', 'sites', 'editors', 'groups', 'conditions')
 
 
 admin.site.register(Task, TaskAdmin)
diff --git a/rdmo/tasks/imports.py b/rdmo/tasks/imports.py
index a7b46f2a43..ad500a4d61 100644
--- a/rdmo/tasks/imports.py
+++ b/rdmo/tasks/imports.py
@@ -42,8 +42,9 @@ def import_task(element, save=False):
             logger.info('Task created with uri %s.', element.get('uri'))
 
         task.save()
-        task.sites.add(Site.objects.get_current())
         set_m2m_instances(task, 'catalogs', element)
         set_m2m_instances(task, 'conditions', element)
+        task.sites.add(Site.objects.get_current())
+        task.editors.add(Site.objects.get_current())
 
     return task
diff --git a/rdmo/tasks/migrations/0034_task_editors.py b/rdmo/tasks/migrations/0034_task_editors.py
new file mode 100644
index 0000000000..a8e6bbbfd9
--- /dev/null
+++ b/rdmo/tasks/migrations/0034_task_editors.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.16 on 2023-02-24 14:20
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('sites', '0002_alter_domain_unique'),
+        ('tasks', '0033_task_locked'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='task',
+            name='editors',
+            field=models.ManyToManyField(blank=True, help_text='The sites that can edit this task (in a multi site setup).', related_name='task_editors', to='sites.Site', verbose_name='Editors'),
+        ),
+    ]
diff --git a/rdmo/tasks/models.py b/rdmo/tasks/models.py
index 6b0b280c16..d96a70bb9c 100644
--- a/rdmo/tasks/models.py
+++ b/rdmo/tasks/models.py
@@ -51,6 +51,11 @@ class Task(TranslationMixin, models.Model):
         verbose_name=_('Sites'),
         help_text=_('The sites this task belongs to (in a multi site setup).')
     )
+    editors = models.ManyToManyField(
+        Site, related_name='%(class)s_editors', blank=True,
+        verbose_name=_('Editors'),
+        help_text=_('The sites that can edit this task (in a multi site setup).')
+    )
     groups = models.ManyToManyField(
         Group, blank=True,
         verbose_name=_('Group'),
diff --git a/rdmo/tasks/serializers/v1.py b/rdmo/tasks/serializers/v1.py
index 5b3c420ce2..89feed0900 100644
--- a/rdmo/tasks/serializers/v1.py
+++ b/rdmo/tasks/serializers/v1.py
@@ -3,16 +3,18 @@
 from rdmo.core.serializers import (ElementExportSerializerMixin,
                                    ElementModelSerializerMixin,
                                    ElementWarningSerializerMixin,
-                                   TranslationSerializerMixin)
+                                   TranslationSerializerMixin,
+                                   ReadOnlyObjectPermissionsSerializerMixin)
 
 from ..models import Task
 from ..validators import TaskLockedValidator, TaskUniqueURIValidator
 
 
 class BaseTaskSerializer(TranslationSerializerMixin, ElementModelSerializerMixin,
-                         serializers.ModelSerializer):
+                         ReadOnlyObjectPermissionsSerializerMixin, serializers.ModelSerializer):
 
     model = serializers.SerializerMethodField()
+    read_only = serializers.SerializerMethodField()
 
     class Meta:
         model = Task
@@ -27,6 +29,8 @@ class Meta:
             'available',
             'catalogs',
             'sites',
+            'editors',
+            'read_only',
             'groups',
             'start_attribute',
             'end_attribute',
diff --git a/rdmo/tasks/templates/tasks/tasks.html b/rdmo/tasks/templates/tasks/tasks.html
index 583df5a350..918094f281 100644
--- a/rdmo/tasks/templates/tasks/tasks.html
+++ b/rdmo/tasks/templates/tasks/tasks.html
@@ -102,6 +102,9 @@ <h1>{% trans 'Tasks' %}</h1>
                         ng-show="task.warning"></span>
 
                     <i class="fa fa-lock text-danger" ng-show="task.locked"></i>
+                    <i class="fa fa-ban text-muted" ng-show="task.read_only"
+                        title="{% trans 'Read only' %}">
+                    </i>
 
                     <a href="" class="fa fa-pencil"
                         title="{% trans 'Update task' %}"
@@ -128,7 +131,9 @@ <h1>{% trans 'Tasks' %}</h1>
                         ng-click="service.openDeleteModal('tasks', task)">
                     </a>
                 </div>
-                <strong>{% trans 'Task' %}</strong>
+                <strong ng-class="{'text-muted': task.read_only}">
+                    {% trans 'Task' %}
+                </strong>
                 <code class="code-tasks">{$ task.uri $}</code>
             </div>
 
@@ -141,13 +146,38 @@ <h1>{% trans 'Tasks' %}</h1>
 
             {% if settings.MULTISITE %}
             <div class="panel-footer">
-                <strong>{% trans 'Sites'%}</strong>
-                <span ng-show="task.sites.length" ng-repeat="site in task.sites">
-                    {$ site.name $}{$ $last ? '' : ', ' $}
-                </span>
-                <span ng-hide="task.sites.length">
-                    {% trans 'all' %}
-                </span>
+                <div class="row">
+                    <div class="col-sm-3">
+                        <strong>{% trans 'Sites' %}</strong>
+                    </div>
+                    <div class="col-sm-9">
+                        <ul>
+                            <li ng-show="task.sites.length > 0 && task.sites.length < service.sites.length"
+                                ng-repeat="site in task.sites">
+                                {$ site.name $}
+                            </li>
+                            <li ng-show="task.sites.length == 0 || task.sites.length == service.sites.length">
+                                {% trans 'All sites' %}
+                            </li>
+                        </ul>
+                    </div>
+                </div>
+                <div class="row">
+                    <div class="col-sm-3">
+                        <strong>{% trans 'Editors' %}</strong>
+                    </div>
+                    <div class="col-sm-9">
+                        <ul>
+                            <li ng-show="task.editors.length > 0 && task.editors.length < service.sites.length"
+                                ng-repeat="editor in task.editors">
+                                {$ editor.name $}
+                            </li>
+                            <li ng-show="task.editors.length == 0 || task.editors.length == service.sites.length">
+                                {% trans 'All sites' %}
+                            </li>
+                        </ul>
+                    </div>
+                </div>
             </div>
             {% endif %}
         </div>
diff --git a/rdmo/tasks/templates/tasks/tasks_modal_delete_tasks.html b/rdmo/tasks/templates/tasks/tasks_modal_delete_tasks.html
index 80bb7c186d..8c42053dc1 100644
--- a/rdmo/tasks/templates/tasks/tasks_modal_delete_tasks.html
+++ b/rdmo/tasks/templates/tasks/tasks_modal_delete_tasks.html
@@ -25,10 +25,14 @@ <h2 class="modal-title" id="add-modal">
                 </div>
 
                 <div class="modal-footer">
+                    <div class="alert alert-warning pull-left" ng-show="service.errors.detail" role="alert">
+                        {$ service.errors.detail $}
+                    </div>
                     <button type="button" class="btn btn-default" data-dismiss="modal">
                         {% trans 'Close' %}
                     </button>
                     <button type="button" class="btn btn-danger"
+                            ng-class="{'disabled': service.values.read_only}"
                             ng-click="service.submitDeleteModal('tasks')">
                         {% trans 'Delete' %}
                     </button>
diff --git a/rdmo/tasks/templates/tasks/tasks_modal_form_tasks.html b/rdmo/tasks/templates/tasks/tasks_modal_form_tasks.html
index f7a3895858..5617e6e22d 100644
--- a/rdmo/tasks/templates/tasks/tasks_modal_form_tasks.html
+++ b/rdmo/tasks/templates/tasks/tasks_modal_form_tasks.html
@@ -145,6 +145,16 @@ <h2 class="modal-title">
                                         <i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
                                     </a>
                                 </li>
+                                <li role="presentation" ng-class="{
+                                    'has-error': service.errors.editors,
+                                }">
+                                    <a class="control-label" href="#tasks-editors"
+                                        role="tab" data-toggle="tab" aria-controls="tasks-editors">
+
+                                        {% trans 'Editors' %}
+                                        <i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
+                                    </a>
+                                </li>
                                 {% endif %}
                             </ul>
                             <div class="tab-content">
@@ -200,7 +210,7 @@ <h2 class="modal-title">
                                         data-model="service.values.groups"
                                         data-errors="service.errors.groups"
                                         data-options="service.groups"
-                                        data-options-label="uri"
+                                        data-options-label="name"
                                         data-help="{% trans 'Hold down Control, or Command on a Mac, to select more than one.' %}">
                                     </formgroup>
                                 </div>
@@ -213,7 +223,19 @@ <h2 class="modal-title">
                                         data-model="service.values.sites"
                                         data-errors="service.errors.sites"
                                         data-options="service.sites"
-                                        data-options-label="uri"
+                                        data-options-label="name"
+                                        data-help="{% trans 'Hold down Control, or Command on a Mac, to select more than one.' %}">
+                                    </formgroup>
+                                </div>
+                                <div role="tabpanel" class="tab-pane" id="tasks-editors">
+                                    <formgroup
+                                        data-id="task_editors"
+                                        data-type="selectmultiple"
+                                        data-label="{% trans 'Editors' %}"
+                                        data-model="service.values.editors"
+                                        data-errors="service.errors.editors"
+                                        data-options="service.sites"
+                                        data-options-label="name"
                                         data-help="{% trans 'Hold down Control, or Command on a Mac, to select more than one.' %}">
                                     </formgroup>
                                 </div>
@@ -224,10 +246,14 @@ <h2 class="modal-title">
                 </div>
 
                 <div class="modal-footer">
+                    <div class="alert alert-warning pull-left" ng-show="service.errors.detail" role="alert">
+                        {$ service.errors.detail $}
+                    </div>
                     <button type="button" class="btn btn-default" data-dismiss="modal">
                         {% trans 'Close' %}
                     </button>
                     <button type="button" class="btn btn-primary"
+                            ng-class="{'disabled': service.values.read_only}"
                             ng-click="service.submitFormModal('tasks')">
                         {% trans 'Save' %}
                     </button>
diff --git a/rdmo/tasks/viewsets.py b/rdmo/tasks/viewsets.py
index d0f0fc78f6..32ee88e7e9 100644
--- a/rdmo/tasks/viewsets.py
+++ b/rdmo/tasks/viewsets.py
@@ -6,7 +6,7 @@
 from rest_framework.viewsets import ModelViewSet
 
 from rdmo.core.exports import XMLResponse
-from rdmo.core.permissions import HasModelPermission
+from rdmo.core.permissions import HasModelPermission, HasObjectPermission
 from rdmo.core.utils import is_truthy, render_to_format
 from rdmo.core.viewsets import CopyModelMixin
 
@@ -18,9 +18,9 @@
 
 
 class TaskViewSet(CopyModelMixin, ModelViewSet):
-    permission_classes = (HasModelPermission, )
+    permission_classes = (HasModelPermission | HasObjectPermission, )
     queryset = Task.objects.select_related('start_attribute', 'end_attribute') \
-                           .prefetch_related('catalogs', 'sites', 'groups', 'conditions') \
+                           .prefetch_related('catalogs', 'sites', 'editors', 'groups', 'conditions') \
                            .annotate(projects_count=models.Count('projects'))
     serializer_class = TaskSerializer
 
@@ -28,8 +28,11 @@ class TaskViewSet(CopyModelMixin, ModelViewSet):
     search_fields = ('uri', )
     filterset_fields = (
         'uri',
+        'uri_prefix',
         'key',
-        'comment'
+        'comment',
+        'sites',
+        'editors'
     )
 
     def get_serializer_class(self):
@@ -41,7 +44,7 @@ def index(self, request):
         serializer = TaskIndexSerializer(queryset, many=True)
         return Response(serializer.data)
 
-    @action(detail=False, url_path='export(/(?P<export_format>[a-z]+))?', permission_classes=[HasModelPermission])
+    @action(detail=False, url_path='export(/(?P<export_format>[a-z]+))?')
     def export(self, request, export_format='xml'):
         queryset = self.filter_queryset(self.get_queryset())
         if export_format == 'xml':
@@ -53,7 +56,7 @@ def export(self, request, export_format='xml'):
                 'tasks': queryset
             })
 
-    @action(detail=True, url_path='export(/(?P<export_format>[a-z]+))?', permission_classes=[HasModelPermission])
+    @action(detail=True, url_path='export(/(?P<export_format>[a-z]+))?')
     def detail_export(self, request, pk=None, export_format='xml'):
         if export_format == 'xml':
             serializer = TaskExportSerializer(self.get_object())
diff --git a/rdmo/views/admin.py b/rdmo/views/admin.py
index 187d62d963..c8fc3bd0e9 100644
--- a/rdmo/views/admin.py
+++ b/rdmo/views/admin.py
@@ -26,6 +26,7 @@ class ViewAdmin(admin.ModelAdmin):
     list_display = ('uri', 'title', 'help', 'available')
     readonly_fields = ('uri', )
     list_filter = ('available', )
+    filter_horizontal = ('catalogs', 'sites', 'editors', 'groups')
 
 
 admin.site.register(View, ViewAdmin)
diff --git a/rdmo/views/imports.py b/rdmo/views/imports.py
index 8ea766385b..3048e6ba93 100644
--- a/rdmo/views/imports.py
+++ b/rdmo/views/imports.py
@@ -37,7 +37,8 @@ def import_view(element, save=False):
             logger.info('View created with uri %s.', element.get('uri'))
 
         view.save()
-        view.sites.add(Site.objects.get_current())
         set_m2m_instances(view, 'catalogs', element)
+        view.sites.add(Site.objects.get_current())
+        view.editors.add(Site.objects.get_current()) 
 
     return view
diff --git a/rdmo/views/migrations/0027_view_editors.py b/rdmo/views/migrations/0027_view_editors.py
new file mode 100644
index 0000000000..f249755a2f
--- /dev/null
+++ b/rdmo/views/migrations/0027_view_editors.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.16 on 2023-02-24 15:35
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('sites', '0002_alter_domain_unique'),
+        ('views', '0026_view_locked'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='view',
+            name='editors',
+            field=models.ManyToManyField(blank=True, help_text='The sites that can edit this view (in a multi site setup).', related_name='view_editors', to='sites.Site', verbose_name='Editors'),
+        ),
+    ]
diff --git a/rdmo/views/models.py b/rdmo/views/models.py
index b78ddb2179..d59bf9c979 100644
--- a/rdmo/views/models.py
+++ b/rdmo/views/models.py
@@ -51,6 +51,11 @@ class View(models.Model, TranslationMixin):
         verbose_name=_('Sites'),
         help_text=_('The sites this view belongs to (in a multi site setup).')
     )
+    editors = models.ManyToManyField(
+        Site, related_name='%(class)s_editors', blank=True,
+        verbose_name=_('Editors'),
+        help_text=_('The sites that can edit this view (in a multi site setup).')
+    )
     groups = models.ManyToManyField(
         Group, blank=True,
         verbose_name=_('Group'),
diff --git a/rdmo/views/serializers/v1.py b/rdmo/views/serializers/v1.py
index 4959f8540a..fd8844b91c 100644
--- a/rdmo/views/serializers/v1.py
+++ b/rdmo/views/serializers/v1.py
@@ -4,16 +4,18 @@
 from rdmo.core.serializers import (ElementExportSerializerMixin,
                                    ElementModelSerializerMixin,
                                    ElementWarningSerializerMixin,
-                                   TranslationSerializerMixin)
+                                   TranslationSerializerMixin,
+                                   ReadOnlyObjectPermissionsSerializerMixin)
 
 from ..models import View
 from ..validators import ViewLockedValidator, ViewUniqueURIValidator
 
 
 class BaseViewSerializer(TranslationSerializerMixin, ElementModelSerializerMixin,
-                         serializers.ModelSerializer):
+                         ReadOnlyObjectPermissionsSerializerMixin, serializers.ModelSerializer):
 
     model = serializers.SerializerMethodField()
+    read_only = serializers.SerializerMethodField()
 
     class Meta:
         model = View
@@ -28,6 +30,8 @@ class Meta:
             'available',
             'catalogs',
             'sites',
+            'editors',
+            'read_only',
             'groups',
             'template',
             'title',
diff --git a/rdmo/views/templates/views/views.html b/rdmo/views/templates/views/views.html
index db47f3f862..2657e4ca9e 100644
--- a/rdmo/views/templates/views/views.html
+++ b/rdmo/views/templates/views/views.html
@@ -101,6 +101,9 @@ <h1>{% trans 'Views' %}</h1>
                         ng-show="view.warning"></span>
 
                     <i class="fa fa-lock text-danger" ng-show="view.locked"></i>
+                    <i class="fa fa-ban text-muted" ng-show="view.read_only"
+                        title="{% trans 'Read only' %}">
+                    </i>
 
                     <a href="" class="fa fa-pencil"
                         title="{% trans 'Update view' %}"
@@ -124,7 +127,9 @@ <h1>{% trans 'Views' %}</h1>
                     </a>
                 </div>
                 <div>
-                    <strong>{% trans 'View' %}</strong>
+                    <strong ng-class="{'text-muted': view.read_only}">
+                        {% trans 'View' %}
+                    </strong>
                     <code class="code-views">{$ view.uri $}</code>
                 </div>
             </div>
@@ -138,13 +143,38 @@ <h1>{% trans 'Views' %}</h1>
 
             {% if settings.MULTISITE %}
             <div class="panel-footer">
-                <strong>{% trans 'Sites'%}</strong>
-                <span ng-show="view.sites.length" ng-repeat="site in view.sites">
-                    {$ site.name $}{$ $last ? '' : ', ' $}
-                </span>
-                <span ng-hide="view.sites.length">
-                    {% trans 'all' %}
-                </span>
+                <div class="row">
+                    <div class="col-sm-3">
+                        <strong>{% trans 'Sites' %}</strong>
+                    </div>
+                    <div class="col-sm-9">
+                        <ul>
+                            <li ng-show="view.sites.length > 0 && view.sites.length < service.sites.length"
+                                ng-repeat="site in view.sites">
+                                {$ site.name $}
+                            </li>
+                            <li ng-show="view.sites.length == 0 || view.sites.length == service.sites.length">
+                                {% trans 'All sites' %}
+                            </li>
+                        </ul>
+                    </div>
+                </div>
+                <div class="row">
+                    <div class="col-sm-3">
+                        <strong>{% trans 'Editors' %}</strong>
+                    </div>
+                    <div class="col-sm-9">
+                        <ul>
+                            <li ng-show="view.editors.length > 0 && view.editors.length < service.sites.length"
+                                ng-repeat="editor in view.editors">
+                                {$ editor.name $}
+                            </li>
+                            <li ng-show="view.editors.length == 0 || view.editors.length == service.sites.length">
+                                {% trans 'All sites' %}
+                            </li>
+                        </ul>
+                    </div>
+                </div>
             </div>
             {% endif %}
         </div>
diff --git a/rdmo/views/templates/views/views_modal_form_views.html b/rdmo/views/templates/views/views_modal_form_views.html
index 60d1c4e489..a8224b8423 100644
--- a/rdmo/views/templates/views/views_modal_form_views.html
+++ b/rdmo/views/templates/views/views_modal_form_views.html
@@ -144,6 +144,16 @@ <h2 class="modal-title">
                                         <i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
                                     </a>
                                 </li>
+                                <li role="presentation" ng-class="{
+                                    'has-error': service.errors.editors,
+                                }">
+                                    <a class="control-label" href="#views-editors"
+                                        role="tab" data-toggle="tab" aria-controls="views-editors">
+
+                                        {% trans 'Editors' %}
+                                        <i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
+                                    </a>
+                                </li>
                                 {% endif %}
                             </ul>
                             <div class="tab-content">
@@ -215,6 +225,18 @@ <h2 class="modal-title">
                                         data-help="{% trans 'Hold down Control, or Command on a Mac, to select more than one.' %}">
                                     </formgroup>
                                 </div>
+                                <div role="tabpanel" class="tab-pane" id="views-editors">
+                                    <formgroup
+                                        data-id="view_editors"
+                                        data-type="selectmultiple"
+                                        data-label="{% trans 'Editors' %}"
+                                        data-model="service.values.editors"s
+                                        data-errors="service.errors.editors"
+                                        data-options="service.sites"
+                                        data-options-label="name"
+                                        data-help="{% trans 'Hold down Control, or Command on a Mac, to select more than one.' %}">
+                                    </formgroup>
+                                </div>
                                 {% endif %}
                             </div>
                         </div>
@@ -222,10 +244,14 @@ <h2 class="modal-title">
                 </div>
 
                 <div class="modal-footer">
+                    <div class="alert alert-warning pull-left" ng-show="service.errors.detail" role="alert">
+                        {$ service.errors.detail $}
+                    </div>
                     <button type="button" class="btn btn-default" data-dismiss="modal">
                         {% trans 'Close' %}
                     </button>
                     <button type="button" class="btn btn-primary"
+                            ng-class="{'disabled': service.values.read_only}"
                             ng-click="service.submitFormModal('views')">
                         {% trans 'Save' %}
                     </button>
diff --git a/rdmo/views/viewsets.py b/rdmo/views/viewsets.py
index 67355f8a07..a2ce41c8e0 100644
--- a/rdmo/views/viewsets.py
+++ b/rdmo/views/viewsets.py
@@ -6,7 +6,7 @@
 from rest_framework.filters import SearchFilter
 
 from rdmo.core.exports import XMLResponse
-from rdmo.core.permissions import HasModelPermission
+from rdmo.core.permissions import HasModelPermission, HasObjectPermission
 from rdmo.core.utils import render_to_format
 from rdmo.core.viewsets import CopyModelMixin
 
@@ -18,8 +18,8 @@
 
 
 class ViewViewSet(CopyModelMixin, ModelViewSet):
-    permission_classes = (HasModelPermission, )
-    queryset = View.objects.prefetch_related('catalogs', 'sites', 'groups') \
+    permission_classes = (HasModelPermission | HasObjectPermission, )
+    queryset = View.objects.prefetch_related('catalogs', 'sites', 'editors', 'groups') \
                            .annotate(projects_count=models.Count('projects'))
 
     filter_backends = (SearchFilter, DjangoFilterBackend)
@@ -28,7 +28,9 @@ class ViewViewSet(CopyModelMixin, ModelViewSet):
         'uri',
         'uri_prefix',
         'key',
-        'sites'
+        'comment',
+        'sites',
+        'editors'
     )
 
     def get_serializer_class(self):
@@ -40,7 +42,7 @@ def index(self, request):
         serializer = ViewIndexSerializer(queryset, many=True)
         return Response(serializer.data)
 
-    @action(detail=False, url_path='export(/(?P<export_format>[a-z]+))?', permission_classes=[HasModelPermission])
+    @action(detail=False, url_path='export(/(?P<export_format>[a-z]+))?')
     def export(self, request, export_format='xml'):
         queryset = self.filter_queryset(self.get_queryset())
         if export_format == 'xml':
@@ -52,7 +54,7 @@ def export(self, request, export_format='xml'):
                 'views': queryset
             })
 
-    @action(detail=True, url_path='export(/(?P<export_format>[a-z]+))?', permission_classes=[HasModelPermission])
+    @action(detail=True, url_path='export(/(?P<export_format>[a-z]+))?')
     def detail_export(self, request, pk=None, export_format='xml'):
         if export_format == 'xml':
             serializer = ViewExportSerializer(self.get_object())


Test patch:
diff --git a/rdmo/accounts/tests/test_admin.py b/rdmo/accounts/tests/test_admin.py
new file mode 100644
index 0000000000..c5d51dc50b
--- /dev/null
+++ b/rdmo/accounts/tests/test_admin.py
@@ -0,0 +1,13 @@
+import pytest
+
+from django.urls import reverse
+
+
+roles = ('member', 'manager', 'editor', 'reviewer')
+
+
+@pytest.mark.parametrize('role', roles)
+def test_admin_accounts_role(admin_client, role):
+    url = reverse('admin:accounts_role_changelist') + f'?q={role}'
+    response = admin_client.get(url)
+    assert response.status_code == 200
diff --git a/rdmo/accounts/tests/test_views.py b/rdmo/accounts/tests/test_views.py
index ef06426c68..30ceef5283 100644
--- a/rdmo/accounts/tests/test_views.py
+++ b/rdmo/accounts/tests/test_views.py
@@ -16,10 +16,24 @@
     ('editor', 'editor'),
     ('reviewer', 'reviewer'),
     ('user', 'user'),
+    ('site', 'site'),
     ('api', 'api'),
     ('anonymous', None),
 )
 
+other_site_users = (
+    'foo-user',
+    'foo-manager',
+    'foo-editor',
+    'foo-reviewer',
+    'bar-user',
+    'bar-manager',
+    'bar-editor',
+    'bar-reviewer',
+)
+
+users += tuple(zip(other_site_users, other_site_users))  # add (other site users and passwords)
+
 boolean_toggle = (True, False)
 
 
diff --git a/rdmo/accounts/tests/test_viewsets.py b/rdmo/accounts/tests/test_viewsets.py
index dc648067ab..10fd0369f2 100644
--- a/rdmo/accounts/tests/test_viewsets.py
+++ b/rdmo/accounts/tests/test_viewsets.py
@@ -3,27 +3,49 @@
 from django.urls import reverse
 
 users = (
-    ('site', 'site'),
-    ('api', 'api'),
+    ('site', 'site'),  # site manager for all sites
+    ('editor', 'editor'),  # editor for all sites
+    ('reviewer', 'reviewer'),  # reviewer for all sites
+    ('api', 'api'),  # has all roles for all sites, same as superuser
+    ('admin', 'admin'),  # superuser
     ('user', 'user'),
-    ('anonymous', None),
+    ('anonymous', None)
+)
+
+more_example_users = (
+    'example-user',
+    'example-manager',
+    'example-editor',
+    'example-reviewer'
+)
+
+members_from_other_sites = (
+    'other',
+    'foo-user',
+    'foo-manager',
+    'foo-editor',
+    'foo-reviewer',
+    'bar-user',
+    'bar-manager',
+    'bar-editor',
+    'bar-reviewer',
 )
 
 status_map = {
     'list': {
-        'site': 200, 'api': 200, 'user': 200, 'anonymous': 401
+        'editor': 403,  'reviewer': 403, 'site': 403, 'api': 403, 'user': 403, 'anonymous': 401, 'admin' : 200
     },
     'detail': {
-        'site': 200, 'api': 200, 'user': 404, 'anonymous': 401
+        'editor': 404,  'reviewer': 404, 'site': 404, 'api': 404, 'user': 404, 'anonymous': 401, 'admin' : 200
     },
     'create': {
-        'site': 405, 'api': 405, 'user': 405, 'anonymous': 401
+        'editor': 403,  'reviewer': 403, 'site': 403, 'api': 403, 'user': 403, 'anonymous': 401, 'admin' : 405
     },
     'update': {
-        'site': 405, 'api': 405, 'user': 405, 'anonymous': 401
+        'editor': 405,  'reviewer': 405, 'site': 405, 'api': 405, 'user': 405, 'anonymous': 401, 'admin' : 405
     },
     'delete': {
-        'site': 405, 'api': 405, 'user': 405, 'anonymous': 401
+        'editor': 405,  'reviewer': 405, 'site': 405, 'api': 405, 'user': 405, 'anonymous': 401, 'admin' : 405
     }
 }
 
@@ -41,11 +63,11 @@ def test_list(db, client, username, password):
     response = client.get(url)
     assert response.status_code == status_map['list'][username], response.json()
     if response.status_code == 200:
-        if username == 'api':
-            assert len(response.json()) == 11
+        if username == 'api' or username == 'admin':
+            assert len(response.json()) == get_user_model().objects.count()
         elif username == 'site':
-            # the site admin must not see the user 'other'
-            assert len(response.json()) == 10
+            # the site manager for example.com must see only the members of example.com
+            assert len(response.json()) == get_user_model().objects.count() - len(members_from_other_sites)
         else:
             assert len(response.json()) == 0
 
@@ -54,12 +76,11 @@ def test_list(db, client, username, password):
 def test_detail(db, client, username, password):
     client.login(username=username, password=password)
     instances = get_user_model().objects.all()
-
     for instance in instances:
         url = reverse(urlnames['detail'], args=[instance.pk])
         response = client.get(url)
-        if username == 'site' and instance.username == 'other':
-            # the site admin must not see the user 'other'
+        if username == 'site' and not instance.role.member.filter(domain__contains='example.com').exists():
+            # the site manager for example.com must see only the members of example.com
             assert response.status_code == 404, response.json()
         else:
             assert response.status_code == status_map['detail'][username], response.json()
diff --git a/rdmo/conditions/tests/test_validator_unique_uri.py b/rdmo/conditions/tests/test_validator_unique_uri.py
index a21db47eb7..d3ee53bfaf 100644
--- a/rdmo/conditions/tests/test_validator_unique_uri.py
+++ b/rdmo/conditions/tests/test_validator_unique_uri.py
@@ -20,7 +20,7 @@ def test_unique_uri_validator_create_error(db):
     with pytest.raises(ValidationError):
         ConditionUniqueURIValidator()({
             'uri_prefix': settings.DEFAULT_URI_PREFIX,
-            'key': Condition.objects.last().key
+            'uri_path': Condition.objects.last().key
         })
 
 
@@ -39,7 +39,7 @@ def test_unique_uri_validator_update_error(db):
     with pytest.raises(ValidationError):
         ConditionUniqueURIValidator(condition)({
             'uri_prefix': condition.uri_prefix,
-            'key': Condition.objects.last().key
+            'uri_path': Condition.objects.last().key
         })
 
 
@@ -60,7 +60,7 @@ def test_unique_uri_validator_serializer_create_error(db):
     with pytest.raises(RestFameworkValidationError):
         validator({
             'uri_prefix': settings.DEFAULT_URI_PREFIX,
-            'key': Condition.objects.last().key
+            'uri_path': Condition.objects.last().key
         })
 
 
@@ -85,5 +85,5 @@ def test_unique_uri_validator_serializer_update_error(db):
     with pytest.raises(RestFameworkValidationError):
         validator({
             'uri_prefix': condition.uri_prefix,
-            'key': Condition.objects.last().key
+            'uri_path': Condition.objects.last().key
         })
diff --git a/rdmo/conditions/tests/test_viewset_condition.py b/rdmo/conditions/tests/test_viewset_condition.py
index ac38453c4b..478e699439 100644
--- a/rdmo/conditions/tests/test_viewset_condition.py
+++ b/rdmo/conditions/tests/test_viewset_condition.py
@@ -7,7 +7,7 @@
 
 users = (
     ('editor', 'editor'),
-    ('editor', 'editor'),
+    ('reviewer', 'reviewer'),
     ('user', 'user'),
     ('api', 'api'),
     ('anonymous', None),
@@ -18,16 +18,19 @@
         'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
     },
     'detail': {
-        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
+        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 404, 'anonymous': 401
     },
     'create': {
         'editor': 201, 'reviewer': 403, 'api': 201, 'user': 403, 'anonymous': 401
     },
     'update': {
-        'editor': 200, 'reviewer': 403, 'api': 200, 'user': 403, 'anonymous': 401
+        'editor': 200, 'reviewer': 403, 'api': 200, 'user': 404, 'anonymous': 401
     },
     'delete': {
-        'editor': 204, 'reviewer': 403, 'api': 204, 'user': 403, 'anonymous': 401
+        'editor': 204, 'reviewer': 403, 'api': 204, 'user': 404, 'anonymous': 401
+    },
+    'copy': {
+        'editor': 201, 'reviewer': 403, 'api': 201, 'user': 404, 'anonymous': 401
     }
 }
 
@@ -282,7 +285,7 @@ def test_detail_export(db, client, username, password, export_format):
 
     url = reverse(urlnames['detail_export'], args=[instance.pk]) + export_format + '/'
     response = client.get(url)
-    assert response.status_code == status_map['list'][username], response.content
+    assert response.status_code == status_map['detail'][username], response.content
 
     if response.status_code == 200 and export_format == 'xml':
         root = et.fromstring(response.content)
@@ -303,7 +306,7 @@ def test_copy(db, client, username, password):
             'key': instance.key + '-'
         }
         response = client.put(url, data, content_type='application/json')
-        assert response.status_code == status_map['create'][username], response.json()
+        assert response.status_code == status_map['copy'][username], response.json()
 
 
 @pytest.mark.parametrize('username,password', users)
@@ -318,7 +321,7 @@ def test_copy_wrong(db, client, username, password):
     }
     response = client.put(url, data, content_type='application/json')
 
-    if status_map['create'][username] == 201:
+    if status_map['copy'][username] == 201:
         assert response.status_code == 400, response.json()
     else:
-        assert response.status_code == status_map['create'][username], response.json()
+        assert response.status_code == status_map['copy'][username], response.json()
diff --git a/rdmo/conditions/tests/test_viewset_condition_multisite.py b/rdmo/conditions/tests/test_viewset_condition_multisite.py
new file mode 100644
index 0000000000..08d064c049
--- /dev/null
+++ b/rdmo/conditions/tests/test_viewset_condition_multisite.py
@@ -0,0 +1,295 @@
+import xml.etree.ElementTree as et
+
+import pytest
+from django.urls import reverse
+
+from ..models import Condition
+
+from .test_viewset_condition import export_formats
+
+from ...core.tests import multisite_status_map as status_map
+from ...core.tests import multisite_users as users
+from ...core.tests import get_obj_perms_status_code
+
+from .test_viewset_condition import urlnames
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_list(db, client, username, password):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['list'])
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_index(db, client, username, password):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['index'])
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+@pytest.mark.parametrize('export_format', export_formats)
+def test_export(db, client, username, password, export_format):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['export']) + export_format + '/'
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.content
+
+    if response.status_code == 200 and export_format == 'xml':
+        root = et.fromstring(response.content)
+        assert root.tag == 'rdmo'
+        for child in root:
+            assert child.tag in ['condition']
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_detail(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Condition.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        response = client.get(url)
+        assert response.status_code == status_map['detail'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Condition.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['list'])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'key': '%s_new_%s' % (instance.key, username),
+            'comment': instance.comment,
+            'source': instance.source.pk,
+            'relation': instance.relation,
+            'target_text': instance.target_text,
+            'target_option': instance.target_option.pk if instance.target_option else ''
+        }
+        response = client.post(url, data)
+        assert response.status_code == status_map['create'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_optionset(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Condition.objects.all()
+
+    for instance in instances:
+        optionset = instance.optionsets.first()
+        if optionset:
+            url = reverse(urlnames['list'])
+            data = {
+                'uri_prefix': instance.uri_prefix,
+                'key': '%s_new_%s' % (instance.key, username),
+                'comment': instance.comment,
+                'source': instance.source.pk,
+                'relation': instance.relation,
+                'target_text': instance.target_text,
+                'target_option': instance.target_option.pk if instance.target_option else '',
+                'optionsets': [optionset.id]
+            }
+            response = client.post(url, data)
+            assert response.status_code == status_map['create'][username], response.json()
+
+            if response.status_code == 201:
+                new_instance = Condition.objects.get(id=response.json().get('id'))
+                assert [optionset.id] == [optionset.id for optionset in new_instance.optionsets.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_page(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Condition.objects.all()
+
+    for instance in instances:
+        page = instance.pages.first()
+        if page:
+            url = reverse(urlnames['list'])
+            data = {
+                'uri_prefix': instance.uri_prefix,
+                'key': '%s_new_%s' % (instance.key, username),
+                'comment': instance.comment,
+                'source': instance.source.pk,
+                'relation': instance.relation,
+                'target_text': instance.target_text,
+                'target_option': instance.target_option.pk if instance.target_option else '',
+                'pages': [page.id]
+            }
+            response = client.post(url, data)
+            assert response.status_code == status_map['create'][username], response.json()
+
+            if response.status_code == 201:
+                new_instance = Condition.objects.get(id=response.json().get('id'))
+                assert [page.id] == [page.id for page in new_instance.pages.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_questionset(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Condition.objects.all()
+
+    for instance in instances:
+        questionset = instance.questionsets.first()
+        if questionset:
+            url = reverse(urlnames['list'])
+            data = {
+                'uri_prefix': instance.uri_prefix,
+                'key': '%s_new_%s' % (instance.key, username),
+                'comment': instance.comment,
+                'source': instance.source.pk,
+                'relation': instance.relation,
+                'target_text': instance.target_text,
+                'target_option': instance.target_option.pk if instance.target_option else '',
+                'questionsets': [questionset.id]
+            }
+            response = client.post(url, data)
+            assert response.status_code == status_map['create'][username], response.json()
+
+            if response.status_code == 201:
+                new_instance = Condition.objects.get(id=response.json().get('id'))
+                assert [questionset.id] == [questionset.id for questionset in new_instance.questionsets.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_question(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Condition.objects.all()
+
+    for instance in instances:
+        question = instance.questions.first()
+        if question:
+            url = reverse(urlnames['list'])
+            data = {
+                'uri_prefix': instance.uri_prefix,
+                'key': '%s_new_%s' % (instance.key, username),
+                'comment': instance.comment,
+                'source': instance.source.pk,
+                'relation': instance.relation,
+                'target_text': instance.target_text,
+                'target_option': instance.target_option.pk if instance.target_option else '',
+                'questions': [question.id]
+            }
+            response = client.post(url, data)
+            assert response.status_code == status_map['create'][username], response.json()
+
+            if response.status_code == 201:
+                new_instance = Condition.objects.get(id=response.json().get('id'))
+                assert [question.id] == [question.id for question in new_instance.questions.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_task(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Condition.objects.all()
+
+    for instance in instances:
+        task = instance.tasks.first()
+        if task:
+            url = reverse(urlnames['list'])
+            data = {
+                'uri_prefix': instance.uri_prefix,
+                'key': '%s_new_%s' % (instance.key, username),
+                'comment': instance.comment,
+                'source': instance.source.pk,
+                'relation': instance.relation,
+                'target_text': instance.target_text,
+                'target_option': instance.target_option.pk if instance.target_option else '',
+                'tasks': [task.id]
+            }
+            response = client.post(url, data)
+            assert response.status_code == status_map['create'][username], response.json()
+
+            if response.status_code == 201:
+                new_instance = Condition.objects.get(id=response.json().get('id'))
+                assert [task.id] == [task.id for task in new_instance.tasks.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_update(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Condition.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'key': instance.key,
+            'comment': instance.comment,
+            'source': instance.source.pk,
+            'relation': instance.relation,
+            'target_text': instance.target_text,
+            'target_option': instance.target_option.pk if instance.target_option else None
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'update'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_delete(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Condition.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        response = client.delete(url)
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'delete'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+@pytest.mark.parametrize('export_format', export_formats)
+def test_detail_export(db, client, username, password, export_format):
+    client.login(username=username, password=password)
+    instance = Condition.objects.first()
+
+    url = reverse(urlnames['detail_export'], args=[instance.pk]) + export_format + '/'
+    response = client.get(url)
+    assert response.status_code == status_map['detail'][username], response.content
+
+    if response.status_code == 200 and export_format == 'xml':
+        root = et.fromstring(response.content)
+        assert root.tag == 'rdmo'
+        for child in root:
+            assert child.tag in ['condition']
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_copy(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Condition.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['copy'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix + '-',
+            'key': instance.key + '-'
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'copy'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_copy_wrong(db, client, username, password):
+    client.login(username=username, password=password)
+    instance = Condition.objects.first()
+
+    url = reverse(urlnames['copy'], args=[instance.pk])
+    data = {
+        'uri_prefix': instance.uri_prefix,
+        'key': instance.key
+    }
+    response = client.put(url, data, content_type='application/json')
+
+    if status_map['copy'][username] == 201:
+        assert response.status_code == 400, response.json()
+    else:
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'copy'), response.json()
diff --git a/rdmo/conditions/tests/test_viewset_relation.py b/rdmo/conditions/tests/test_viewset_relation.py
index 65cbf647e7..7ff7baefcf 100644
--- a/rdmo/conditions/tests/test_viewset_relation.py
+++ b/rdmo/conditions/tests/test_viewset_relation.py
@@ -3,7 +3,7 @@
 
 users = (
     ('editor', 'editor'),
-    ('editor', 'editor'),
+    ('reviewer', 'reviewer'),
     ('user', 'user'),
     ('api', 'api'),
     ('anonymous', None),
diff --git a/rdmo/core/tests/__init__.py b/rdmo/core/tests/__init__.py
index e69de29bb2..44ffb7538a 100644
--- a/rdmo/core/tests/__init__.py
+++ b/rdmo/core/tests/__init__.py
@@ -0,0 +1,125 @@
+
+
+multisite_users = (
+    ('user', 'user'),
+    ('reviewer', 'reviewer'),
+    ('editor', 'editor'),
+    ('example-reviewer', 'example-reviewer'),
+    ('example-editor', 'example-editor'),
+    ('foo-user', 'foo-user'),
+    ('foo-reviewer', 'foo-reviewer'),
+    ('foo-editor', 'foo-editor'),
+    ('bar-user', 'bar-user'),
+    ('bar-reviewer', 'bar-reviewer'),
+    ('bar-editor', 'bar-editor'),
+    ('anonymous', None),
+)
+
+
+multisite_status_map = {
+    'list': {
+        'foo-user': 403, 'foo-reviewer': 200, 'foo-editor': 200,
+        'bar-user': 403, 'bar-reviewer': 200, 'bar-editor': 200,
+        'user': 403, 'example-reviewer': 200, 'example-editor': 200,
+        'anonymous': 401, 'reviewer': 200, 'editor': 200,
+    },
+    'detail': {
+        'foo-user': 404, 'foo-reviewer': 200, 'foo-editor': 200,
+        'bar-user': 404, 'bar-reviewer': 200, 'bar-editor': 200,
+        'user': 404, 'example-reviewer': 200, 'example-editor': 200,
+        'anonymous': 401, 'reviewer': 200, 'editor': 200,
+    },
+    'nested': {
+        'foo-user': 404, 'foo-reviewer': 200, 'foo-editor': 200,
+        'bar-user': 404, 'bar-reviewer': 200, 'bar-editor': 200,
+        'user': 404, 'example-reviewer': 200, 'example-editor': 200,
+        'anonymous': 401, 'reviewer': 200, 'editor': 200,
+    },
+    'create': {
+        'foo-user': 403, 'foo-reviewer': 403, 'foo-editor': 201,
+        'bar-user': 403, 'bar-reviewer': 403, 'bar-editor': 201,
+        'user': 403, 'example-reviewer': 403, 'example-editor': 201,
+        'anonymous': 401, 'reviewer': 403, 'editor': 201,
+    },
+    'copy': {
+        'foo-user': 404, 'foo-reviewer': 403, 'foo-editor': 201,
+        'bar-user': 404, 'bar-reviewer': 403, 'bar-editor': 201,
+        'user': 404, 'example-reviewer': 403, 'example-editor': 201,
+        'anonymous': 401, 'reviewer': 403, 'editor': 201,
+    },
+    'update': {
+        'foo-user': 404, 'foo-reviewer': 403, 'foo-editor': 200,
+        'bar-user': 404, 'bar-reviewer': 403, 'bar-editor': 200,
+        'user': 404, 'example-reviewer': 403, 'example-editor': 200,
+        'anonymous': 401, 'reviewer': 403, 'editor': 200,
+    },
+    'delete': {
+        'foo-user': 404, 'foo-reviewer': 403, 'foo-editor': 204,
+        'bar-user': 404, 'bar-reviewer': 403, 'bar-editor': 204,
+        'user': 404, 'example-reviewer': 403, 'example-editor': 204,
+        'anonymous': 401, 'reviewer': 403, 'editor': 204,
+    }
+}
+
+
+status_map_object_permissions = {
+    'copy': {
+        'foo-element': {
+            'foo-reviewer': 403, 'foo-editor': 201,
+            'bar-reviewer': 404, 'bar-editor': 404,
+            'example-reviewer': 404, 'example-editor': 404,
+        },
+        'bar-element': {
+            'foo-reviewer': 404, 'foo-editor': 404,
+            'bar-reviewer': 403, 'bar-editor': 201,
+            'example-reviewer': 404, 'example-editor': 404,
+        }
+    },
+    'update': {
+        'foo-element': {
+            'foo-reviewer': 403, 'foo-editor': 200,
+            'bar-reviewer': 404, 'bar-editor': 404,
+            'example-reviewer': 404, 'example-editor': 404,
+        },
+        'bar-element': {
+            'foo-reviewer': 404, 'foo-editor': 404,
+            'bar-reviewer': 403, 'bar-editor': 200,
+            'example-reviewer': 404, 'example-editor': 404,
+        }
+    },
+    'delete': {
+        'foo-element': {
+            'foo-reviewer': 403, 'foo-editor': 204,
+            'bar-reviewer': 404, 'bar-editor': 404,
+            'example-reviewer': 404, 'example-editor': 404,
+        },
+        'bar-element': {
+            'foo-reviewer': 404, 'foo-editor': 404,
+            'bar-reviewer': 403, 'bar-editor': 204,
+            'example-reviewer': 404, 'example-editor': 404,
+        }
+    },
+}
+
+
+def get_obj_perms_status_code(instance, username, method):
+    ''' looks for the object permissions of the instance and returns the status code '''
+    if not instance.editors.exists():
+        return multisite_status_map[method][username]
+    
+    if 'foo-' in str(instance):
+        instance_obj_perms_key = 'foo-element'
+    elif 'bar-' in str(instance):
+        instance_obj_perms_key = 'bar-element'
+    else:
+        raise ValueError('instance should have either foo or bar in name for obj perms')
+
+    try:
+        method_instance_obj_perms_map = status_map_object_permissions[method][instance_obj_perms_key]
+    except KeyError as exc:
+        raise KeyError('instance (%s) should be defined in status_map_object_permissions' % instance_obj_perms_key) from exc
+    try:
+        return method_instance_obj_perms_map[username]
+    except KeyError:
+        # not all users are defined in the method_instance_perms_map
+        return multisite_status_map[method][username]
diff --git a/rdmo/domain/tests/test_viewset_attribute.py b/rdmo/domain/tests/test_viewset_attribute.py
index f75c6116f1..33337eced8 100644
--- a/rdmo/domain/tests/test_viewset_attribute.py
+++ b/rdmo/domain/tests/test_viewset_attribute.py
@@ -18,19 +18,21 @@
         'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
     },
     'detail': {
-        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
+        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 404, 'anonymous': 401
     },
     'create': {
         'editor': 201, 'reviewer': 403, 'api': 201, 'user': 403, 'anonymous': 401
     },
+    'copy': {
+        'editor': 201, 'reviewer': 403, 'api': 201, 'user': 404, 'anonymous': 401
+    },
     'update': {
-        'editor': 200, 'reviewer': 403, 'api': 200, 'user': 403, 'anonymous': 401
+        'editor': 200, 'reviewer': 403, 'api': 200, 'user': 404, 'anonymous': 401
     },
     'delete': {
-        'editor': 204, 'reviewer': 403, 'api': 204, 'user': 403, 'anonymous': 401
+        'editor': 204, 'reviewer': 403, 'api': 204, 'user': 404, 'anonymous': 401
     }
 }
-
 urlnames = {
     'list': 'v1-domain:attribute-list',
     'nested': 'v1-domain:attribute-nested',
@@ -239,7 +241,7 @@ def test_detail_export(db, client, username, password, export_format):
 
     url = reverse(urlnames['detail_export'], args=[instance.pk]) + export_format + '/'
     response = client.get(url)
-    assert response.status_code == status_map['list'][username], response.content
+    assert response.status_code == status_map['detail'][username], response.content
 
     if response.status_code == 200 and export_format == 'xml':
         root = et.fromstring(response.content)
@@ -261,7 +263,7 @@ def test_copy(db, client, username, password):
             'parent': instance.parent.pk if instance.parent else None
         }
         response = client.put(url, data, content_type='application/json')
-        assert response.status_code == status_map['create'][username], response.json()
+        assert response.status_code == status_map['copy'][username], response.json()
 
 
 @pytest.mark.parametrize('username,password', users)
@@ -276,7 +278,7 @@ def test_copy_wrong(db, client, username, password):
     }
     response = client.put(url, data, content_type='application/json')
 
-    if status_map['create'][username] == 201:
+    if status_map['copy'][username] == 201:
         assert response.status_code == 400, response.json()
     else:
-        assert response.status_code == status_map['create'][username], response.json()
+        assert response.status_code == status_map['copy'][username], response.json()
diff --git a/rdmo/domain/tests/test_viewset_attribute_multisite.py b/rdmo/domain/tests/test_viewset_attribute_multisite.py
new file mode 100644
index 0000000000..3de2855e8b
--- /dev/null
+++ b/rdmo/domain/tests/test_viewset_attribute_multisite.py
@@ -0,0 +1,164 @@
+import xml.etree.ElementTree as et
+
+import pytest
+
+from django.urls import reverse
+
+from ..models import Attribute
+
+
+from ...core.tests import multisite_status_map as status_map
+from ...core.tests import multisite_users as users
+from ...core.tests import get_obj_perms_status_code
+
+from .test_viewset_attribute import urlnames
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_list(db, client, username, password):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['list'])
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_nested(db, client, username, password):
+    ''' only users and status_map are unique for this test '''
+    client.login(username=username, password=password)
+    instances = Attribute.objects.order_by('-level')
+
+    for instance in instances:
+        url = reverse(urlnames['nested'], args=[instance.pk])
+        response = client.get(url)
+        assert response.status_code == status_map['detail'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_export(db, client, username, password):
+    ''' only users and status_map are unique for this test '''
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['export'])
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.content
+
+    if response.status_code == 200:
+        root = et.fromstring(response.content)
+        assert root.tag == 'rdmo'
+        for child in root:
+            assert child.tag in ['attribute']
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_detail(db, client, username, password):
+    ''' only users and status_map are unique for this test '''
+    client.login(username=username, password=password)
+    instances = Attribute.objects.order_by('-level')
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        response = client.get(url)
+        assert response.status_code == status_map['detail'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create(db, client, username, password):
+    ''' only users and status_map are unique for this test '''
+    client.login(username=username, password=password)
+    instances = Attribute.objects.order_by('-level')
+
+    for instance in instances:
+        url = reverse(urlnames['list'])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'key': '%s_new_%s' % (instance.key, username),
+            'comment': '',
+            'parent': instance.parent.pk if instance.parent else ''
+        }
+        response = client.post(url, data)
+        assert response.status_code == status_map['create'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_update_multisite(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Attribute.objects.order_by('-level')
+
+    for instance in instances:
+
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'key': instance.key,
+            'comment': '',
+            'parent': instance.parent.pk if instance.parent else ''
+        }
+        response = client.put(url, data, content_type='application/json')
+        if response.status_code == 200 and get_obj_perms_status_code(instance, username, 'update') == 404:
+            breakpoint()
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'update'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_delete_multisite(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Attribute.objects.order_by('-level')
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        response = client.delete(url)
+        assert response.status_code == get_obj_perms_status_code(instance,username,'delete'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_detail_export(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Attribute.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail_export'], args=[instance.pk])
+        response = client.get(url)
+        assert response.status_code == status_map['detail'][username], response.content
+
+        if response.status_code == 200:
+            root = et.fromstring(response.content)
+            assert root.tag == 'rdmo'
+            for child in root:
+                assert child.tag in ['attribute']
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_copy_multisite(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Attribute.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['copy'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix + '-',
+            'key': instance.key + '-',
+            'parent': instance.parent.pk if instance.parent else None
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'copy'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_copy_wrong(db, client, username, password):
+    ''' only users and status_map are unique for this test '''
+    client.login(username=username, password=password)
+    instance = Attribute.objects.first()
+
+    url = reverse(urlnames['copy'], args=[instance.pk])
+    data = {
+        'uri_prefix': instance.uri_prefix,
+        'key': instance.key
+    }
+    response = client.put(url, data, content_type='application/json')
+
+    if status_map['copy'][username] == 201:
+        assert response.status_code == 400, response.json()
+    else:
+        assert response.status_code == status_map['copy'][username], response.json()
diff --git a/rdmo/options/tests/test_viewset_options.py b/rdmo/options/tests/test_viewset_options.py
index 9bfdc4b4f8..b4223e5edb 100644
--- a/rdmo/options/tests/test_viewset_options.py
+++ b/rdmo/options/tests/test_viewset_options.py
@@ -19,16 +19,19 @@
         'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
     },
     'detail': {
-        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
+        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 404, 'anonymous': 401
     },
     'create': {
         'editor': 201, 'reviewer': 403, 'api': 201, 'user': 403, 'anonymous': 401
     },
+    'copy': {
+        'editor': 201, 'reviewer': 403, 'api': 201, 'user': 404, 'anonymous': 401
+    },
     'update': {
-        'editor': 200, 'reviewer': 403, 'api': 200, 'user': 403, 'anonymous': 401
+        'editor': 200, 'reviewer': 403, 'api': 200, 'user': 404, 'anonymous': 401
     },
     'delete': {
-        'editor': 204, 'reviewer': 403, 'api': 204, 'user': 403, 'anonymous': 401
+        'editor': 204, 'reviewer': 403, 'api': 204, 'user': 404, 'anonymous': 401
     }
 }
 
@@ -179,7 +182,7 @@ def test_detail_export(db, client, username, password, export_format):
 
     url = reverse(urlnames['detail_export'], args=[instance.pk]) + export_format + '/'
     response = client.get(url)
-    assert response.status_code == status_map['list'][username], response.content
+    assert response.status_code == status_map['detail'][username], response.content
 
     if response.status_code == 200 and export_format == 'xml':
         root = et.fromstring(response.content)
@@ -200,7 +203,7 @@ def test_copy(db, client, username, password):
             'uri_path': instance.uri_path + '-'
         }
         response = client.put(url, data, content_type='application/json')
-        assert response.status_code == status_map['create'][username], response.json()
+        assert response.status_code == status_map['copy'][username], response.json()
 
 
 @pytest.mark.parametrize('username,password', users)
@@ -215,7 +218,7 @@ def test_copy_wrong(db, client, username, password):
     }
     response = client.put(url, data, content_type='application/json')
 
-    if status_map['create'][username] == 201:
+    if status_map['copy'][username] == 201:
         assert response.status_code == 400, response.json()
     else:
-        assert response.status_code == status_map['create'][username], response.json()
+        assert response.status_code == status_map['copy'][username], response.json()
diff --git a/rdmo/options/tests/test_viewset_options_multisite.py b/rdmo/options/tests/test_viewset_options_multisite.py
new file mode 100644
index 0000000000..be5558b794
--- /dev/null
+++ b/rdmo/options/tests/test_viewset_options_multisite.py
@@ -0,0 +1,141 @@
+import xml.etree.ElementTree as et
+
+import pytest
+from django.urls import reverse
+
+from ..models import Option
+
+from ...core.tests import multisite_status_map as status_map
+from ...core.tests import multisite_users as users
+from ...core.tests import get_obj_perms_status_code
+
+from .test_viewset_options import urlnames
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_list(db, client, username, password):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['list'])
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_index(db, client, username, password):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['index'])
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_export(db, client, username, password):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['export'])
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.content
+
+    if response.status_code == 200:
+        root = et.fromstring(response.content)
+        assert root.tag == 'rdmo'
+        for child in root:
+            assert child.tag in ['option']
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_detail(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Option.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        response = client.get(url)
+        assert response.status_code == status_map['detail'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Option.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['list'])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'uri_path': '%s_new_%s' % (instance.uri_path, username),
+            'comment': instance.comment,
+            'text_en': instance.text_lang1,
+            'text_de': instance.text_lang2
+        }
+        response = client.post(url, data, content_type='application/json')
+        assert response.status_code == status_map['create'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_update_multisite(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Option.objects.all()
+
+    for instance in instances:
+        optionsets = [optionset.id for optionset in instance.optionsets.all()]
+
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'uri_path': instance.uri_path,
+            'comment': instance.comment,
+            'text_en': instance.text_lang1,
+            'text_de': instance.text_lang2
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'update'), response.json()
+
+        instance.refresh_from_db()
+        assert optionsets == [optionset.id for optionset in instance.optionsets.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_delete_multisite(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Option.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        response = client.delete(url)
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'delete')
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_copy_multisite(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Option.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['copy'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix + '-',
+            'uri_path': instance.uri_path + '-'
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'copy'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_copy_wrong(db, client, username, password):
+    client.login(username=username, password=password)
+    instance = Option.objects.first()
+
+    url = reverse(urlnames['copy'], args=[instance.pk])
+    data = {
+        'uri_prefix': instance.uri_prefix,
+        'uri_path': instance.uri_path
+    }
+    response = client.put(url, data, content_type='application/json')
+
+    if status_map['copy'][username] == 201:
+        assert response.status_code == 400, response.json()
+    else:
+        assert response.status_code == status_map['copy'][username], response.json()
diff --git a/rdmo/options/tests/test_viewset_optionsets.py b/rdmo/options/tests/test_viewset_optionsets.py
index d4d229795d..4c80a9b1ff 100644
--- a/rdmo/options/tests/test_viewset_optionsets.py
+++ b/rdmo/options/tests/test_viewset_optionsets.py
@@ -18,16 +18,19 @@
         'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
     },
     'detail': {
-        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
+        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 404, 'anonymous': 401
     },
     'create': {
         'editor': 201, 'reviewer': 403, 'api': 201, 'user': 403, 'anonymous': 401
     },
+    'copy': {
+        'editor': 201, 'reviewer': 403, 'api': 201, 'user': 404, 'anonymous': 401
+    },
     'update': {
-        'editor': 200, 'reviewer': 403, 'api': 200, 'user': 403, 'anonymous': 401
+        'editor': 200, 'reviewer': 403, 'api': 200, 'user': 404, 'anonymous': 401
     },
     'delete': {
-        'editor': 204, 'reviewer': 403, 'api': 204, 'user': 403, 'anonymous': 401
+        'editor': 204, 'reviewer': 403, 'api': 204, 'user': 404, 'anonymous': 401
     }
 }
 
@@ -97,7 +100,7 @@ def test_nested(db, client, username, password):
     for instance in instances:
         url = reverse(urlnames['nested'], args=[instance.pk])
         response = client.get(url)
-        assert response.status_code == status_map['list'][username], response.json()
+        assert response.status_code == status_map['detail'][username], response.json()
 
 
 @pytest.mark.parametrize('username,password', users)
@@ -286,7 +289,7 @@ def test_detail_export(db, client, username, password, export_format):
 
     url = reverse(urlnames['detail_export'], args=[instance.pk]) + export_format + '/'
     response = client.get(url)
-    assert response.status_code == status_map['list'][username], response.content
+    assert response.status_code == status_map['detail'][username], response.content
 
     if response.status_code == 200 and export_format == 'xml':
         root = et.fromstring(response.content)
@@ -307,7 +310,7 @@ def test_copy(db, client, username, password):
             'uri_path': instance.uri_path + '-'
         }
         response = client.put(url, data, content_type='application/json')
-        assert response.status_code == status_map['create'][username], response.json()
+        assert response.status_code == status_map['copy'][username], response.json()
 
 
 @pytest.mark.parametrize('username,password', users)
@@ -322,7 +325,7 @@ def test_copy_wrong(db, client, username, password):
     }
     response = client.put(url, data, content_type='application/json')
 
-    if status_map['create'][username] == 201:
+    if status_map['copy'][username] == 201:
         assert response.status_code == 400, response.json()
     else:
-        assert response.status_code == status_map['create'][username], response.json()
+        assert response.status_code == status_map['copy'][username], response.json()
diff --git a/rdmo/options/tests/test_viewset_optionsets_multisite.py b/rdmo/options/tests/test_viewset_optionsets_multisite.py
new file mode 100644
index 0000000000..f07103f0ed
--- /dev/null
+++ b/rdmo/options/tests/test_viewset_optionsets_multisite.py
@@ -0,0 +1,181 @@
+import xml.etree.ElementTree as et
+
+import pytest
+from django.contrib.sites.models import Site
+from django.urls import reverse
+
+from ..models import OptionSet
+
+from ...core.tests import multisite_status_map as status_map
+from ...core.tests import multisite_users as users
+from ...core.tests import get_obj_perms_status_code
+
+from .test_viewset_optionsets import export_formats
+from .test_viewset_optionsets import urlnames
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_list(db, client, username, password):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['list'])
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_detail(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = OptionSet.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        response = client.get(url)
+        assert response.status_code == status_map['detail'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_nested(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = OptionSet.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['nested'], args=[instance.pk])
+        response = client.get(url)
+        assert response.status_code == status_map['detail'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_index(db, client, username, password):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['index'])
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_export(db, client, username, password):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['export'])
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.content
+
+    if response.status_code == 200:
+        root = et.fromstring(response.content)
+        assert root.tag == 'rdmo'
+        for child in root:
+            assert child.tag in ['optionset', 'option']
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = OptionSet.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['list'])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'uri_path': '%s_new_%s' % (instance.uri_path, username),
+            'comment': instance.comment,
+            'order': instance.order,
+            'conditions': [condition.pk for condition in instance.conditions.all()],
+        }
+        response = client.post(url, data)
+        assert response.status_code == status_map['create'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_update_m2m_multisite(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = OptionSet.objects.all()
+
+    for instance in instances:
+        optionset_options = [{
+            'option': optionset_option.option.id,
+            'order': optionset_option.order
+        } for optionset_option in instance.optionset_options.all()[:1]]
+        conditions = [condition.pk for condition in instance.conditions.all()[:1]]
+
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'uri_path': instance.uri_path,
+            'comment': instance.comment,
+            'order': instance.order,
+            'options': optionset_options,
+            'conditions': conditions,
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'update'), response.json()
+
+        if response.status_code == 200:
+            instance.refresh_from_db()
+            assert optionset_options == [{
+                'option': optionset_option.option.id,
+                'order': optionset_option.order
+            } for optionset_option in instance.optionset_options.all()]
+            assert conditions == [condition.pk for condition in instance.conditions.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_delete_multisite(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = OptionSet.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        response = client.delete(url)
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'delete'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_detail_export(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = OptionSet.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail_export'], args=[instance.pk])
+        response = client.get(url)
+        assert response.status_code == status_map['detail'][username], response.content
+
+        if response.status_code == 200:
+            root = et.fromstring(response.content)
+            assert root.tag == 'rdmo'
+            for child in root:
+                assert child.tag in ['optionset', 'option']
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_copy_multisite(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = OptionSet.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['copy'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix + '-',
+            'uri_path': instance.uri_path + '-'
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'copy'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_copy_wrong(db, client, username, password):
+    client.login(username=username, password=password)
+    instance = OptionSet.objects.first()
+
+    url = reverse(urlnames['copy'], args=[instance.pk])
+    data = {
+        'uri_prefix': instance.uri_prefix,
+        'uri_path': instance.uri_path
+    }
+    response = client.put(url, data, content_type='application/json')
+
+    if status_map['copy'][username] == 201:
+        assert response.status_code == 400, response.json()
+    else:
+        assert response.status_code == status_map['copy'][username], response.json()
diff --git a/rdmo/projects/tests/test_handlers.py b/rdmo/projects/tests/test_handlers.py
index 6ee2a928a5..33ea718603 100644
--- a/rdmo/projects/tests/test_handlers.py
+++ b/rdmo/projects/tests/test_handlers.py
@@ -30,15 +30,15 @@
     ('3', [2],        [1, 2],  [1], '10', False),
     ('3', [1, 2, 3],  [1, 2],  [1], '10', False),
 
-    ('3', [],         [],      [1, 2, 3, 4], '10', True),
+    ('3', [],         [],      [1, 2, 3, 4], '10', False),
     ('3', [2],        [],      [1, 2, 3, 4], '10', False),
-    ('3', [1, 2, 3],  [],      [1, 2, 3, 4], '10', True),
+    ('3', [1, 2, 3],  [],      [1, 2, 3, 4], '10', False),
     ('3', [],         [2],     [1, 2, 3, 4], '10', False),
     ('3', [2],        [2],     [1, 2, 3, 4], '10', False),
     ('3', [1, 2, 3],  [2],     [1, 2, 3, 4], '10', False),
-    ('3', [],         [1, 2],  [1, 2, 3, 4], '10', True),
+    ('3', [],         [1, 2],  [1, 2, 3, 4], '10', False),
     ('3', [2],        [1, 2],  [1, 2, 3, 4], '10', False),
-    ('3', [1, 2, 3],  [1, 2],  [1, 2, 3, 4], '10', True)
+    ('3', [1, 2, 3],  [1, 2],  [1, 2, 3, 4], '10', False)
 ]
 
 @pytest.mark.parametrize('view_id,sites,catalogs,groups,project_id,project_exists', view_update_tests)
diff --git a/rdmo/projects/tests/test_view_project.py b/rdmo/projects/tests/test_view_project.py
index 1c48b5d985..3b4488aef7 100644
--- a/rdmo/projects/tests/test_view_project.py
+++ b/rdmo/projects/tests/test_view_project.py
@@ -1,8 +1,8 @@
 import re
 
 import pytest
+from pytest_django.asserts import assertContains, assertNotContains, assertTemplateUsed
 from django.urls import reverse
-from pytest_django.asserts import assertTemplateUsed
 
 from rdmo.views.models import View
 
@@ -68,15 +68,15 @@ def test_list(db, client, username, password):
     if password:
         assert response.status_code == 200
         assertTemplateUsed(response, 'projects/projects.html')
-
         if username == 'site':
             assert projects == []
             assert response.context['number_of_projects'] == len([])
+            assertContains(response, 'View all projects on')
         else:
-            # breakpoint()
             user_projects_map = view_project_permission_map.get(username, [])
             assert sorted(list(set(map(int, projects)))) == user_projects_map
             assert response.context['number_of_projects'] == len(user_projects_map)
+            assertNotContains(response, 'View all projects on')
     else:
         assert response.status_code == 302
 
diff --git a/rdmo/questions/tests/test_models.py b/rdmo/questions/tests/test_models.py
index 3202809b90..c71838f011 100644
--- a/rdmo/questions/tests/test_models.py
+++ b/rdmo/questions/tests/test_models.py
@@ -5,6 +5,13 @@ def test_catalog_str(db):
     instances = Catalog.objects.all()
     for instance in instances:
         assert str(instance)
+        assert str(instance) == instance.uri
+
+
+def test_catalog_property_is_locked(db):
+    instances = Catalog.objects.all()
+    for instance in instances:
+        assert instance.is_locked == instance.locked
 
 
 def test_catalog_clean(db):
@@ -71,6 +78,12 @@ def test_section_str(db):
     instances = Section.objects.all()
     for instance in instances:
         assert str(instance)
+        assert str(instance) == instance.uri
+
+def test_section_property_is_locked(db):
+    instances = Section.objects.all()
+    for instance in instances:
+        assert instance.is_locked == instance.locked or instance.catalog.is_locked
 
 
 def test_section_clean(db):
@@ -190,6 +203,13 @@ def test_questionset_str(db):
     instances = QuestionSet.objects.all()
     for instance in instances:
         assert str(instance)
+        assert str(instance) == instance.uri
+
+
+def test_questionset_property_is_locked(db):
+    instances = QuestionSet.objects.all()
+    for instance in instances:
+        assert instance.is_locked == instance.locked or instance.section.is_locked
 
 
 def test_questionset_clean(db):
@@ -243,6 +263,7 @@ def test_question_str(db):
     instances = Question.objects.all()
     for instance in instances:
         assert str(instance)
+        assert str(instance) == instance.uri
 
 
 def test_question_clean(db):
@@ -251,6 +272,12 @@ def test_question_clean(db):
         instance.clean()
 
 
+def test_questionset_property_is_locked(db):
+    instances = Question.objects.all()
+    for instance in instances:
+        assert instance.is_locked == instance.locked or instance.questionset.is_locked
+
+
 def test_question_copy(db):
     instances = Question.objects.all()
     for instance in instances:
diff --git a/rdmo/questions/tests/test_viewset_catalog.py b/rdmo/questions/tests/test_viewset_catalog.py
index cf291a95dd..edc12efbc8 100644
--- a/rdmo/questions/tests/test_viewset_catalog.py
+++ b/rdmo/questions/tests/test_viewset_catalog.py
@@ -18,16 +18,22 @@
         'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
     },
     'detail': {
-        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
+        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 404, 'anonymous': 401
+    },
+    'nested': {
+        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 404, 'anonymous': 401
     },
     'create': {
         'editor': 201, 'reviewer': 403, 'api': 201, 'user': 403, 'anonymous': 401
     },
+    'copy': {
+        'editor': 201, 'reviewer': 403, 'api': 201, 'user': 404, 'anonymous': 401
+    },
     'update': {
-        'editor': 200, 'reviewer': 403, 'api': 200, 'user': 403, 'anonymous': 401
+        'editor': 200, 'reviewer': 403, 'api': 200, 'user': 404, 'anonymous': 401
     },
     'delete': {
-        'editor': 204, 'reviewer': 403, 'api': 204, 'user': 403, 'anonymous': 401
+        'editor': 204, 'reviewer': 403, 'api': 204, 'user': 404, 'anonymous': 401
     }
 }
 
@@ -97,7 +103,7 @@ def test_nested(db, client, username, password):
     for instance in instances:
         url = reverse(urlnames['nested'], args=[instance.pk])
         response = client.get(url)
-        assert response.status_code == status_map['detail'][username], response.json()
+        assert response.status_code == status_map['nested'][username], response.json()
 
 
 @pytest.mark.parametrize('username,password', users)
@@ -232,7 +238,7 @@ def test_detail_export(db, client, username, password, export_format):
 
     url = reverse(urlnames['detail_export'], args=[instance.pk]) + export_format + '/'
     response = client.get(url)
-    assert response.status_code == status_map['list'][username], response.content
+    assert response.status_code == status_map['detail'][username], response.content
 
     if response.status_code == 200 and export_format == 'xml':
         root = et.fromstring(response.content)
@@ -253,7 +259,7 @@ def test_copy(db, client, username, password):
             'uri_path': instance.uri_path + '-',
         }
         response = client.put(url, data, content_type='application/json')
-        assert response.status_code == status_map['create'][username], response.json()
+        assert response.status_code == status_map['copy'][username], response.json()
 
 
 @pytest.mark.parametrize('username,password', users)
@@ -268,7 +274,7 @@ def test_copy_wrong(db, client, username, password):
     }
     response = client.put(url, data, content_type='application/json')
 
-    if status_map['create'][username] == 201:
+    if status_map['copy'][username] == 201:
         assert response.status_code == 400, response.json()
     else:
-        assert response.status_code == status_map['create'][username], response.json()
+        assert response.status_code == status_map['copy'][username], response.json()
diff --git a/rdmo/questions/tests/test_viewset_catalog_multisite.py b/rdmo/questions/tests/test_viewset_catalog_multisite.py
new file mode 100644
index 0000000000..65bd0cb09a
--- /dev/null
+++ b/rdmo/questions/tests/test_viewset_catalog_multisite.py
@@ -0,0 +1,242 @@
+import xml.etree.ElementTree as et
+
+import pytest
+from django.urls import reverse
+
+from ...core.tests import multisite_status_map as status_map
+from ...core.tests import multisite_users as users
+from ...core.tests import get_obj_perms_status_code
+
+from ..models import Catalog
+
+from .test_viewset_catalog import urlnames, export_formats
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_list(db, client, username, password):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['list'])
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_index(db, client, username, password):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['index'])
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+@pytest.mark.parametrize('export_format', export_formats)
+def test_export(db, client, username, password, export_format):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['export']) + export_format + '/'
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.content
+
+    if response.status_code == 200 and export_format == 'xml':
+        root = et.fromstring(response.content)
+        assert root.tag == 'rdmo'
+        for child in root:
+            assert child.tag in ['catalog', 'section', 'page', 'questionset', 'question']
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_detail(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Catalog.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        response = client.get(url)
+        assert response.status_code == status_map['detail'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_nested(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Catalog.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['nested'], args=[instance.pk])
+        response = client.get(url)
+        assert response.status_code == status_map['detail'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Catalog.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['list'])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'uri_path': '%s_new_%s' % (instance.uri_path, username),
+            'comment': instance.comment,
+            'order': instance.order,
+            'title_en': instance.title_lang1,
+            'title_de': instance.title_lang2
+        }
+        response = client.post(url, data)
+        assert response.status_code == status_map['create'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_m2m(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Catalog.objects.all()
+
+    for instance in instances:
+        catalog_sections = [{
+            'section': section.section.id,
+            'order': section.order
+        } for section in instance.catalog_sections.all()[:1]]
+
+        url = reverse(urlnames['list'])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'uri_path': '%s_new_%s' % (instance.uri_path, username),
+            'comment': instance.comment,
+            'order': instance.order,
+            'title_en': instance.title_lang1,
+            'title_de': instance.title_lang2,
+            'sections': catalog_sections
+        }
+        response = client.post(url, data, content_type='application/json')
+        assert response.status_code == status_map['create'][username], response.json()
+
+        if response.status_code == 201:
+            new_instance = Catalog.objects.get(id=response.json().get('id'))
+            assert catalog_sections == [{
+                'section': section.section.id,
+                'order': section.order
+            } for section in new_instance.catalog_sections.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_update(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Catalog.objects.all()
+
+    for instance in instances:
+        catalog_sections = [{
+            'section': section.section.id,
+            'order': section.order
+        } for section in instance.catalog_sections.all()]
+
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'uri_path': instance.uri_path,
+            'comment': instance.comment,
+            'order': instance.order,
+            'title_en': instance.title_lang1,
+            'title_de': instance.title_lang2
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'update'), response.json()
+
+        instance.refresh_from_db()
+        assert catalog_sections == [{
+            'section': section.section.id,
+            'order': section.order
+        } for section in instance.catalog_sections.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_update_m2m(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Catalog.objects.all()
+
+    for instance in instances:
+        catalog_sections = [{
+            'section': section.section.id,
+            'order': section.order
+        } for section in instance.catalog_sections.all()[:1]]
+
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'uri_path': instance.uri_path,
+            'comment': instance.comment,
+            'order': instance.order,
+            'title_en': instance.title_lang1,
+            'title_de': instance.title_lang2,
+            'sections': catalog_sections
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'update'), response.json()
+
+        if response.status_code == 200:
+            instance.refresh_from_db()
+            assert catalog_sections == [{
+                'section': section.section.id,
+                'order': section.order
+            } for section in instance.catalog_sections.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_delete(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Catalog.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        response = client.delete(url)
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'delete'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+@pytest.mark.parametrize('export_format', export_formats)
+def test_detail_export(db, client, username, password, export_format):
+    client.login(username=username, password=password)
+    instance = Catalog.objects.first()
+
+    url = reverse(urlnames['detail_export'], args=[instance.pk]) + export_format + '/'
+    response = client.get(url)
+    assert response.status_code == status_map['detail'][username], response.content
+
+    if response.status_code == 200 and export_format == 'xml':
+        root = et.fromstring(response.content)
+        assert root.tag == 'rdmo'
+        for child in root:
+            assert child.tag in ['catalog', 'section', 'page', 'questionset', 'question']
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_copy(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Catalog.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['copy'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix + '-',
+            'uri_path': instance.uri_path + '-',
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'copy'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_copy_wrong(db, client, username, password):
+    client.login(username=username, password=password)
+    instance = Catalog.objects.first()
+
+    url = reverse(urlnames['copy'], args=[instance.pk])
+    data = {
+        'uri_prefix': instance.uri_prefix,
+        'uri_path': instance.uri_path
+    }
+    response = client.put(url, data, content_type='application/json')
+
+    if status_map['copy'][username] == 201:
+        assert response.status_code == 400, response.json()
+    else:
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'copy'), response.json()
diff --git a/rdmo/questions/tests/test_viewset_page.py b/rdmo/questions/tests/test_viewset_page.py
index b9988ae01d..628efdbd16 100644
--- a/rdmo/questions/tests/test_viewset_page.py
+++ b/rdmo/questions/tests/test_viewset_page.py
@@ -19,16 +19,22 @@
         'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
     },
     'detail': {
-        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
+        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 404, 'anonymous': 401
+    },
+    'nested': {
+        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 404, 'anonymous': 401
     },
     'create': {
         'editor': 201, 'reviewer': 403, 'api': 201, 'user': 403, 'anonymous': 401
     },
+    'copy': {
+        'editor': 201, 'reviewer': 403, 'api': 201, 'user': 404, 'anonymous': 401
+    },
     'update': {
-        'editor': 200, 'reviewer': 403, 'api': 200, 'user': 403, 'anonymous': 401
+        'editor': 200, 'reviewer': 403, 'api': 200, 'user': 404, 'anonymous': 401
     },
     'delete': {
-        'editor': 204, 'reviewer': 403, 'api': 204, 'user': 403, 'anonymous': 401
+        'editor': 204, 'reviewer': 403, 'api': 204, 'user': 404, 'anonymous': 401
     }
 }
 
@@ -321,7 +327,7 @@ def test_detail_export(db, client, username, password, export_format):
 
     url = reverse(urlnames['detail_export'], args=[instance.pk]) + export_format + '/'
     response = client.get(url)
-    assert response.status_code == status_map['list'][username], response.content
+    assert response.status_code == status_map['detail'][username], response.content
 
     if response.status_code == 200 and export_format == 'xml':
         root = et.fromstring(response.content)
@@ -342,7 +348,7 @@ def test_copy(db, client, username, password):
             'uri_path': instance.uri_path + '-'
         }
         response = client.put(url, data, content_type='application/json')
-        assert response.status_code == status_map['create'][username], response.json()
+        assert response.status_code == status_map['copy'][username], response.json()
 
 
 @pytest.mark.parametrize('username,password', users)
@@ -357,7 +363,7 @@ def test_copy_wrong(db, client, username, password):
     }
     response = client.put(url, data, content_type='application/json')
 
-    if status_map['create'][username] == 201:
+    if status_map['copy'][username] == 201:
         assert response.status_code == 400, response.json()
     else:
-        assert response.status_code == status_map['create'][username], response.json()
+        assert response.status_code == status_map['copy'][username], response.json()
diff --git a/rdmo/questions/tests/test_viewset_page_multisite.py b/rdmo/questions/tests/test_viewset_page_multisite.py
new file mode 100644
index 0000000000..4499ea4403
--- /dev/null
+++ b/rdmo/questions/tests/test_viewset_page_multisite.py
@@ -0,0 +1,331 @@
+import xml.etree.ElementTree as et
+
+import pytest
+from django.db.models import Max
+from django.urls import reverse
+
+from ...core.tests import multisite_status_map as status_map
+from ...core.tests import multisite_users as users
+from ...core.tests import get_obj_perms_status_code
+
+from ..models import Page
+
+from .test_viewset_page import urlnames, export_formats
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_list(db, client, username, password):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['list'])
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_index(db, client, username, password):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['index'])
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+@pytest.mark.parametrize('export_format', export_formats)
+def test_export(db, client, username, password, export_format):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['export']) + export_format + '/'
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.content
+
+    if response.status_code == 200 and export_format == 'xml':
+        root = et.fromstring(response.content)
+        assert root.tag == 'rdmo'
+        for child in root:
+            assert child.tag in ['page', 'questionset', 'question']
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_detail(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Page.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        response = client.get(url)
+        assert response.status_code == status_map['detail'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_nested(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Page.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['nested'], args=[instance.pk])
+        response = client.get(url)
+        assert response.status_code == status_map['detail'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Page.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['list'])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'uri_path': '%s_new_%s' % (instance.uri_path, username),
+            'comment': instance.comment,
+            'attribute': instance.attribute.pk if instance.attribute else '',
+            'is_collection': instance.is_collection,
+            'title_en': instance.title_lang1,
+            'title_de': instance.title_lang2,
+            'help_en': instance.help_lang1,
+            'help_de': instance.help_lang2,
+            'verbose_name_en': instance.verbose_name_lang1,
+            'verbose_name_de': instance.verbose_name_lang2,
+            'verbose_name_plural_en': instance.verbose_name_plural_lang1,
+            'verbose_name_plural_de': instance.verbose_name_plural_lang2
+        }
+        response = client.post(url, data, content_type='application/json')
+        assert response.status_code == status_map['create'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_section(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Page.objects.all()
+
+    for instance in instances:
+        section = instance.sections.first()
+        if section is not None:
+            section_pages = list(section.section_pages.values_list('page', 'order'))
+            order = section.section_pages.aggregate(order=Max('order')).get('order') + 1
+
+            url = reverse(urlnames['list'])
+            data = {
+                'uri_prefix': instance.uri_prefix,
+                'uri_path': '%s_new_%s' % (instance.uri_path, username),
+                'comment': instance.comment,
+                'attribute': instance.attribute.pk if instance.attribute else '',
+                'is_collection': instance.is_collection,
+                'title_en': instance.title_lang1,
+                'title_de': instance.title_lang2,
+                'help_en': instance.help_lang1,
+                'help_de': instance.help_lang2,
+                'verbose_name_en': instance.verbose_name_lang1,
+                'verbose_name_de': instance.verbose_name_lang2,
+                'verbose_name_plural_en': instance.verbose_name_plural_lang1,
+                'verbose_name_plural_de': instance.verbose_name_plural_lang2,
+                'sections': [section.id]
+            }
+            response = client.post(url, data, content_type='application/json')
+            assert response.status_code == status_map['create'][username], response.json()
+
+            if response.status_code == 201:
+                new_instance = Page.objects.get(id=response.json().get('id'))
+                section.refresh_from_db()
+                assert section_pages + [(new_instance.id, order)] == \
+                    list(section.section_pages.values_list('page', 'order'))
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_m2m(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Page.objects.all()
+
+    for instance in instances:
+        page_questionsets = [{
+            'questionset': page_questionset.questionset.id,
+            'order': page_questionset.order
+        } for page_questionset in instance.page_questionsets.all()[:1]]
+        page_questions = [{
+            'question': page_question.question.id,
+            'order': page_question.order
+        } for page_question in instance.page_questions.all()[:1]]
+        conditions = [condition.pk for condition in instance.conditions.all()[:1]]
+
+        url = reverse(urlnames['list'])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'uri_path': '%s_new_%s' % (instance.uri_path, username),
+            'comment': instance.comment,
+            'attribute': instance.attribute.pk if instance.attribute else '',
+            'is_collection': instance.is_collection,
+            'title_en': instance.title_lang1,
+            'title_de': instance.title_lang2,
+            'help_en': instance.help_lang1,
+            'help_de': instance.help_lang2,
+            'verbose_name_en': instance.verbose_name_lang1,
+            'verbose_name_de': instance.verbose_name_lang2,
+            'verbose_name_plural_en': instance.verbose_name_plural_lang1,
+            'verbose_name_plural_de': instance.verbose_name_plural_lang2,
+            'questionsets': page_questionsets,
+            'questions': page_questions,
+            'conditions': conditions
+        }
+        response = client.post(url, data, content_type='application/json')
+        assert response.status_code == status_map['create'][username], response.json()
+
+        if response.status_code == 201:
+            new_instance = Page.objects.get(id=response.json().get('id'))
+            assert page_questionsets == [{
+                'questionset': page_questionset.questionset.id,
+                'order': page_questionset.order
+            } for page_questionset in new_instance.page_questionsets.all()]
+            assert page_questions == [{
+                'question': page_question.question.id,
+                'order': page_question.order
+            } for page_question in new_instance.page_questions.all()]
+            assert conditions == [condition.pk for condition in new_instance.conditions.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_update(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Page.objects.all()
+
+    for instance in instances:
+        questionsets = [questionset.id for questionset in instance.questionsets.all()]
+        questions = [question.id for question in instance.questions.all()]
+        conditions = [condition.pk for condition in instance.conditions.all()]
+
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'uri_path': instance.uri_path,
+            'comment': instance.comment,
+            'attribute': instance.attribute.pk if instance.attribute else None,
+            'is_collection': instance.is_collection,
+            'title_en': instance.title_lang1,
+            'title_de': instance.title_lang2,
+            'help_en': instance.help_lang1,
+            'help_de': instance.help_lang2,
+            'verbose_name_en': instance.verbose_name_lang1,
+            'verbose_name_de': instance.verbose_name_lang2,
+            'verbose_name_plural_en': instance.verbose_name_plural_lang1,
+            'verbose_name_plural_de': instance.verbose_name_plural_lang2
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'update'), response.json()
+
+        instance.refresh_from_db()
+        assert questionsets == [questionset.id for questionset in instance.questionsets.all()]
+        assert questions == [question.id for question in instance.questions.all()]
+        assert conditions == [condition.pk for condition in instance.conditions.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_update_m2m(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Page.objects.all()
+
+    for instance in instances:
+        page_questionsets = [{
+            'questionset': page_questionset.questionset.id,
+            'order': page_questionset.order
+        } for page_questionset in instance.page_questionsets.all()[:1]]
+        page_questions = [{
+            'question': page_question.question.id,
+            'order': page_question.order
+        } for page_question in instance.page_questions.all()[:1]]
+        conditions = [condition.pk for condition in instance.conditions.all()[:1]]
+
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'uri_path': instance.uri_path,
+            'comment': instance.comment,
+            'attribute': instance.attribute.pk if instance.attribute else None,
+            'is_collection': instance.is_collection,
+            'title_en': instance.title_lang1,
+            'title_de': instance.title_lang2,
+            'help_en': instance.help_lang1,
+            'help_de': instance.help_lang2,
+            'verbose_name_en': instance.verbose_name_lang1,
+            'verbose_name_de': instance.verbose_name_lang2,
+            'verbose_name_plural_en': instance.verbose_name_plural_lang1,
+            'verbose_name_plural_de': instance.verbose_name_plural_lang2,
+            'questionsets': page_questionsets,
+            'questions': page_questions,
+            'conditions': conditions
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'update'), response.json()
+
+        if response.status_code == 200:
+            instance.refresh_from_db()
+            assert page_questionsets == [{
+                'questionset': page_questionset.questionset.id,
+                'order': page_questionset.order
+            } for page_questionset in instance.page_questionsets.all()]
+            assert page_questions == [{
+                'question': page_question.question.id,
+                'order': page_question.order
+            } for page_question in instance.page_questions.all()]
+            assert conditions == [condition.pk for condition in instance.conditions.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_delete(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Page.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        response = client.delete(url)
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'delete'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+@pytest.mark.parametrize('export_format', export_formats)
+def test_detail_export(db, client, username, password, export_format):
+    client.login(username=username, password=password)
+    instance = Page.objects.first()
+
+    url = reverse(urlnames['detail_export'], args=[instance.pk]) + export_format + '/'
+    response = client.get(url)
+    assert response.status_code == status_map['detail'][username], response.content
+
+    if response.status_code == 200 and export_format == 'xml':
+        root = et.fromstring(response.content)
+        assert root.tag == 'rdmo'
+        for child in root:
+            assert child.tag in ['page', 'questionset', 'question']
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_copy(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Page.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['copy'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix + '-',
+            'uri_path': instance.uri_path + '-'
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'copy'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_copy_wrong(db, client, username, password):
+    client.login(username=username, password=password)
+    instance = Page.objects.first()
+
+    url = reverse(urlnames['copy'], args=[instance.pk])
+    data = {
+        'uri_prefix': instance.uri_prefix,
+        'uri_path': instance.uri_path
+    }
+    response = client.put(url, data, content_type='application/json')
+
+    if get_obj_perms_status_code(instance, username, 'copy') == 201:
+        assert response.status_code == 400, response.json()
+    else:
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'copy'), response.json()
diff --git a/rdmo/questions/tests/test_viewset_question.py b/rdmo/questions/tests/test_viewset_question.py
index 10827d984a..6a3fc3665f 100644
--- a/rdmo/questions/tests/test_viewset_question.py
+++ b/rdmo/questions/tests/test_viewset_question.py
@@ -19,22 +19,28 @@
         'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
     },
     'detail': {
-        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
+        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 404, 'anonymous': 401
+    },
+    'nested': {
+        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 404, 'anonymous': 401
     },
     'create': {
         'editor': 201, 'reviewer': 403, 'api': 201, 'user': 403, 'anonymous': 401
     },
+    'copy': {
+        'editor': 201, 'reviewer': 403, 'api': 201, 'user': 404, 'anonymous': 401
+    },
     'update': {
-        'editor': 200, 'reviewer': 403, 'api': 200, 'user': 403, 'anonymous': 401
+        'editor': 200, 'reviewer': 403, 'api': 200, 'user': 404, 'anonymous': 401
     },
     'delete': {
-        'editor': 204, 'reviewer': 403, 'api': 204, 'user': 403, 'anonymous': 401
+        'editor': 204, 'reviewer': 403, 'api': 204, 'user': 404, 'anonymous': 401
     }
 }
 
+
 urlnames = {
     'list': 'v1-questions:question-list',
-    'nested': 'v1-questions:question-nested',
     'index': 'v1-questions:question-index',
     'export': 'v1-questions:question-export',
     'detail': 'v1-questions:question-detail',
@@ -358,7 +364,7 @@ def test_detail_export(db, client, username, password, export_format):
 
     url = reverse(urlnames['detail_export'], args=[instance.pk]) + export_format + '/'
     response = client.get(url)
-    assert response.status_code == status_map['list'][username], response.content
+    assert response.status_code == status_map['detail'][username], response.content
 
     if response.status_code == 200 and export_format == 'xml':
         root = et.fromstring(response.content)
@@ -379,7 +385,7 @@ def test_copy(db, client, username, password):
             'uri_path': instance.uri_path + '-'
         }
         response = client.put(url, data, content_type='application/json')
-        assert response.status_code == status_map['create'][username], response.json()
+        assert response.status_code == status_map['copy'][username], response.json()
 
 
 @pytest.mark.parametrize('username,password', users)
@@ -394,7 +400,7 @@ def test_copy_wrong(db, client, username, password):
     }
     response = client.put(url, data, content_type='application/json')
 
-    if status_map['create'][username] == 201:
+    if status_map['copy'][username] == 201:
         assert response.status_code == 400, response.json()
     else:
-        assert response.status_code == status_map['create'][username], response.json()
+        assert response.status_code == status_map['copy'][username], response.json()
diff --git a/rdmo/questions/tests/test_viewset_question_multisite.py b/rdmo/questions/tests/test_viewset_question_multisite.py
new file mode 100644
index 0000000000..1eed0425ff
--- /dev/null
+++ b/rdmo/questions/tests/test_viewset_question_multisite.py
@@ -0,0 +1,368 @@
+import xml.etree.ElementTree as et
+
+import pytest
+from django.db.models import Max
+from django.urls import reverse
+
+from ...core.tests import multisite_status_map as status_map
+from ...core.tests import multisite_users as users
+from ...core.tests import get_obj_perms_status_code
+
+from ..models import Question
+
+from .test_viewset_question import urlnames, export_formats
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_list(db, client, username, password):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['list'])
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_index(db, client, username, password):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['index'])
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+@pytest.mark.parametrize('export_format', export_formats)
+def test_export(db, client, username, password, export_format):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['export']) + export_format + '/'
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.content
+
+    if response.status_code == 200 and export_format == 'xml':
+        root = et.fromstring(response.content)
+        assert root.tag == 'rdmo'
+        for child in root:
+            assert child.tag in ['question']
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_detail(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Question.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        response = client.get(url)
+        assert response.status_code == status_map['detail'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Question.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['list'])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'uri_path': '%s_new_%s' % (instance.uri_path, username),
+            'comment': instance.comment or '',
+            'attribute': instance.attribute.pk if instance.attribute else '',
+            'is_collection': instance.is_collection,
+            'help_en': instance.help_lang1 or '',
+            'help_de': instance.help_lang2 or '',
+            'text_en': instance.text_lang1 or '',
+            'text_de': instance.text_lang2 or '',
+            'verbose_name_en': instance.verbose_name_lang1 or '',
+            'verbose_name_de': instance.verbose_name_lang2 or '',
+            'verbose_name_plural_en': instance.verbose_name_plural_lang1 or '',
+            'verbose_name_plural_de': instance.verbose_name_plural_lang2 or '',
+            'widget_type': instance.widget_type,
+            'value_type': instance.value_type,
+            'minimum': instance.minimum or '',
+            'maximum': instance.maximum or '',
+            'step': instance.step or '',
+            'unit': instance.unit or ''
+        }
+        response = client.post(url, data, content_type='application/json')
+        assert response.status_code == status_map['create'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_page(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Question.objects.all()
+
+    for instance in instances:
+        page = instance.pages.first()
+        if page is not None:
+            page_questions = list(page.page_questions.values_list('question', 'order'))
+            order = page.page_questions.aggregate(order=Max('order')).get('order') + 1
+
+            url = reverse(urlnames['list'])
+            data = {
+                'uri_prefix': instance.uri_prefix,
+                'uri_path': '%s_new_%s' % (instance.uri_path, username),
+                'comment': instance.comment or '',
+                'attribute': instance.attribute.pk if instance.attribute else '',
+                'is_collection': instance.is_collection,
+                'help_en': instance.help_lang1 or '',
+                'help_de': instance.help_lang2 or '',
+                'text_en': instance.text_lang1 or '',
+                'text_de': instance.text_lang2 or '',
+                'verbose_name_en': instance.verbose_name_lang1 or '',
+                'verbose_name_de': instance.verbose_name_lang2 or '',
+                'verbose_name_plural_en': instance.verbose_name_plural_lang1 or '',
+                'verbose_name_plural_de': instance.verbose_name_plural_lang2 or '',
+                'widget_type': instance.widget_type,
+                'value_type': instance.value_type,
+                'minimum': instance.minimum or '',
+                'maximum': instance.maximum or '',
+                'step': instance.step or '',
+                'unit': instance.unit or '',
+                'pages': [page.id]
+            }
+            response = client.post(url, data, content_type='application/json')
+            assert response.status_code == status_map['create'][username], response.json()
+
+            if response.status_code == 201:
+                new_instance = Question.objects.get(id=response.json().get('id'))
+                page.refresh_from_db()
+                assert page_questions + [(new_instance.id, order)] == \
+                    list(page.page_questions.values_list('question', 'order'))
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_questionset(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Question.objects.all()
+
+    for instance in instances:
+        questionset = instance.questionsets.first()
+        if questionset is not None:
+            questionset_questions = list(questionset.questionset_questions.values_list('question', 'order'))
+            order = questionset.questionset_questions.aggregate(order=Max('order')).get('order') + 1
+
+            url = reverse(urlnames['list'])
+            data = {
+                'uri_prefix': instance.uri_prefix,
+                'uri_path': '%s_new_%s' % (instance.uri_path, username),
+                'comment': instance.comment or '',
+                'attribute': instance.attribute.pk if instance.attribute else '',
+                'is_collection': instance.is_collection,
+                'help_en': instance.help_lang1 or '',
+                'help_de': instance.help_lang2 or '',
+                'text_en': instance.text_lang1 or '',
+                'text_de': instance.text_lang2 or '',
+                'verbose_name_en': instance.verbose_name_lang1 or '',
+                'verbose_name_de': instance.verbose_name_lang2 or '',
+                'verbose_name_plural_en': instance.verbose_name_plural_lang1 or '',
+                'verbose_name_plural_de': instance.verbose_name_plural_lang2 or '',
+                'widget_type': instance.widget_type,
+                'value_type': instance.value_type,
+                'minimum': instance.minimum or '',
+                'maximum': instance.maximum or '',
+                'step': instance.step or '',
+                'unit': instance.unit or '',
+                'questionsets': [questionset.id]
+            }
+            response = client.post(url, data, content_type='application/json')
+            assert response.status_code == status_map['create'][username], response.json()
+
+            if response.status_code == 201:
+                new_instance = Question.objects.get(id=response.json().get('id'))
+                questionset.refresh_from_db()
+                assert questionset_questions + [(new_instance.id, order)] == \
+                    list(questionset.questionset_questions.values_list('question', 'order'))
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_m2m(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Question.objects.all()
+
+    for instance in instances:
+        optionsets = [optionset.id for optionset in instance.optionsets.all()[:1]]
+        conditions = [condition.pk for condition in instance.conditions.all()[:1]]
+
+        url = reverse(urlnames['list'])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'uri_path': '%s_new_%s' % (instance.uri_path, username),
+            'comment': instance.comment or '',
+            'attribute': instance.attribute.pk if instance.attribute else '',
+            'is_collection': instance.is_collection,
+            'help_en': instance.help_lang1 or '',
+            'help_de': instance.help_lang2 or '',
+            'text_en': instance.text_lang1 or '',
+            'text_de': instance.text_lang2 or '',
+            'verbose_name_en': instance.verbose_name_lang1 or '',
+            'verbose_name_de': instance.verbose_name_lang2 or '',
+            'verbose_name_plural_en': instance.verbose_name_plural_lang1 or '',
+            'verbose_name_plural_de': instance.verbose_name_plural_lang2 or '',
+            'widget_type': instance.widget_type,
+            'value_type': instance.value_type,
+            'minimum': instance.minimum or '',
+            'maximum': instance.maximum or '',
+            'step': instance.step or '',
+            'unit': instance.unit or '',
+            'optionsets': optionsets,
+            'conditions': conditions
+        }
+        response = client.post(url, data, content_type='application/json')
+        assert response.status_code == status_map['create'][username], response.json()
+
+        if response.status_code == 201:
+            new_instance = Question.objects.get(id=response.json().get('id'))
+            assert optionsets == [optionset.pk for optionset in new_instance.optionsets.all()]
+            assert conditions == [condition.pk for condition in new_instance.conditions.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_update(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Question.objects.all()
+
+    for instance in instances:
+        pages = [page.id for page in instance.pages.all()]
+        questionsets = [questionset.id for questionset in instance.questionsets.all()]
+        optionsets = [optionset.id for optionset in instance.optionsets.all()]
+        conditions = [condition.pk for condition in instance.conditions.all()]
+
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'uri_path': instance.uri_path,
+            'comment': instance.comment,
+            'attribute': instance.attribute.pk if instance.attribute else None,
+            'is_collection': instance.is_collection,
+            'help_en': instance.help_lang1,
+            'help_de': instance.help_lang2,
+            'text_en': instance.text_lang1,
+            'text_de': instance.text_lang2,
+            'verbose_name_en': instance.verbose_name_lang1,
+            'verbose_name_de': instance.verbose_name_lang2,
+            'verbose_name_plural_en': instance.verbose_name_plural_lang1,
+            'verbose_name_plural_de': instance.verbose_name_plural_lang2,
+            'widget_type': instance.widget_type,
+            'value_type': instance.value_type,
+            'minimum': instance.minimum,
+            'maximum': instance.maximum,
+            'step': instance.step,
+            'unit': instance.unit,
+            'optionsets': [optionset.pk for optionset in instance.optionsets.all()],
+            'conditions': [condition.pk for condition in instance.conditions.all()],
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'update'), response.json()
+
+        instance.refresh_from_db()
+        assert pages == [page.id for page in instance.pages.all()]
+        assert questionsets == [questionset.id for questionset in instance.questionsets.all()]
+        assert optionsets == [optionset.id for optionset in instance.optionsets.all()]
+        assert conditions == [condition.pk for condition in instance.conditions.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_update_m2m(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Question.objects.all()
+
+    for instance in instances:
+        optionsets = [optionset.id for optionset in instance.optionsets.all()[:1]]
+        conditions = [condition.pk for condition in instance.conditions.all()[:1]]
+
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'uri_path': instance.uri_path,
+            'comment': instance.comment,
+            'attribute': instance.attribute.pk if instance.attribute else None,
+            'is_collection': instance.is_collection,
+            'help_en': instance.help_lang1,
+            'help_de': instance.help_lang2,
+            'text_en': instance.text_lang1,
+            'text_de': instance.text_lang2,
+            'verbose_name_en': instance.verbose_name_lang1,
+            'verbose_name_de': instance.verbose_name_lang2,
+            'verbose_name_plural_en': instance.verbose_name_plural_lang1,
+            'verbose_name_plural_de': instance.verbose_name_plural_lang2,
+            'widget_type': instance.widget_type,
+            'value_type': instance.value_type,
+            'minimum': instance.minimum,
+            'maximum': instance.maximum,
+            'step': instance.step,
+            'unit': instance.unit,
+            'optionsets': optionsets,
+            'conditions': conditions
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'update'), response.json()
+
+        if response.status_code == 200:
+            instance.refresh_from_db()
+            assert optionsets == [optionset.pk for optionset in instance.optionsets.all()]
+            assert conditions == [condition.pk for condition in instance.conditions.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_delete(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Question.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        response = client.delete(url)
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'delete'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+@pytest.mark.parametrize('export_format', export_formats)
+def test_detail_export(db, client, username, password, export_format):
+    client.login(username=username, password=password)
+    instance = Question.objects.first()
+
+    url = reverse(urlnames['detail_export'], args=[instance.pk]) + export_format + '/'
+    response = client.get(url)
+    assert response.status_code == status_map['detail'][username], response.content
+
+    if response.status_code == 200 and export_format == 'xml':
+        root = et.fromstring(response.content)
+        assert root.tag == 'rdmo'
+        for child in root:
+            assert child.tag in ['question']
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_copy(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Question.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['copy'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix + '-',
+            'uri_path': instance.uri_path + '-'
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'copy'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_copy_wrong(db, client, username, password):
+    client.login(username=username, password=password)
+    instance = Question.objects.first()
+
+    url = reverse(urlnames['copy'], args=[instance.pk])
+    data = {
+        'uri_prefix': instance.uri_prefix,
+        'uri_path': instance.uri_path
+    }
+    response = client.put(url, data, content_type='application/json')
+
+    if get_obj_perms_status_code(instance, username, 'copy') == 201:
+        assert response.status_code == 400, response.json()
+    else:
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'copy'), response.json()
diff --git a/rdmo/questions/tests/test_viewset_questionset.py b/rdmo/questions/tests/test_viewset_questionset.py
index 945711228e..fc575a812b 100644
--- a/rdmo/questions/tests/test_viewset_questionset.py
+++ b/rdmo/questions/tests/test_viewset_questionset.py
@@ -19,16 +19,22 @@
         'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
     },
     'detail': {
-        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
+        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 404, 'anonymous': 401
+    },
+    'nested': {
+        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 404, 'anonymous': 401
     },
     'create': {
         'editor': 201, 'reviewer': 403, 'api': 201, 'user': 403, 'anonymous': 401
     },
+    'copy': {
+        'editor': 201, 'reviewer': 403, 'api': 201, 'user': 404, 'anonymous': 401
+    },
     'update': {
-        'editor': 200, 'reviewer': 403, 'api': 200, 'user': 403, 'anonymous': 401
+        'editor': 200, 'reviewer': 403, 'api': 200, 'user': 404, 'anonymous': 401
     },
     'delete': {
-        'editor': 204, 'reviewer': 403, 'api': 204, 'user': 403, 'anonymous': 401
+        'editor': 204, 'reviewer': 403, 'api': 204, 'user': 404, 'anonymous': 401
     }
 }
 
@@ -96,9 +102,9 @@ def test_nested(db, client, username, password):
     instances = QuestionSet.objects.all()
 
     for instance in instances:
-        url = reverse(urlnames['nested'], args=[instance.pk])
+        url = reverse(urlnames['detail'], args=[instance.pk])
         response = client.get(url)
-        assert response.status_code == status_map['detail'][username], response.json()
+        assert response.status_code == status_map['nested'][username], response.json()
 
 
 @pytest.mark.parametrize('username,password', users)
@@ -363,7 +369,7 @@ def test_detail_export(db, client, username, password, export_format):
 
     url = reverse(urlnames['detail_export'], args=[instance.pk]) + export_format + '/'
     response = client.get(url)
-    assert response.status_code == status_map['list'][username], response.content
+    assert response.status_code == status_map['detail'][username], response.content
 
     if response.status_code == 200 and export_format == 'xml':
         root = et.fromstring(response.content)
@@ -384,7 +390,7 @@ def test_copy(db, client, username, password):
             'uri_path': instance.uri_path + '-'
         }
         response = client.put(url, data, content_type='application/json')
-        assert response.status_code == status_map['create'][username], response.json()
+        assert response.status_code == status_map['copy'][username], response.json()
 
 
 @pytest.mark.parametrize('username,password', users)
@@ -399,7 +405,7 @@ def test_copy_wrong(db, client, username, password):
     }
     response = client.put(url, data, content_type='application/json')
 
-    if status_map['create'][username] == 201:
+    if status_map['copy'][username] == 201:
         assert response.status_code == 400, response.json()
     else:
-        assert response.status_code == status_map['create'][username], response.json()
+        assert response.status_code == status_map['copy'][username], response.json()
diff --git a/rdmo/questions/tests/test_viewset_questionset_multisite.py b/rdmo/questions/tests/test_viewset_questionset_multisite.py
new file mode 100644
index 0000000000..c17b024790
--- /dev/null
+++ b/rdmo/questions/tests/test_viewset_questionset_multisite.py
@@ -0,0 +1,372 @@
+import xml.etree.ElementTree as et
+
+import pytest
+from django.db.models import Max
+from django.urls import reverse
+
+from ...core.tests import multisite_status_map as status_map
+from ...core.tests import multisite_users as users
+from ...core.tests import get_obj_perms_status_code
+
+from ..models import QuestionSet
+
+from .test_viewset_questionset import urlnames, export_formats
+
+@pytest.mark.parametrize('username,password', users)
+def test_list(db, client, username, password):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['list'])
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_index(db, client, username, password):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['index'])
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+@pytest.mark.parametrize('export_format', export_formats)
+def test_export(db, client, username, password, export_format):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['export']) + export_format + '/'
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.content
+
+    if response.status_code == 200 and export_format == 'xml':
+        root = et.fromstring(response.content)
+        assert root.tag == 'rdmo'
+        for child in root:
+            assert child.tag in ['questionset', 'question']
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_detail(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = QuestionSet.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        response = client.get(url)
+        assert response.status_code == status_map['detail'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_nested(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = QuestionSet.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        response = client.get(url)
+        assert response.status_code == status_map['nested'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = QuestionSet.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['list'])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'uri_path': '%s_new_%s' % (instance.uri_path, username),
+            'comment': instance.comment,
+            'attribute': instance.attribute.pk if instance.attribute else '',
+            'is_collection': instance.is_collection,
+            'title_en': instance.title_lang1,
+            'title_de': instance.title_lang2,
+            'help_en': instance.help_lang1,
+            'help_de': instance.help_lang2,
+            'verbose_name_en': instance.verbose_name_lang1,
+            'verbose_name_de': instance.verbose_name_lang2,
+            'verbose_name_plural_en': instance.verbose_name_plural_lang1,
+            'verbose_name_plural_de': instance.verbose_name_plural_lang2
+        }
+        response = client.post(url, data, content_type='application/json')
+        assert response.status_code == status_map['create'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_page(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = QuestionSet.objects.all()
+
+    for instance in instances:
+        page = instance.pages.first()
+        if page is not None:
+            page_questionsets = list(page.page_questionsets.values_list('questionset', 'order'))
+            order = page.page_questionsets.aggregate(order=Max('order')).get('order') + 1
+
+            url = reverse(urlnames['list'])
+            data = {
+                'uri_prefix': instance.uri_prefix,
+                'uri_path': '%s_new_%s' % (instance.uri_path, username),
+                'comment': instance.comment,
+                'attribute': instance.attribute.pk if instance.attribute else '',
+                'is_collection': instance.is_collection,
+                'title_en': instance.title_lang1,
+                'title_de': instance.title_lang2,
+                'help_en': instance.help_lang1,
+                'help_de': instance.help_lang2,
+                'verbose_name_en': instance.verbose_name_lang1,
+                'verbose_name_de': instance.verbose_name_lang2,
+                'verbose_name_plural_en': instance.verbose_name_plural_lang1,
+                'verbose_name_plural_de': instance.verbose_name_plural_lang2,
+                'pages': [page.id]
+            }
+            response = client.post(url, data, content_type='application/json')
+            assert response.status_code == status_map['create'][username], response.json()
+
+            if response.status_code == 201:
+                new_instance = QuestionSet.objects.get(id=response.json().get('id'))
+                page.refresh_from_db()
+                assert page_questionsets + [(new_instance.id, order)] == \
+                    list(page.page_questionsets.values_list('questionset', 'order'))
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_parent(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = QuestionSet.objects.all()
+
+    for instance in instances:
+        parent = instance.parents.first()
+        if parent is not None:
+            parent_questionsets = list(parent.questionset_questionsets.values_list('questionset', 'order'))
+            order = parent.questionset_questionsets.aggregate(order=Max('order')).get('order') + 1
+
+            url = reverse(urlnames['list'])
+            data = {
+                'uri_prefix': instance.uri_prefix,
+                'uri_path': '%s_new_%s' % (instance.uri_path, username),
+                'comment': instance.comment,
+                'attribute': instance.attribute.pk if instance.attribute else '',
+                'is_collection': instance.is_collection,
+                'title_en': instance.title_lang1,
+                'title_de': instance.title_lang2,
+                'help_en': instance.help_lang1,
+                'help_de': instance.help_lang2,
+                'verbose_name_en': instance.verbose_name_lang1,
+                'verbose_name_de': instance.verbose_name_lang2,
+                'verbose_name_plural_en': instance.verbose_name_plural_lang1,
+                'verbose_name_plural_de': instance.verbose_name_plural_lang2,
+                'parents': [parent.id]
+            }
+            response = client.post(url, data, content_type='application/json')
+            assert response.status_code == status_map['create'][username], response.json()
+
+            if response.status_code == 201:
+                new_instance = QuestionSet.objects.get(id=response.json().get('id'))
+                parent.refresh_from_db()
+                assert parent_questionsets + [(new_instance.id, order)] == \
+                    list(parent.questionset_questionsets.values_list('questionset', 'order'))
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_m2m(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = QuestionSet.objects.all()
+
+    for instance in instances:
+        questionsets = [{
+            'questionset': questionset_questionset.questionset.id,
+            'order': questionset_questionset.order
+        } for questionset_questionset in instance.questionset_questionsets.all()[:1]]
+        questions = [{
+            'question': questionset_question.question.id,
+            'order': questionset_question.order
+        } for questionset_question in instance.questionset_questions.all()[:1]]
+        conditions = [condition.pk for condition in instance.conditions.all()[:1]]
+
+        url = reverse(urlnames['list'])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'uri_path': '%s_new_%s' % (instance.uri_path, username),
+            'comment': instance.comment,
+            'attribute': instance.attribute.pk if instance.attribute else '',
+            'is_collection': instance.is_collection,
+            'title_en': instance.title_lang1,
+            'title_de': instance.title_lang2,
+            'help_en': instance.help_lang1,
+            'help_de': instance.help_lang2,
+            'verbose_name_en': instance.verbose_name_lang1,
+            'verbose_name_de': instance.verbose_name_lang2,
+            'verbose_name_plural_en': instance.verbose_name_plural_lang1,
+            'verbose_name_plural_de': instance.verbose_name_plural_lang2,
+            'questionsets': questionsets,
+            'questions': questions,
+            'conditions': conditions
+        }
+        response = client.post(url, data, content_type='application/json')
+        assert response.status_code == status_map['create'][username], response.json()
+
+        if response.status_code == 201:
+            new_instance = QuestionSet.objects.get(id=response.json().get('id'))
+            assert questionsets == [{
+                'questionset': questionset_questionset.questionset.id,
+                'order': questionset_questionset.order
+            } for questionset_questionset in new_instance.questionset_questionsets.all()]
+            assert questions == [{
+                'question': questionset_question.question.id,
+                'order': questionset_question.order
+            } for questionset_question in new_instance.questionset_questions.all()]
+            assert conditions == [condition.pk for condition in new_instance.conditions.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_update(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = QuestionSet.objects.all()
+
+    for instance in instances:
+        pages = [page.id for page in instance.pages.all()]
+        parents = [parent.id for parent in instance.parents.all()]
+        questionsets = [questionset.id for questionset in instance.questionsets.all()]
+        questions = [question.id for question in instance.questions.all()]
+        conditions = [condition.pk for condition in instance.conditions.all()]
+
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'uri_path': instance.uri_path,
+            'comment': instance.comment,
+            'attribute': instance.attribute.pk if instance.attribute else None,
+            'is_collection': instance.is_collection,
+            'title_en': instance.title_lang1,
+            'title_de': instance.title_lang2,
+            'help_en': instance.help_lang1,
+            'help_de': instance.help_lang2,
+            'verbose_name_en': instance.verbose_name_lang1,
+            'verbose_name_de': instance.verbose_name_lang2,
+            'verbose_name_plural_en': instance.verbose_name_plural_lang1,
+            'verbose_name_plural_de': instance.verbose_name_plural_lang2
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'update'), response.json()
+
+        instance.refresh_from_db()
+        assert pages == [page.id for page in instance.pages.all()]
+        assert parents == [parent.id for parent in instance.parents.all()]
+        assert questionsets == [questionset.id for questionset in instance.questionsets.all()]
+        assert questions == [question.id for question in instance.questions.all()]
+        assert conditions == [condition.pk for condition in instance.conditions.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_update_m2m(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = QuestionSet.objects.all()
+
+    for instance in instances:
+        questionsets = [{
+            'questionset': questionset_questionset.questionset.id,
+            'order': questionset_questionset.order
+        } for questionset_questionset in instance.questionset_questionsets.all()[:1]]
+        questions = [{
+            'question': questionset_question.question.id,
+            'order': questionset_question.order
+        } for questionset_question in instance.questionset_questions.all()[:1]]
+        conditions = [condition.pk for condition in instance.conditions.all()[:1]]
+
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'uri_path': instance.uri_path,
+            'comment': instance.comment,
+            'attribute': instance.attribute.pk if instance.attribute else None,
+            'is_collection': instance.is_collection,
+            'title_en': instance.title_lang1,
+            'title_de': instance.title_lang2,
+            'help_en': instance.help_lang1,
+            'help_de': instance.help_lang2,
+            'verbose_name_en': instance.verbose_name_lang1,
+            'verbose_name_de': instance.verbose_name_lang2,
+            'verbose_name_plural_en': instance.verbose_name_plural_lang1,
+            'verbose_name_plural_de': instance.verbose_name_plural_lang2,
+            'questionsets': questionsets,
+            'questions': questions,
+            'conditions': conditions
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'update'), response.json()
+
+        if response.status_code == 200:
+            instance.refresh_from_db()
+            assert questionsets == [{
+                'questionset': questionset_questionset.questionset.id,
+                'order': questionset_questionset.order
+            } for questionset_questionset in instance.questionset_questionsets.all()]
+            assert questions == [{
+                'question': questionset_question.question.id,
+                'order': questionset_question.order
+            } for questionset_question in instance.questionset_questions.all()]
+            assert conditions == [condition.pk for condition in instance.conditions.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_delete(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = QuestionSet.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        response = client.delete(url)
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'delete'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+@pytest.mark.parametrize('export_format', export_formats)
+def test_detail_export(db, client, username, password, export_format):
+    client.login(username=username, password=password)
+    instance = QuestionSet.objects.first()
+
+    url = reverse(urlnames['detail_export'], args=[instance.pk]) + export_format + '/'
+    response = client.get(url)
+    assert response.status_code == status_map['detail'][username], response.content
+
+    if response.status_code == 200 and export_format == 'xml':
+        root = et.fromstring(response.content)
+        assert root.tag == 'rdmo'
+        for child in root:
+            assert child.tag in ['questionset', 'question']
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_copy(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = QuestionSet.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['copy'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix + '-',
+            'uri_path': instance.uri_path + '-'
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'copy'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_copy_wrong(db, client, username, password):
+    client.login(username=username, password=password)
+    instance = QuestionSet.objects.first()
+
+    url = reverse(urlnames['copy'], args=[instance.pk])
+    data = {
+        'uri_prefix': instance.uri_prefix,
+        'uri_path': instance.uri_path
+    }
+    response = client.put(url, data, content_type='application/json')
+
+    if get_obj_perms_status_code(instance, username, 'copy') == 201:
+        assert response.status_code == 400, response.json()
+    else:
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'copy'), response.json()
diff --git a/rdmo/questions/tests/test_viewset_section.py b/rdmo/questions/tests/test_viewset_section.py
index 3a4e4e71dc..0e56aa9eee 100644
--- a/rdmo/questions/tests/test_viewset_section.py
+++ b/rdmo/questions/tests/test_viewset_section.py
@@ -19,16 +19,22 @@
         'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
     },
     'detail': {
-        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
+        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 404, 'anonymous': 401
+    },
+    'nested': {
+        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 404, 'anonymous': 401
     },
     'create': {
         'editor': 201, 'reviewer': 403, 'api': 201, 'user': 403, 'anonymous': 401
     },
+    'copy': {
+        'editor': 201, 'reviewer': 403, 'api': 201, 'user': 404, 'anonymous': 401
+    },
     'update': {
-        'editor': 200, 'reviewer': 403, 'api': 200, 'user': 403, 'anonymous': 401
+        'editor': 200, 'reviewer': 403, 'api': 200, 'user': 404, 'anonymous': 401
     },
     'delete': {
-        'editor': 204, 'reviewer': 403, 'api': 204, 'user': 403, 'anonymous': 401
+        'editor': 204, 'reviewer': 403, 'api': 204, 'user': 404, 'anonymous': 401
     }
 }
 
@@ -98,7 +104,7 @@ def test_nested(db, client, username, password):
     for instance in instances:
         url = reverse(urlnames['nested'], args=[instance.pk])
         response = client.get(url)
-        assert response.status_code == status_map['detail'][username], response.json()
+        assert response.status_code == status_map['nested'][username], response.json()
 
 
 @pytest.mark.parametrize('username,password', users)
@@ -256,7 +262,7 @@ def test_detail_export(db, client, username, password, export_format):
 
     url = reverse(urlnames['detail_export'], args=[instance.pk]) + export_format + '/'
     response = client.get(url)
-    assert response.status_code == status_map['list'][username], response.content
+    assert response.status_code == status_map['detail'][username], response.content
 
     if response.status_code == 200 and export_format == 'xml':
         root = et.fromstring(response.content)
@@ -277,7 +283,7 @@ def test_copy(db, client, username, password):
             'uri_path': instance.uri_path + '-'
         }
         response = client.put(url, data, content_type='application/json')
-        assert response.status_code == status_map['create'][username], response.json()
+        assert response.status_code == status_map['copy'][username], response.json()
 
 
 @pytest.mark.parametrize('username,password', users)
@@ -292,7 +298,7 @@ def test_copy_wrong(db, client, username, password):
     }
     response = client.put(url, data, content_type='application/json')
 
-    if status_map['create'][username] == 201:
+    if status_map['copy'][username] == 201:
         assert response.status_code == 400, response.json()
     else:
-        assert response.status_code == status_map['create'][username], response.json()
+        assert response.status_code == status_map['copy'][username], response.json()
diff --git a/rdmo/questions/tests/test_viewset_section_multisite.py b/rdmo/questions/tests/test_viewset_section_multisite.py
new file mode 100644
index 0000000000..4abe25517a
--- /dev/null
+++ b/rdmo/questions/tests/test_viewset_section_multisite.py
@@ -0,0 +1,266 @@
+import xml.etree.ElementTree as et
+
+import pytest
+from django.db.models import Max
+from django.urls import reverse
+
+from ...core.tests import multisite_status_map as status_map
+from ...core.tests import multisite_users as users
+from ...core.tests import get_obj_perms_status_code
+
+from ..models import Section
+
+from .test_viewset_section import urlnames, export_formats
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_list(db, client, username, password):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['list'])
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_index(db, client, username, password):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['index'])
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+@pytest.mark.parametrize('export_format', export_formats)
+def test_export(db, client, username, password, export_format):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['export']) + export_format + '/'
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.content
+
+    if response.status_code == 200 and export_format == 'xml':
+        root = et.fromstring(response.content)
+        assert root.tag == 'rdmo'
+        for child in root:
+            assert child.tag in ['section', 'page', 'questionset', 'question']
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_detail(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Section.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        response = client.get(url)
+        assert response.status_code == status_map['detail'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_nested(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Section.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['nested'], args=[instance.pk])
+        response = client.get(url)
+        assert response.status_code == status_map['nested'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Section.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['list'])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'uri_path': '%s_new_%s' % (instance.uri_path, username),
+            'comment': instance.comment,
+            'title_en': instance.title_lang1,
+            'title_de': instance.title_lang2
+        }
+        response = client.post(url, data, content_type='application/json')
+        assert response.status_code == status_map['create'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_catalog(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Section.objects.all()
+
+    for instance in instances:
+        catalog = instance.catalogs.first()
+        if catalog is not None:
+            catalog_sections = list(catalog.catalog_sections.values_list('section', 'order'))
+            order = catalog.catalog_sections.aggregate(order=Max('order')).get('order') + 1
+
+            url = reverse(urlnames['list'])
+            data = {
+                'uri_prefix': instance.uri_prefix,
+                'uri_path': '%s_new_%s' % (instance.uri_path, username),
+                'comment': instance.comment,
+                'title_en': instance.title_lang1,
+                'title_de': instance.title_lang2,
+                'catalogs': [catalog.id]
+            }
+            response = client.post(url, data, content_type='application/json')
+            assert response.status_code == status_map['create'][username], response.json()
+
+            if response.status_code == 201:
+                new_instance = Section.objects.get(id=response.json().get('id'))
+                catalog.refresh_from_db()
+                assert catalog_sections + [(new_instance.id, order)] == \
+                    list(catalog.catalog_sections.values_list('section', 'order'))
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create_m2m(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Section.objects.all()
+
+    for instance in instances:
+        section_pages = [{
+            'page': section_page.page_id,
+            'order': section_page.order
+        } for section_page in instance.section_pages.all()[:1]]
+
+        url = reverse(urlnames['list'])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'uri_path': '%s_new_%s' % (instance.uri_path, username),
+            'comment': instance.comment,
+            'pages': section_pages,
+            'title_en': instance.title_lang1,
+            'title_de': instance.title_lang2
+        }
+
+        response = client.post(url, data, content_type='application/json')
+        assert response.status_code == status_map['create'][username], response.json()
+
+        if response.status_code == 201:
+            new_instance = Section.objects.get(id=response.json().get('id'))
+            assert section_pages == [{
+                'page': section_page.page_id,
+                'order': section_page.order
+            } for section_page in new_instance.section_pages.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_update(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Section.objects.all()
+
+    for instance in instances:
+        catalogs = [catalog.id for catalog in instance.catalogs.all()]
+        pages = [page.id for page in instance.pages.all()]
+
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'uri_path': instance.uri_path,
+            'comment': instance.comment,
+            'title_en': instance.title_lang1,
+            'title_de': instance.title_lang2
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'update'), response.json()
+
+        instance.refresh_from_db()
+        assert catalogs == [catalog.id for catalog in instance.catalogs.all()]
+        assert pages == [page.id for page in instance.pages.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_update_m2m(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Section.objects.all()
+
+    for instance in instances:
+        pages = [{
+            'page': section_page.page_id,
+            'order': section_page.order
+        } for section_page in instance.section_pages.all()[:1]]
+
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'uri_path': instance.uri_path,
+            'comment': instance.comment,
+            'pages': pages,
+            'title_en': instance.title_lang1,
+            'title_de': instance.title_lang2
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'update'), response.json()
+
+        if response.status_code == 200:
+            instance.refresh_from_db()
+            assert pages == [{
+                'page': section_page.page_id,
+                'order': section_page.order
+            } for section_page in instance.section_pages.all()]
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_delete(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Section.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        response = client.delete(url)
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'delete'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+@pytest.mark.parametrize('export_format', export_formats)
+def test_detail_export(db, client, username, password, export_format):
+    client.login(username=username, password=password)
+    instance = Section.objects.first()
+
+    url = reverse(urlnames['detail_export'], args=[instance.pk]) + export_format + '/'
+    response = client.get(url)
+    assert response.status_code == status_map['detail'][username], response.content
+
+    if response.status_code == 200 and export_format == 'xml':
+        root = et.fromstring(response.content)
+        assert root.tag == 'rdmo'
+        for child in root:
+            assert child.tag in ['section', 'page', 'questionset', 'question']
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_copy(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Section.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['copy'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix + '-',
+            'uri_path': instance.uri_path + '-'
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'copy'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_copy_wrong(db, client, username, password):
+    client.login(username=username, password=password)
+    instance = Section.objects.first()
+
+    url = reverse(urlnames['copy'], args=[instance.pk])
+    data = {
+        'uri_prefix': instance.uri_prefix,
+        'uri_path': instance.uri_path
+    }
+    response = client.put(url, data, content_type='application/json')
+
+    if get_obj_perms_status_code(instance, username, 'copy') == 201:
+        assert response.status_code == 400, response.json()
+    else:
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'copy'), response.json()
diff --git a/rdmo/tasks/tests/test_viewset_task.py b/rdmo/tasks/tests/test_viewset_task.py
index ff3ebc8676..8163ab0fe1 100644
--- a/rdmo/tasks/tests/test_viewset_task.py
+++ b/rdmo/tasks/tests/test_viewset_task.py
@@ -18,16 +18,19 @@
         'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
     },
     'detail': {
-        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
+        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 404, 'anonymous': 401
     },
     'create': {
         'editor': 201, 'reviewer': 403, 'api': 201, 'user': 403, 'anonymous': 401
     },
+    'copy': {
+        'editor': 201, 'reviewer': 403, 'api': 201, 'user': 404, 'anonymous': 401
+    },
     'update': {
-        'editor': 200, 'reviewer': 403, 'api': 200, 'user': 403, 'anonymous': 401
+        'editor': 200, 'reviewer': 403, 'api': 200, 'user': 404, 'anonymous': 401
     },
     'delete': {
-        'editor': 204, 'reviewer': 403, 'api': 204, 'user': 403, 'anonymous': 401
+        'editor': 204, 'reviewer': 403, 'api': 204, 'user': 404, 'anonymous': 401
     }
 }
 
@@ -105,8 +108,8 @@ def test_create(db, client, username, password):
             'text_de': instance.text_lang2,
             'start_attribute': instance.start_attribute.pk if instance.start_attribute else '',
             'end_attribute': instance.end_attribute.pk if instance.end_attribute else '',
-            'days_before': instance.days_before,
-            'days_after': instance.days_after,
+            'days_before': instance.days_before or 0,
+            'days_after': instance.days_before or 0,
             'conditions': [condition.pk for condition in instance.conditions.all()]
         }
         response = client.post(url, data)
@@ -157,7 +160,7 @@ def test_detail_export(db, client, username, password, export_format):
 
     url = reverse(urlnames['detail_export'], args=[instance.pk]) + export_format + '/'
     response = client.get(url)
-    assert response.status_code == status_map['list'][username], response.content
+    assert response.status_code == status_map['detail'][username], response.content
 
     if response.status_code == 200 and export_format == 'xml':
         root = et.fromstring(response.content)
@@ -178,7 +181,7 @@ def test_copy(db, client, username, password):
             'key': instance.key + '-'
         }
         response = client.put(url, data, content_type='application/json')
-        assert response.status_code == status_map['create'][username], response.json()
+        assert response.status_code == status_map['copy'][username], response.json()
 
 
 @pytest.mark.parametrize('username,password', users)
@@ -193,7 +196,7 @@ def test_copy_wrong(db, client, username, password):
     }
     response = client.put(url, data, content_type='application/json')
 
-    if status_map['create'][username] == 201:
+    if status_map['copy'][username] == 201:
         assert response.status_code == 400, response.json()
     else:
-        assert response.status_code == status_map['create'][username], response.json()
+        assert response.status_code == status_map['copy'][username], response.json()
diff --git a/rdmo/tasks/tests/test_viewset_task_multisite.py b/rdmo/tasks/tests/test_viewset_task_multisite.py
new file mode 100644
index 0000000000..80fe89256b
--- /dev/null
+++ b/rdmo/tasks/tests/test_viewset_task_multisite.py
@@ -0,0 +1,169 @@
+import xml.etree.ElementTree as et
+
+import pytest
+from django.urls import reverse
+
+from ...core.tests import multisite_status_map as status_map
+from ...core.tests import multisite_users as users
+from ...core.tests import get_obj_perms_status_code
+
+from ..models import Task
+
+from .test_viewset_task import export_formats
+from .test_viewset_task import urlnames
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_list(db, client, username, password):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['list'])
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_index(db, client, username, password):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['index'])
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+@pytest.mark.parametrize('export_format', export_formats)
+def test_export(db, client, username, password, export_format):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['export']) + export_format + '/'
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.content
+
+    if response.status_code == 200 and export_format == 'xml':
+        root = et.fromstring(response.content)
+        assert root.tag == 'rdmo'
+        for child in root:
+            assert child.tag in ['task']
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_detail(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Task.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        response = client.get(url)
+        assert response.status_code == status_map['detail'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Task.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['list'])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'key': '%s_new_%s' % (instance.key, username),
+            'comment': instance.comment,
+            'title_en': instance.title_lang1,
+            'title_de': instance.title_lang2,
+            'text_en': instance.text_lang1,
+            'text_de': instance.text_lang2,
+            'start_attribute': instance.start_attribute.pk if instance.start_attribute else '',
+            'end_attribute': instance.end_attribute.pk if instance.end_attribute else '',
+            'days_before': instance.days_before or 0,
+            'days_after': instance.days_after or 0,
+            'conditions': [condition.pk for condition in instance.conditions.all()]
+        }
+        response = client.post(url, data)
+        assert response.status_code == status_map['create'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_update(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Task.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'key': instance.key,
+            'comment': instance.comment,
+            'title_en': instance.title_lang1,
+            'title_de': instance.title_lang2,
+            'text_en': instance.text_lang1,
+            'text_de': instance.text_lang2,
+            'start_attribute': instance.start_attribute.pk if instance.start_attribute else '',
+            'end_attribute': instance.end_attribute.pk if instance.end_attribute else '',
+            'days_before': instance.days_before,
+            'days_after': instance.days_after,
+            'conditions': [condition.pk for condition in instance.conditions.all()]
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'update'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_delete(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Task.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        response = client.delete(url)
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'delete'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+@pytest.mark.parametrize('export_format', export_formats)
+def test_detail_export(db, client, username, password, export_format):
+    client.login(username=username, password=password)
+    instance = Task.objects.first()
+
+    url = reverse(urlnames['detail_export'], args=[instance.pk]) + export_format + '/'
+    response = client.get(url)
+    assert response.status_code == status_map['detail'][username], response.content
+
+    if response.status_code == 200 and export_format == 'xml':
+        root = et.fromstring(response.content)
+        assert root.tag == 'rdmo'
+        for child in root:
+            assert child.tag in ['task']
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_copy(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = Task.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['copy'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix + '-',
+            'key': instance.key + '-'
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'copy'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_copy_wrong(db, client, username, password):
+    client.login(username=username, password=password)
+    instance = Task.objects.first()
+
+    url = reverse(urlnames['copy'], args=[instance.pk])
+    data = {
+        'uri_prefix': instance.uri_prefix,
+        'key': instance.key
+    }
+    response = client.put(url, data, content_type='application/json')
+
+    if status_map['copy'][username] == 201 and response.status_code != 404:
+        assert response.status_code == 400, response.json()
+    else:
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'copy'), response.json()
diff --git a/rdmo/views/tests/test_viewset_view.py b/rdmo/views/tests/test_viewset_view.py
index 4040d3943e..45cd11d60e 100644
--- a/rdmo/views/tests/test_viewset_view.py
+++ b/rdmo/views/tests/test_viewset_view.py
@@ -18,16 +18,19 @@
         'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
     },
     'detail': {
-        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 403, 'anonymous': 401
+        'editor': 200, 'reviewer': 200, 'api': 200, 'user': 404, 'anonymous': 401
     },
     'create': {
         'editor': 201, 'reviewer': 403, 'api': 201, 'user': 403, 'anonymous': 401
     },
+    'copy': {
+        'editor': 201, 'reviewer': 403, 'api': 201, 'user': 404, 'anonymous': 401
+    },
     'update': {
-        'editor': 200, 'reviewer': 403, 'api': 200, 'user': 403, 'anonymous': 401
+        'editor': 200, 'reviewer': 403, 'api': 200, 'user': 404, 'anonymous': 401
     },
     'delete': {
-        'editor': 204, 'reviewer': 403, 'api': 204, 'user': 403, 'anonymous': 401
+        'editor': 204, 'reviewer': 403, 'api': 204, 'user': 404, 'anonymous': 401
     }
 }
 
@@ -149,7 +152,7 @@ def test_detail_export(db, client, username, password, export_format):
 
     url = reverse(urlnames['detail_export'], args=[instance.pk]) + export_format + '/'
     response = client.get(url)
-    assert response.status_code == status_map['list'][username], response.content
+    assert response.status_code == status_map['detail'][username], response.content
 
     if response.status_code == 200 and export_format == 'xml':
         root = et.fromstring(response.content)
@@ -170,7 +173,7 @@ def test_copy(db, client, username, password):
             'key': instance.key + '-'
         }
         response = client.put(url, data, content_type='application/json')
-        assert response.status_code == status_map['create'][username], response.json()
+        assert response.status_code == status_map['copy'][username], response.json()
 
 
 @pytest.mark.parametrize('username,password', users)
@@ -185,7 +188,7 @@ def test_copy_wrong(db, client, username, password):
     }
     response = client.put(url, data, content_type='application/json')
 
-    if status_map['create'][username] == 201:
+    if status_map['copy'][username] == 201:
         assert response.status_code == 400, response.json()
     else:
-        assert response.status_code == status_map['create'][username], response.json()
+        assert response.status_code == status_map['copy'][username], response.json()
diff --git a/rdmo/views/tests/test_viewset_view_multisite.py b/rdmo/views/tests/test_viewset_view_multisite.py
new file mode 100644
index 0000000000..00553b91c9
--- /dev/null
+++ b/rdmo/views/tests/test_viewset_view_multisite.py
@@ -0,0 +1,161 @@
+import xml.etree.ElementTree as et
+
+import pytest
+from django.urls import reverse
+
+from ...core.tests import multisite_status_map as status_map
+from ...core.tests import multisite_users as users
+from ...core.tests import get_obj_perms_status_code
+
+from ..models import View
+
+from .test_viewset_view import export_formats
+from .test_viewset_view import urlnames
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_list(db, client, username, password):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['list'])
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_index(db, client, username, password):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['index'])
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+@pytest.mark.parametrize('export_format', export_formats)
+def test_export(db, client, username, password, export_format):
+    client.login(username=username, password=password)
+
+    url = reverse(urlnames['export']) + export_format + '/'
+    response = client.get(url)
+    assert response.status_code == status_map['list'][username], response.content
+
+    if response.status_code == 200 and export_format == 'xml':
+        root = et.fromstring(response.content)
+        assert root.tag == 'rdmo'
+        for child in root:
+            assert child.tag in ['view']
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_detail(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = View.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        response = client.get(url)
+        assert response.status_code == status_map['detail'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_create(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = View.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['list'])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'key': '%s_new_%s' % (instance.key, username),
+            'comment': instance.comment,
+            'template': instance.template,
+            'title_en': instance.title_lang1,
+            'title_de': instance.title_lang2,
+            'help_en': instance.help_lang1,
+            'help_de': instance.help_lang2
+        }
+        response = client.post(url, data)
+        assert response.status_code == status_map['create'][username], response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_update(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = View.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix,
+            'key': instance.key,
+            'comment': instance.comment,
+            'template': instance.template,
+            'title_en': instance.title_lang1,
+            'title_de': instance.title_lang2,
+            'help_en': instance.help_lang1,
+            'help_de': instance.help_lang2
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'update'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_delete(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = View.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['detail'], args=[instance.pk])
+        response = client.delete(url)
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'delete'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+@pytest.mark.parametrize('export_format', export_formats)
+def test_detail_export(db, client, username, password, export_format):
+    client.login(username=username, password=password)
+    instance = View.objects.first()
+
+    url = reverse(urlnames['detail_export'], args=[instance.pk]) + export_format + '/'
+    response = client.get(url)
+    assert response.status_code == status_map['detail'][username], response.content
+
+    if response.status_code == 200 and export_format == 'xml':
+        root = et.fromstring(response.content)
+        assert root.tag == 'rdmo'
+        for child in root:
+            assert child.tag in ['view']
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_copy(db, client, username, password):
+    client.login(username=username, password=password)
+    instances = View.objects.all()
+
+    for instance in instances:
+        url = reverse(urlnames['copy'], args=[instance.pk])
+        data = {
+            'uri_prefix': instance.uri_prefix + '-',
+            'key': instance.key + '-'
+        }
+        response = client.put(url, data, content_type='application/json')
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'copy'), response.json()
+
+
+@pytest.mark.parametrize('username,password', users)
+def test_copy_wrong(db, client, username, password):
+    client.login(username=username, password=password)
+    instance = View.objects.first()
+
+    url = reverse(urlnames['copy'], args=[instance.pk])
+    data = {
+        'uri_prefix': instance.uri_prefix,
+        'key': instance.key
+    }
+    response = client.put(url, data, content_type='application/json')
+
+    if status_map['copy'][username] == 201 and response.status_code != 404:
+        assert response.status_code == 400, response.json()
+    else:
+        assert response.status_code == get_obj_perms_status_code(instance, username, 'copy'), response.json()
diff --git a/testing/fixtures/accounts.json b/testing/fixtures/accounts.json
index 7c3c3ff71e..53c100b43c 100644
--- a/testing/fixtures/accounts.json
+++ b/testing/fixtures/accounts.json
@@ -87,7 +87,9 @@
       "member": [
         1
       ],
-      "manager": []
+      "manager": [],
+      "editor": [],
+      "reviewer": []
     }
   },
   {
@@ -98,7 +100,13 @@
       "member": [
         1
       ],
-      "manager": []
+      "manager": [],
+      "editor": [
+        3,
+        1,
+        2
+      ],
+      "reviewer": []
     }
   },
   {
@@ -109,7 +117,13 @@
       "member": [
         1
       ],
-      "manager": []
+      "manager": [],
+      "editor": [],
+      "reviewer": [
+        3,
+        1,
+        2
+      ]
     }
   },
   {
@@ -120,7 +134,9 @@
       "member": [
         1
       ],
-      "manager": []
+      "manager": [],
+      "editor": [],
+      "reviewer": []
     }
   },
   {
@@ -131,7 +147,9 @@
       "member": [
         1
       ],
-      "manager": []
+      "manager": [],
+      "editor": [],
+      "reviewer": []
     }
   },
   {
@@ -142,7 +160,9 @@
       "member": [
         1
       ],
-      "manager": []
+      "manager": [],
+      "editor": [],
+      "reviewer": []
     }
   },
   {
@@ -153,7 +173,9 @@
       "member": [
         1
       ],
-      "manager": []
+      "manager": [],
+      "editor": [],
+      "reviewer": []
     }
   },
   {
@@ -164,7 +186,9 @@
       "member": [
         1
       ],
-      "manager": []
+      "manager": [],
+      "editor": [],
+      "reviewer": []
     }
   },
   {
@@ -173,9 +197,21 @@
     "fields": {
       "user": 9,
       "member": [
-        1
+        1,
+        2,
+        3
+      ],
+      "manager": [
+        3,
+        1,
+        2
+      ],
+      "editor": [
+        3,
+        1,
+        2
       ],
-      "manager": []
+      "reviewer": []
     }
   },
   {
@@ -188,7 +224,9 @@
       ],
       "manager": [
         1
-      ]
+      ],
+      "editor": [],
+      "reviewer": []
     }
   },
   {
@@ -197,10 +235,186 @@
     "fields": {
       "user": 11,
       "member": [
-        2,
+        3,
+        2
+      ],
+      "manager": [],
+      "editor": [],
+      "reviewer": []
+    }
+  },
+  {
+    "model": "accounts.role",
+    "pk": 12,
+    "fields": {
+      "user": 12,
+      "member": [
+        1
+      ],
+      "manager": [],
+      "editor": [],
+      "reviewer": []
+    }
+  },
+  {
+    "model": "accounts.role",
+    "pk": 13,
+    "fields": {
+      "user": 13,
+      "member": [
+        2
+      ],
+      "manager": [],
+      "editor": [],
+      "reviewer": []
+    }
+  },
+  {
+    "model": "accounts.role",
+    "pk": 14,
+    "fields": {
+      "user": 14,
+      "member": [
+        3
+      ],
+      "manager": [],
+      "editor": [],
+      "reviewer": []
+    }
+  },
+  {
+    "model": "accounts.role",
+    "pk": 15,
+    "fields": {
+      "user": 15,
+      "member": [
+        1
+      ],
+      "manager": [
+        1
+      ],
+      "editor": [],
+      "reviewer": []
+    }
+  },
+  {
+    "model": "accounts.role",
+    "pk": 16,
+    "fields": {
+      "user": 16,
+      "member": [
+        2
+      ],
+      "manager": [
+        2
+      ],
+      "editor": [],
+      "reviewer": []
+    }
+  },
+  {
+    "model": "accounts.role",
+    "pk": 17,
+    "fields": {
+      "user": 17,
+      "member": [
+        3
+      ],
+      "manager": [
+        3
+      ],
+      "editor": [],
+      "reviewer": []
+    }
+  },
+  {
+    "model": "accounts.role",
+    "pk": 18,
+    "fields": {
+      "user": 18,
+      "member": [
+        1
+      ],
+      "manager": [],
+      "editor": [
+        1
+      ],
+      "reviewer": []
+    }
+  },
+  {
+    "model": "accounts.role",
+    "pk": 19,
+    "fields": {
+      "user": 19,
+      "member": [
+        2
+      ],
+      "manager": [],
+      "editor": [
+        2
+      ],
+      "reviewer": []
+    }
+  },
+  {
+    "model": "accounts.role",
+    "pk": 20,
+    "fields": {
+      "user": 20,
+      "member": [
         3
       ],
-      "manager": []
+      "manager": [],
+      "editor": [
+        3
+      ],
+      "reviewer": []
+    }
+  },
+  {
+    "model": "accounts.role",
+    "pk": 21,
+    "fields": {
+      "user": 21,
+      "member": [
+        1
+      ],
+      "manager": [],
+      "editor": [],
+      "reviewer": [
+        1
+      ]
+    }
+  },
+  {
+    "model": "accounts.role",
+    "pk": 22,
+    "fields": {
+      "user": 22,
+      "member": [
+        2
+      ],
+      "manager": [],
+      "editor": [],
+      "reviewer": [
+        2
+      ]
+    }
+  },
+  {
+    "model": "accounts.role",
+    "pk": 23,
+    "fields": {
+      "user": 23,
+      "member": [
+        3
+      ],
+      "manager": [],
+      "editor": [],
+      "reviewer": [
+        3
+      ]
     }
   }
-]
\ No newline at end of file
+]
diff --git a/testing/fixtures/conditions.json b/testing/fixtures/conditions.json
index 490d228d55..1124c6931b 100644
--- a/testing/fixtures/conditions.json
+++ b/testing/fixtures/conditions.json
@@ -11,7 +11,8 @@
       "source": 81,
       "relation": "eq",
       "target_text": "test",
-      "target_option": null
+      "target_option": null,
+      "editors": []
     }
   },
   {
@@ -26,7 +27,8 @@
       "source": 81,
       "relation": "neq",
       "target_text": "test",
-      "target_option": null
+      "target_option": null,
+      "editors": []
     }
   },
   {
@@ -41,7 +43,8 @@
       "source": 81,
       "relation": "contains",
       "target_text": "test",
-      "target_option": null
+      "target_option": null,
+      "editors": []
     }
   },
   {
@@ -56,7 +59,8 @@
       "source": 81,
       "relation": "gt",
       "target_text": "0",
-      "target_option": null
+      "target_option": null,
+      "editors": []
     }
   },
   {
@@ -71,7 +75,8 @@
       "source": 81,
       "relation": "gte",
       "target_text": "0",
-      "target_option": null
+      "target_option": null,
+      "editors": []
     }
   },
   {
@@ -86,7 +91,8 @@
       "source": 81,
       "relation": "lt",
       "target_text": "0",
-      "target_option": null
+      "target_option": null,
+      "editors": []
     }
   },
   {
@@ -101,7 +107,8 @@
       "source": 81,
       "relation": "lte",
       "target_text": "0",
-      "target_option": null
+      "target_option": null,
+      "editors": []
     }
   },
   {
@@ -116,7 +123,8 @@
       "source": 81,
       "relation": "empty",
       "target_text": "",
-      "target_option": null
+      "target_option": null,
+      "editors": []
     }
   },
   {
@@ -131,7 +139,8 @@
       "source": 81,
       "relation": "notempty",
       "target_text": "",
-      "target_option": null
+      "target_option": null,
+      "editors": []
     }
   },
   {
@@ -146,7 +155,8 @@
       "source": 82,
       "relation": "eq",
       "target_text": "",
-      "target_option": 1
+      "target_option": 1,
+      "editors": []
     }
   },
   {
@@ -161,7 +171,8 @@
       "source": 82,
       "relation": "neq",
       "target_text": "",
-      "target_option": 1
+      "target_option": 1,
+      "editors": []
     }
   },
   {
@@ -176,7 +187,8 @@
       "source": 82,
       "relation": "empty",
       "target_text": "",
-      "target_option": null
+      "target_option": null,
+      "editors": []
     }
   },
   {
@@ -191,7 +203,8 @@
       "source": 82,
       "relation": "notempty",
       "target_text": "",
-      "target_option": null
+      "target_option": null,
+      "editors": []
     }
   },
   {
@@ -206,7 +219,8 @@
       "source": 114,
       "relation": "eq",
       "target_text": "1",
-      "target_option": null
+      "target_option": null,
+      "editors": []
     }
   },
   {
@@ -221,7 +235,44 @@
       "source": 120,
       "relation": "eq",
       "target_text": "1",
-      "target_option": null
+      "target_option": null,
+      "editors": []
+    }
+  },
+  {
+    "model": "conditions.condition",
+    "pk": 17,
+    "fields": {
+      "uri": "https://foo.com/terms/conditions/foo-condition",
+      "uri_prefix": "https://foo.com/terms",
+      "key": "foo-condition",
+      "comment": "",
+      "locked": false,
+      "source": 122,
+      "relation": "eq",
+      "target_text": "",
+      "target_option": null,
+      "editors": [
+        2
+      ]
+    }
+  },
+  {
+    "model": "conditions.condition",
+    "pk": 18,
+    "fields": {
+      "uri": "https://bar.com/terms/conditions/bar-condition",
+      "uri_prefix": "https://bar.com/terms",
+      "key": "bar-condition",
+      "comment": "",
+      "locked": false,
+      "source": 123,
+      "relation": "eq",
+      "target_text": "",
+      "target_option": null,
+      "editors": [
+        3
+      ]
     }
   }
-]
\ No newline at end of file
+]
diff --git a/testing/fixtures/domain.json b/testing/fixtures/domain.json
index 2bad1a33e4..7add8d157d 100644
--- a/testing/fixtures/domain.json
+++ b/testing/fixtures/domain.json
@@ -13,7 +13,8 @@
       "lft": 2,
       "rght": 27,
       "tree_id": 2,
-      "level": 1
+      "level": 1,
+      "editors": []
     }
   },
   {
@@ -30,7 +31,8 @@
       "lft": 28,
       "rght": 55,
       "tree_id": 2,
-      "level": 1
+      "level": 1,
+      "editors": []
     }
   },
   {
@@ -47,7 +49,8 @@
       "lft": 17,
       "rght": 18,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -64,7 +67,8 @@
       "lft": 19,
       "rght": 20,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -81,7 +85,8 @@
       "lft": 9,
       "rght": 10,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -98,7 +103,8 @@
       "lft": 7,
       "rght": 8,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -115,7 +121,8 @@
       "lft": 13,
       "rght": 14,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -132,7 +139,8 @@
       "lft": 3,
       "rght": 4,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -149,7 +157,8 @@
       "lft": 5,
       "rght": 6,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -166,7 +175,8 @@
       "lft": 21,
       "rght": 22,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -183,7 +193,8 @@
       "lft": 11,
       "rght": 12,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -200,7 +211,8 @@
       "lft": 15,
       "rght": 16,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -217,7 +229,8 @@
       "lft": 29,
       "rght": 30,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -234,7 +247,8 @@
       "lft": 31,
       "rght": 32,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -251,7 +265,8 @@
       "lft": 39,
       "rght": 40,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -268,7 +283,8 @@
       "lft": 33,
       "rght": 34,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -285,7 +301,8 @@
       "lft": 35,
       "rght": 36,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -302,7 +319,8 @@
       "lft": 37,
       "rght": 38,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -319,7 +337,8 @@
       "lft": 41,
       "rght": 42,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -336,7 +355,8 @@
       "lft": 43,
       "rght": 44,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -353,7 +373,8 @@
       "lft": 45,
       "rght": 46,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -370,7 +391,8 @@
       "lft": 47,
       "rght": 48,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -387,7 +409,8 @@
       "lft": 49,
       "rght": 50,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -404,7 +427,8 @@
       "lft": 1,
       "rght": 58,
       "tree_id": 1,
-      "level": 0
+      "level": 0,
+      "editors": []
     }
   },
   {
@@ -421,7 +445,8 @@
       "lft": 30,
       "rght": 55,
       "tree_id": 1,
-      "level": 1
+      "level": 1,
+      "editors": []
     }
   },
   {
@@ -438,7 +463,8 @@
       "lft": 2,
       "rght": 29,
       "tree_id": 1,
-      "level": 1
+      "level": 1,
+      "editors": []
     }
   },
   {
@@ -455,7 +481,8 @@
       "lft": 3,
       "rght": 4,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -472,7 +499,8 @@
       "lft": 5,
       "rght": 6,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -489,7 +517,8 @@
       "lft": 31,
       "rght": 32,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -506,7 +535,8 @@
       "lft": 33,
       "rght": 34,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -523,7 +553,8 @@
       "lft": 1,
       "rght": 56,
       "tree_id": 2,
-      "level": 0
+      "level": 0,
+      "editors": []
     }
   },
   {
@@ -540,7 +571,8 @@
       "lft": 7,
       "rght": 8,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -557,7 +589,8 @@
       "lft": 9,
       "rght": 10,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -574,7 +607,8 @@
       "lft": 11,
       "rght": 12,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -591,7 +625,8 @@
       "lft": 13,
       "rght": 14,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -608,7 +643,8 @@
       "lft": 15,
       "rght": 16,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -625,7 +661,8 @@
       "lft": 17,
       "rght": 18,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -642,7 +679,8 @@
       "lft": 19,
       "rght": 20,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -659,7 +697,8 @@
       "lft": 21,
       "rght": 22,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -676,7 +715,8 @@
       "lft": 35,
       "rght": 36,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -693,7 +733,8 @@
       "lft": 39,
       "rght": 40,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -710,7 +751,8 @@
       "lft": 41,
       "rght": 42,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -727,7 +769,8 @@
       "lft": 43,
       "rght": 44,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -744,7 +787,8 @@
       "lft": 45,
       "rght": 46,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -761,7 +805,8 @@
       "lft": 47,
       "rght": 48,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -778,7 +823,8 @@
       "lft": 49,
       "rght": 50,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -795,7 +841,8 @@
       "lft": 51,
       "rght": 52,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -812,7 +859,8 @@
       "lft": 25,
       "rght": 26,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -829,7 +877,8 @@
       "lft": 56,
       "rght": 57,
       "tree_id": 1,
-      "level": 1
+      "level": 1,
+      "editors": []
     }
   },
   {
@@ -846,7 +895,8 @@
       "lft": 1,
       "rght": 22,
       "tree_id": 3,
-      "level": 0
+      "level": 0,
+      "editors": []
     }
   },
   {
@@ -863,7 +913,8 @@
       "lft": 2,
       "rght": 3,
       "tree_id": 3,
-      "level": 1
+      "level": 1,
+      "editors": []
     }
   },
   {
@@ -880,7 +931,8 @@
       "lft": 4,
       "rght": 5,
       "tree_id": 3,
-      "level": 1
+      "level": 1,
+      "editors": []
     }
   },
   {
@@ -897,7 +949,8 @@
       "lft": 1,
       "rght": 16,
       "tree_id": 4,
-      "level": 0
+      "level": 0,
+      "editors": []
     }
   },
   {
@@ -914,7 +967,8 @@
       "lft": 2,
       "rght": 3,
       "tree_id": 4,
-      "level": 1
+      "level": 1,
+      "editors": []
     }
   },
   {
@@ -931,7 +985,8 @@
       "lft": 4,
       "rght": 15,
       "tree_id": 4,
-      "level": 1
+      "level": 1,
+      "editors": []
     }
   },
   {
@@ -948,7 +1003,8 @@
       "lft": 5,
       "rght": 6,
       "tree_id": 4,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -965,7 +1021,8 @@
       "lft": 7,
       "rght": 8,
       "tree_id": 4,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -982,7 +1039,8 @@
       "lft": 9,
       "rght": 10,
       "tree_id": 4,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -999,7 +1057,8 @@
       "lft": 13,
       "rght": 14,
       "tree_id": 4,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -1016,7 +1075,8 @@
       "lft": 1,
       "rght": 8,
       "tree_id": 5,
-      "level": 0
+      "level": 0,
+      "editors": []
     }
   },
   {
@@ -1033,7 +1093,8 @@
       "lft": 2,
       "rght": 3,
       "tree_id": 5,
-      "level": 1
+      "level": 1,
+      "editors": []
     }
   },
   {
@@ -1050,7 +1111,8 @@
       "lft": 4,
       "rght": 5,
       "tree_id": 5,
-      "level": 1
+      "level": 1,
+      "editors": []
     }
   },
   {
@@ -1067,7 +1129,8 @@
       "lft": 6,
       "rght": 7,
       "tree_id": 5,
-      "level": 1
+      "level": 1,
+      "editors": []
     }
   },
   {
@@ -1084,7 +1147,8 @@
       "lft": 23,
       "rght": 24,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -1101,7 +1165,8 @@
       "lft": 51,
       "rght": 52,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -1118,7 +1183,8 @@
       "lft": 23,
       "rght": 24,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -1135,7 +1201,8 @@
       "lft": 37,
       "rght": 38,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -1152,7 +1219,8 @@
       "lft": 25,
       "rght": 26,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -1169,7 +1237,8 @@
       "lft": 53,
       "rght": 54,
       "tree_id": 2,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -1186,7 +1255,8 @@
       "lft": 27,
       "rght": 28,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -1203,7 +1273,8 @@
       "lft": 53,
       "rght": 54,
       "tree_id": 1,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -1220,7 +1291,8 @@
       "lft": 11,
       "rght": 12,
       "tree_id": 4,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -1237,7 +1309,8 @@
       "lft": 1,
       "rght": 12,
       "tree_id": 6,
-      "level": 0
+      "level": 0,
+      "editors": []
     }
   },
   {
@@ -1254,7 +1327,8 @@
       "lft": 2,
       "rght": 3,
       "tree_id": 6,
-      "level": 1
+      "level": 1,
+      "editors": []
     }
   },
   {
@@ -1271,7 +1345,8 @@
       "lft": 4,
       "rght": 5,
       "tree_id": 6,
-      "level": 1
+      "level": 1,
+      "editors": []
     }
   },
   {
@@ -1288,7 +1363,8 @@
       "lft": 6,
       "rght": 7,
       "tree_id": 6,
-      "level": 1
+      "level": 1,
+      "editors": []
     }
   },
   {
@@ -1305,7 +1381,8 @@
       "lft": 8,
       "rght": 9,
       "tree_id": 6,
-      "level": 1
+      "level": 1,
+      "editors": []
     }
   },
   {
@@ -1322,7 +1399,8 @@
       "lft": 10,
       "rght": 11,
       "tree_id": 6,
-      "level": 1
+      "level": 1,
+      "editors": []
     }
   },
   {
@@ -1339,7 +1417,8 @@
       "lft": 6,
       "rght": 15,
       "tree_id": 3,
-      "level": 1
+      "level": 1,
+      "editors": []
     }
   },
   {
@@ -1356,7 +1435,8 @@
       "lft": 7,
       "rght": 8,
       "tree_id": 3,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -1373,7 +1453,8 @@
       "lft": 9,
       "rght": 10,
       "tree_id": 3,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -1390,7 +1471,8 @@
       "lft": 11,
       "rght": 14,
       "tree_id": 3,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -1407,7 +1489,8 @@
       "lft": 12,
       "rght": 13,
       "tree_id": 3,
-      "level": 3
+      "level": 3,
+      "editors": []
     }
   },
   {
@@ -1424,7 +1507,8 @@
       "lft": 16,
       "rght": 21,
       "tree_id": 3,
-      "level": 1
+      "level": 1,
+      "editors": []
     }
   },
   {
@@ -1441,7 +1525,8 @@
       "lft": 17,
       "rght": 18,
       "tree_id": 3,
-      "level": 2
+      "level": 2,
+      "editors": []
     }
   },
   {
@@ -1458,7 +1543,48 @@
       "lft": 19,
       "rght": 20,
       "tree_id": 3,
-      "level": 2
+      "level": 2,
+      "editors": []
+    }
+  },
+  {
+    "model": "domain.attribute",
+    "pk": 122,
+    "fields": {
+      "uri": "https://foo.com/terms/domain/foo-attribute",
+      "uri_prefix": "https://foo.com/terms",
+      "key": "foo-attribute",
+      "path": "foo-attribute",
+      "comment": "",
+      "locked": false,
+      "parent": null,
+      "lft": 1,
+      "rght": 2,
+      "tree_id": 7,
+      "level": 0,
+      "editors": [
+        2
+      ]
+    }
+  },
+  {
+    "model": "domain.attribute",
+    "pk": 123,
+    "fields": {
+      "uri": "https://bar.com/terms/domain/bar-attribute",
+      "uri_prefix": "https://bar.com/terms",
+      "key": "bar-attribute",
+      "path": "bar-attribute",
+      "comment": "",
+      "locked": false,
+      "parent": null,
+      "lft": 1,
+      "rght": 2,
+      "tree_id": 8,
+      "level": 0,
+      "editors": [
+        3
+      ]
     }
   }
-]
\ No newline at end of file
+]
diff --git a/testing/fixtures/options.json b/testing/fixtures/options.json
index b0cfa9d3cf..7bff355451 100644
--- a/testing/fixtures/options.json
+++ b/testing/fixtures/options.json
@@ -10,6 +10,7 @@
       "locked": false,
       "order": 0,
       "provider_key": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -24,6 +25,7 @@
       "locked": false,
       "order": 1,
       "provider_key": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -38,6 +40,7 @@
       "locked": false,
       "order": 2,
       "provider_key": "",
+      "editors": [],
       "conditions": [
         16
       ]
@@ -54,6 +57,41 @@
       "locked": false,
       "order": 4,
       "provider_key": "simple",
+      "editors": [],
+      "conditions": []
+    }
+  },
+  {
+    "model": "options.optionset",
+    "pk": 5,
+    "fields": {
+      "uri": "https://foo.com/terms/options/foo-optionset",
+      "uri_prefix": "https://foo.com/terms",
+      "uri_path": "foo-optionset",
+      "comment": "",
+      "locked": false,
+      "order": 0,
+      "provider_key": "",
+      "editors": [
+        2
+      ],
+      "conditions": []
+    }
+  },
+  {
+    "model": "options.optionset",
+    "pk": 6,
+    "fields": {
+      "uri": "https://bar.com/terms/options/bar-optionset",
+      "uri_prefix": "https://bar.com/terms",
+      "uri_path": "bar-optionset",
+      "comment": "",
+      "locked": false,
+      "order": 0,
+      "provider_key": "",
+      "editors": [
+        3
+      ],
       "conditions": []
     }
   },
@@ -129,6 +167,42 @@
       "order": 2
     }
   },
+  {
+    "model": "options.optionsetoption",
+    "pk": 9,
+    "fields": {
+      "optionset": 5,
+      "option": 9,
+      "order": 1
+    }
+  },
+  {
+    "model": "options.optionsetoption",
+    "pk": 10,
+    "fields": {
+      "optionset": 5,
+      "option": 10,
+      "order": 2
+    }
+  },
+  {
+    "model": "options.optionsetoption",
+    "pk": 11,
+    "fields": {
+      "optionset": 6,
+      "option": 11,
+      "order": 1
+    }
+  },
+  {
+    "model": "options.optionsetoption",
+    "pk": 12,
+    "fields": {
+      "optionset": 6,
+      "option": 12,
+      "order": 2
+    }
+  },
   {
     "model": "options.option",
     "pk": 1,
@@ -143,7 +217,8 @@
       "text_lang3": "",
       "text_lang4": "",
       "text_lang5": "",
-      "additional_input": false
+      "additional_input": false,
+      "editors": []
     }
   },
   {
@@ -160,7 +235,8 @@
       "text_lang3": "",
       "text_lang4": "",
       "text_lang5": "",
-      "additional_input": false
+      "additional_input": false,
+      "editors": []
     }
   },
   {
@@ -177,7 +253,8 @@
       "text_lang3": "",
       "text_lang4": "",
       "text_lang5": "",
-      "additional_input": false
+      "additional_input": false,
+      "editors": []
     }
   },
   {
@@ -194,7 +271,8 @@
       "text_lang3": "",
       "text_lang4": "",
       "text_lang5": "",
-      "additional_input": false
+      "additional_input": false,
+      "editors": []
     }
   },
   {
@@ -211,7 +289,8 @@
       "text_lang3": "",
       "text_lang4": "",
       "text_lang5": "",
-      "additional_input": false
+      "additional_input": false,
+      "editors": []
     }
   },
   {
@@ -228,7 +307,8 @@
       "text_lang3": "",
       "text_lang4": "",
       "text_lang5": "",
-      "additional_input": false
+      "additional_input": false,
+      "editors": []
     }
   },
   {
@@ -245,7 +325,8 @@
       "text_lang3": "",
       "text_lang4": "",
       "text_lang5": "",
-      "additional_input": true
+      "additional_input": true,
+      "editors": []
     }
   },
   {
@@ -262,7 +343,88 @@
       "text_lang3": "",
       "text_lang4": "",
       "text_lang5": "",
-      "additional_input": true
+      "additional_input": true,
+      "editors": []
+    }
+  },
+  {
+    "model": "options.option",
+    "pk": 9,
+    "fields": {
+      "uri": "https://foo.com/terms/options/foo-option-1",
+      "uri_prefix": "https://foo.com/terms",
+      "uri_path": "foo-option-1",
+      "comment": "",
+      "locked": false,
+      "text_lang1": "foo-option-1",
+      "text_lang2": "foo-option-1",
+      "text_lang3": "",
+      "text_lang4": "",
+      "text_lang5": "",
+      "additional_input": false,
+      "editors": [
+        2
+      ]
+    }
+  },
+  {
+    "model": "options.option",
+    "pk": 10,
+    "fields": {
+      "uri": "https://foo.com/terms/options/foo-option-2",
+      "uri_prefix": "https://foo.com/terms",
+      "uri_path": "foo-option-2",
+      "comment": "",
+      "locked": false,
+      "text_lang1": "foo-option-2",
+      "text_lang2": "foo-option-2",
+      "text_lang3": "",
+      "text_lang4": "",
+      "text_lang5": "",
+      "additional_input": false,
+      "editors": [
+        2
+      ]
+    }
+  },
+  {
+    "model": "options.option",
+    "pk": 11,
+    "fields": {
+      "uri": "https://bar.com/terms/options/bar-option-1",
+      "uri_prefix": "https://bar.com/terms",
+      "uri_path": "bar-option-1",
+      "comment": "",
+      "locked": false,
+      "text_lang1": "bar-option-1",
+      "text_lang2": "bar-option-1",
+      "text_lang3": "",
+      "text_lang4": "",
+      "text_lang5": "",
+      "additional_input": false,
+      "editors": [
+        3
+      ]
+    }
+  },
+  {
+    "model": "options.option",
+    "pk": 12,
+    "fields": {
+      "uri": "https://bar.com/terms/options/bar-option-2",
+      "uri_prefix": "https://bar.com/terms",
+      "uri_path": "bar-option-2",
+      "comment": "",
+      "locked": false,
+      "text_lang1": "bar-option-2",
+      "text_lang2": "bar-option-2",
+      "text_lang3": "",
+      "text_lang4": "",
+      "text_lang5": "",
+      "additional_input": false,
+      "editors": [
+        3
+      ]
     }
   }
-]
\ No newline at end of file
+]
diff --git a/testing/fixtures/questions.json b/testing/fixtures/questions.json
index ce0be54a3f..a26f784a46 100644
--- a/testing/fixtures/questions.json
+++ b/testing/fixtures/questions.json
@@ -25,6 +25,7 @@
       "sites": [
         1
       ],
+      "editors": [],
       "groups": []
     }
   },
@@ -54,6 +55,71 @@
       "sites": [
         1
       ],
+      "editors": [],
+      "groups": []
+    }
+  },
+  {
+    "model": "questions.catalog",
+    "pk": 3,
+    "fields": {
+      "created": "2023-06-26T14:07:46.508Z",
+      "updated": "2023-06-26T14:52:36.050Z",
+      "uri": "https://foo.com/terms/questions/foo-catalog",
+      "uri_prefix": "https://foo.com/terms",
+      "uri_path": "foo-catalog",
+      "comment": "",
+      "locked": false,
+      "order": 0,
+      "title_lang1": "foo-catalog",
+      "title_lang2": "foo-catalog",
+      "title_lang3": "",
+      "title_lang4": "",
+      "title_lang5": "",
+      "help_lang1": "",
+      "help_lang2": "",
+      "help_lang3": "",
+      "help_lang4": "",
+      "help_lang5": "",
+      "available": true,
+      "sites": [
+        2
+      ],
+      "editors": [
+        2
+      ],
+      "groups": []
+    }
+  },
+  {
+    "model": "questions.catalog",
+    "pk": 4,
+    "fields": {
+      "created": "2023-06-27T11:34:42.943Z",
+      "updated": "2023-06-27T11:39:23.811Z",
+      "uri": "https://bar.com/terms/questions/bar-catalog",
+      "uri_prefix": "https://bar.com/terms",
+      "uri_path": "bar-catalog",
+      "comment": "",
+      "locked": false,
+      "order": 0,
+      "title_lang1": "bar-catalog",
+      "title_lang2": "bar-catalog",
+      "title_lang3": "",
+      "title_lang4": "",
+      "title_lang5": "",
+      "help_lang1": "",
+      "help_lang2": "",
+      "help_lang3": "",
+      "help_lang4": "",
+      "help_lang5": "",
+      "available": true,
+      "sites": [
+        3
+      ],
+      "editors": [
+        3
+      ],
       "groups": []
     }
   },
@@ -111,6 +177,24 @@
       "order": 3
     }
   },
+  {
+    "model": "questions.catalogsection",
+    "pk": 7,
+    "fields": {
+      "catalog": 3,
+      "section": 9,
+      "order": 1
+    }
+  },
+  {
+    "model": "questions.catalogsection",
+    "pk": 8,
+    "fields": {
+      "catalog": 4,
+      "section": 10,
+      "order": 1
+    }
+  },
   {
     "model": "questions.page",
     "pk": 1,
@@ -144,6 +228,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -180,6 +265,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -216,6 +302,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -252,6 +339,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -269,7 +357,7 @@
       "attribute": null,
       "is_collection": false,
       "title_lang1": "Select drop-down",
-      "title_lang2": "Select-Aufklappmenü",
+      "title_lang2": "Select-Aufklappmen\u00fc",
       "title_lang3": "",
       "title_lang4": "",
       "title_lang5": "",
@@ -288,6 +376,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -324,6 +413,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -360,6 +450,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -396,6 +487,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -432,6 +524,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -468,6 +561,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -504,6 +598,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -521,7 +616,7 @@
       "attribute": null,
       "is_collection": false,
       "title_lang1": "Select drop-down",
-      "title_lang2": "Select-Aufklappmenü",
+      "title_lang2": "Select-Aufklappmen\u00fc",
       "title_lang3": "",
       "title_lang4": "",
       "title_lang5": "",
@@ -540,6 +635,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -576,6 +672,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -612,6 +709,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -648,6 +746,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -684,6 +783,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -720,6 +820,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -756,6 +857,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -792,6 +894,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -828,6 +931,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": [
         3
       ]
@@ -866,6 +970,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": [
         8
       ]
@@ -904,6 +1009,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": [
         1
       ]
@@ -942,6 +1048,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": [
         4
       ]
@@ -980,6 +1087,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": [
         5
       ]
@@ -1018,6 +1126,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": [
         6
       ]
@@ -1056,6 +1165,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": [
         7
       ]
@@ -1094,6 +1204,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": [
         9
       ]
@@ -1132,6 +1243,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": [
         2
       ]
@@ -1170,6 +1282,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": [
         13
       ]
@@ -1208,6 +1321,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": [
         10
       ]
@@ -1246,6 +1360,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": [
         14
       ]
@@ -1284,6 +1399,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": [
         11
       ]
@@ -1322,6 +1438,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -1358,6 +1475,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -1394,6 +1512,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -1430,6 +1549,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -1466,6 +1586,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -1502,6 +1623,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -1538,6 +1660,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -1574,6 +1697,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -1610,6 +1734,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -1646,6 +1771,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -1682,6 +1808,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -1718,6 +1845,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -1754,6 +1882,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -1790,6 +1919,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -1826,6 +1956,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -1862,11 +1993,90 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": [
         3
       ]
     }
   },
+  {
+    "model": "questions.page",
+    "pk": 97,
+    "fields": {
+      "created": "2023-06-26T14:08:39.327Z",
+      "updated": "2023-06-26T14:31:34.860Z",
+      "uri": "https://foo.com/terms/questions/foo-page",
+      "uri_prefix": "https://foo.com/terms",
+      "uri_path": "foo-page",
+      "comment": "",
+      "locked": false,
+      "attribute": 122,
+      "is_collection": false,
+      "title_lang1": "foo-page",
+      "title_lang2": "foo-page",
+      "title_lang3": "",
+      "title_lang4": "",
+      "title_lang5": "",
+      "help_lang1": "",
+      "help_lang2": "",
+      "help_lang3": "",
+      "help_lang4": "",
+      "help_lang5": "",
+      "verbose_name_lang1": "",
+      "verbose_name_lang2": "",
+      "verbose_name_lang3": "",
+      "verbose_name_lang4": "",
+      "verbose_name_lang5": "",
+      "verbose_name_plural_lang1": "",
+      "verbose_name_plural_lang2": "",
+      "verbose_name_plural_lang3": "",
+      "verbose_name_plural_lang4": "",
+      "verbose_name_plural_lang5": "",
+      "editors": [
+        2
+      ],
+      "conditions": []
+    }
+  },
+  {
+    "model": "questions.page",
+    "pk": 98,
+    "fields": {
+      "created": "2023-06-27T11:36:19.701Z",
+      "updated": "2023-06-27T11:39:17.861Z",
+      "uri": "https://bar.com/terms/questions/bar-page",
+      "uri_prefix": "https://bar.com/terms",
+      "uri_path": "bar-page",
+      "comment": "",
+      "locked": false,
+      "attribute": 123,
+      "is_collection": false,
+      "title_lang1": "bar-page",
+      "title_lang2": "bar-page",
+      "title_lang3": "",
+      "title_lang4": "",
+      "title_lang5": "",
+      "help_lang1": "",
+      "help_lang2": "",
+      "help_lang3": "",
+      "help_lang4": "",
+      "help_lang5": "",
+      "verbose_name_lang1": "",
+      "verbose_name_lang2": "",
+      "verbose_name_lang3": "",
+      "verbose_name_lang4": "",
+      "verbose_name_lang5": "",
+      "verbose_name_plural_lang1": "",
+      "verbose_name_plural_lang2": "",
+      "verbose_name_plural_lang3": "",
+      "verbose_name_plural_lang4": "",
+      "verbose_name_plural_lang5": "",
+      "editors": [
+        3
+      ],
+      "conditions": []
+    }
+  },
   {
     "model": "questions.pagequestion",
     "pk": 1,
@@ -2641,6 +2851,24 @@
       "order": 1
     }
   },
+  {
+    "model": "questions.pagequestionset",
+    "pk": 3,
+    "fields": {
+      "page": 97,
+      "questionset": 95,
+      "order": 0
+    }
+  },
+  {
+    "model": "questions.pagequestionset",
+    "pk": 4,
+    "fields": {
+      "page": 98,
+      "questionset": 96,
+      "order": 0
+    }
+  },
   {
     "model": "questions.question",
     "pk": 1,
@@ -2661,7 +2889,7 @@
       "help_lang4": "",
       "help_lang5": "",
       "text_lang1": "Text?",
-      "text_lang2": "￼Text?",
+      "text_lang2": "\ufffcText?",
       "text_lang3": "",
       "text_lang4": "",
       "text_lang5": "",
@@ -2689,6 +2917,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -2741,6 +2970,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -2793,6 +3023,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -2845,6 +3076,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         2
       ],
@@ -2871,7 +3103,7 @@
       "help_lang4": "",
       "help_lang5": "",
       "text_lang1": "Select drop-down?",
-      "text_lang2": "Select-Aufklappmenü?",
+      "text_lang2": "Select-Aufklappmen\u00fc?",
       "text_lang3": "",
       "text_lang4": "",
       "text_lang5": "",
@@ -2899,6 +3131,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         1
       ],
@@ -2953,6 +3186,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -3005,6 +3239,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -3029,7 +3264,7 @@
       "help_lang4": "",
       "help_lang5": "",
       "text_lang1": "Text?",
-      "text_lang2": "￼Text?",
+      "text_lang2": "\ufffcText?",
       "text_lang3": "",
       "text_lang4": "",
       "text_lang5": "",
@@ -3057,6 +3292,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -3109,6 +3345,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -3161,6 +3398,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -3213,6 +3451,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         2
       ],
@@ -3239,7 +3478,7 @@
       "help_lang4": "",
       "help_lang5": "",
       "text_lang1": "Select drop-down?",
-      "text_lang2": "Select-Aufklappmenü?",
+      "text_lang2": "Select-Aufklappmen\u00fc?",
       "text_lang3": "",
       "text_lang4": "",
       "text_lang5": "",
@@ -3267,6 +3506,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         1
       ],
@@ -3321,6 +3561,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -3373,6 +3614,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -3425,6 +3667,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         2
       ],
@@ -3479,6 +3722,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -3531,6 +3775,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -3583,6 +3828,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -3635,6 +3881,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -3687,6 +3934,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -3739,6 +3987,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -3791,6 +4040,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         2
       ],
@@ -3817,7 +4067,7 @@
       "help_lang4": "",
       "help_lang5": "",
       "text_lang1": "Select drop-down?",
-      "text_lang2": "Select-Aufklappmenü?",
+      "text_lang2": "Select-Aufklappmen\u00fc?",
       "text_lang3": "",
       "text_lang4": "",
       "text_lang5": "",
@@ -3845,6 +4095,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         1
       ],
@@ -3899,6 +4150,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -3951,6 +4203,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -4003,6 +4256,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         2
       ],
@@ -4029,7 +4283,7 @@
       "help_lang4": "",
       "help_lang5": "",
       "text_lang1": "Select drop-down?",
-      "text_lang2": "Select-Aufklappmenü?",
+      "text_lang2": "Select-Aufklappmen\u00fc?",
       "text_lang3": "",
       "text_lang4": "",
       "text_lang5": "",
@@ -4057,6 +4311,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         1
       ],
@@ -4111,6 +4366,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -4163,6 +4419,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -4215,6 +4472,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         2
       ],
@@ -4269,6 +4527,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -4321,6 +4580,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -4373,6 +4633,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -4425,6 +4686,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         2
       ],
@@ -4451,7 +4713,7 @@
       "help_lang4": "",
       "help_lang5": "",
       "text_lang1": "Select drop-down?",
-      "text_lang2": "Select-Aufklappmenü?",
+      "text_lang2": "Select-Aufklappmen\u00fc?",
       "text_lang3": "",
       "text_lang4": "",
       "text_lang5": "",
@@ -4479,6 +4741,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         1
       ],
@@ -4533,6 +4796,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -4585,6 +4849,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -4637,6 +4902,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -4689,6 +4955,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -4741,6 +5008,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -4793,6 +5061,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         2
       ],
@@ -4819,7 +5088,7 @@
       "help_lang4": "",
       "help_lang5": "",
       "text_lang1": "Select drop-down?",
-      "text_lang2": "Select-Aufklappmenü?",
+      "text_lang2": "Select-Aufklappmen\u00fc?",
       "text_lang3": "",
       "text_lang4": "",
       "text_lang5": "",
@@ -4847,6 +5116,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         1
       ],
@@ -4901,6 +5171,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -4953,6 +5224,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -5005,6 +5277,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         2
       ],
@@ -5059,6 +5332,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -5111,6 +5385,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -5163,6 +5438,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -5215,6 +5491,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -5267,6 +5544,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -5319,6 +5597,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -5371,6 +5650,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -5423,6 +5703,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -5475,6 +5756,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -5527,6 +5809,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         1
       ],
@@ -5581,6 +5864,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         1
       ],
@@ -5635,6 +5919,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         1
       ],
@@ -5689,6 +5974,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         1
       ],
@@ -5743,6 +6029,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         1,
         2
@@ -5798,6 +6085,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         4
       ],
@@ -5852,6 +6140,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         4
       ],
@@ -5906,6 +6195,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         4
       ],
@@ -5960,6 +6250,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         2,
         4
@@ -6015,6 +6306,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -6067,6 +6359,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -6119,6 +6412,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -6171,6 +6465,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -6223,6 +6518,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -6275,6 +6571,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         2
       ],
@@ -6329,6 +6626,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         1
       ],
@@ -6383,6 +6681,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         1
       ],
@@ -6437,6 +6736,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         1
       ],
@@ -6491,6 +6791,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         1
       ],
@@ -6545,6 +6846,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         1
       ],
@@ -6599,6 +6901,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         1
       ],
@@ -6653,6 +6956,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         4
       ],
@@ -6707,6 +7011,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -6759,6 +7064,7 @@
       "step": null,
       "unit": "",
       "width": 6,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -6811,6 +7117,7 @@
       "step": null,
       "unit": "",
       "width": 6,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -6863,6 +7170,7 @@
       "step": null,
       "unit": "",
       "width": 9,
+      "editors": [],
       "optionsets": [
         1
       ],
@@ -6917,6 +7225,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -6969,6 +7278,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         1
       ],
@@ -7023,6 +7333,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -7075,6 +7386,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -7127,6 +7439,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -7179,6 +7492,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": [
         15
@@ -7233,6 +7547,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
@@ -7285,6 +7600,7 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [
         3,
         1
@@ -7340,10 +7656,125 @@
       "step": null,
       "unit": "",
       "width": null,
+      "editors": [],
       "optionsets": [],
       "conditions": []
     }
   },
+  {
+    "model": "questions.question",
+    "pk": 109,
+    "fields": {
+      "created": "2023-06-26T14:10:30.364Z",
+      "updated": "2023-06-26T14:31:57.376Z",
+      "uri": "https://foo.com/terms/questions/foo-question",
+      "uri_prefix": "https://foo.com/terms",
+      "uri_path": "foo-question",
+      "comment": "",
+      "locked": false,
+      "attribute": 122,
+      "is_collection": false,
+      "is_optional": false,
+      "help_lang1": "",
+      "help_lang2": "",
+      "help_lang3": "",
+      "help_lang4": "",
+      "help_lang5": "",
+      "text_lang1": "foo-question",
+      "text_lang2": "foo-question",
+      "text_lang3": "",
+      "text_lang4": "",
+      "text_lang5": "",
+      "default_text_lang1": "",
+      "default_text_lang2": "",
+      "default_text_lang3": "",
+      "default_text_lang4": "",
+      "default_text_lang5": "",
+      "default_option": null,
+      "default_external_id": "",
+      "verbose_name_lang1": "",
+      "verbose_name_lang2": "",
+      "verbose_name_lang3": "",
+      "verbose_name_lang4": "",
+      "verbose_name_lang5": "",
+      "verbose_name_plural_lang1": "",
+      "verbose_name_plural_lang2": "",
+      "verbose_name_plural_lang3": "",
+      "verbose_name_plural_lang4": "",
+      "verbose_name_plural_lang5": "",
+      "widget_type": "text",
+      "value_type": "text",
+      "minimum": null,
+      "maximum": null,
+      "step": null,
+      "unit": "",
+      "width": null,
+      "editors": [
+        2
+      ],
+      "optionsets": [
+        5
+      ],
+      "conditions": []
+    }
+  },
+  {
+    "model": "questions.question",
+    "pk": 110,
+    "fields": {
+      "created": "2023-06-27T11:38:08.358Z",
+      "updated": "2023-06-27T11:44:44.368Z",
+      "uri": "https://bar.com/terms/questions/bar-question",
+      "uri_prefix": "https://bar.com/terms",
+      "uri_path": "bar-question",
+      "comment": null,
+      "locked": false,
+      "attribute": 123,
+      "is_collection": false,
+      "is_optional": false,
+      "help_lang1": null,
+      "help_lang2": null,
+      "help_lang3": null,
+      "help_lang4": null,
+      "help_lang5": null,
+      "text_lang1": "bar-question",
+      "text_lang2": "bar-question",
+      "text_lang3": null,
+      "text_lang4": null,
+      "text_lang5": null,
+      "default_text_lang1": null,
+      "default_text_lang2": null,
+      "default_text_lang3": null,
+      "default_text_lang4": null,
+      "default_text_lang5": null,
+      "default_option": null,
+      "default_external_id": "",
+      "verbose_name_lang1": "",
+      "verbose_name_lang2": "",
+      "verbose_name_lang3": "",
+      "verbose_name_lang4": "",
+      "verbose_name_lang5": "",
+      "verbose_name_plural_lang1": "",
+      "verbose_name_plural_lang2": "",
+      "verbose_name_plural_lang3": "",
+      "verbose_name_plural_lang4": "",
+      "verbose_name_plural_lang5": "",
+      "widget_type": "text",
+      "value_type": "text",
+      "minimum": null,
+      "maximum": null,
+      "step": null,
+      "unit": "",
+      "width": null,
+      "editors": [
+        3
+      ],
+      "optionsets": [
+        6
+      ],
+      "conditions": []
+    }
+  },
   {
     "model": "questions.questionset",
     "pk": 89,
@@ -7377,6 +7808,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -7413,6 +7845,7 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": []
     }
   },
@@ -7449,11 +7882,90 @@
       "verbose_name_plural_lang3": "",
       "verbose_name_plural_lang4": "",
       "verbose_name_plural_lang5": "",
+      "editors": [],
       "conditions": [
         15
       ]
     }
   },
+  {
+    "model": "questions.questionset",
+    "pk": 95,
+    "fields": {
+      "created": "2023-06-26T14:10:07.972Z",
+      "updated": "2023-06-26T14:51:30.505Z",
+      "uri": "https://foo.com/terms/questions/foo-questionset",
+      "uri_prefix": "https://foo.com/terms",
+      "uri_path": "foo-questionset",
+      "comment": "",
+      "locked": false,
+      "attribute": 122,
+      "is_collection": false,
+      "title_lang1": "foo-questionset",
+      "title_lang2": "foo-questionset",
+      "title_lang3": "",
+      "title_lang4": "",
+      "title_lang5": "",
+      "help_lang1": "",
+      "help_lang2": "",
+      "help_lang3": "",
+      "help_lang4": "",
+      "help_lang5": "",
+      "verbose_name_lang1": "",
+      "verbose_name_lang2": "",
+      "verbose_name_lang3": "",
+      "verbose_name_lang4": "",
+      "verbose_name_lang5": "",
+      "verbose_name_plural_lang1": "",
+      "verbose_name_plural_lang2": "",
+      "verbose_name_plural_lang3": "",
+      "verbose_name_plural_lang4": "",
+      "verbose_name_plural_lang5": "",
+      "editors": [
+        2
+      ],
+      "conditions": []
+    }
+  },
+  {
+    "model": "questions.questionset",
+    "pk": 96,
+    "fields": {
+      "created": "2023-06-27T11:37:35.454Z",
+      "updated": "2023-06-27T11:38:57.402Z",
+      "uri": "https://bar.com/terms/questions/bar-questionset",
+      "uri_prefix": "https://bar.com/terms",
+      "uri_path": "bar-questionset",
+      "comment": "",
+      "locked": false,
+      "attribute": 123,
+      "is_collection": false,
+      "title_lang1": "bar-questionset",
+      "title_lang2": "bar-questionset",
+      "title_lang3": "",
+      "title_lang4": "",
+      "title_lang5": "",
+      "help_lang1": "",
+      "help_lang2": "",
+      "help_lang3": "",
+      "help_lang4": "",
+      "help_lang5": "",
+      "verbose_name_lang1": "",
+      "verbose_name_lang2": "",
+      "verbose_name_lang3": "",
+      "verbose_name_lang4": "",
+      "verbose_name_lang5": "",
+      "verbose_name_plural_lang1": "",
+      "verbose_name_plural_lang2": "",
+      "verbose_name_plural_lang3": "",
+      "verbose_name_plural_lang4": "",
+      "verbose_name_plural_lang5": "",
+      "editors": [
+        3
+      ],
+      "conditions": []
+    }
+  },
   {
     "model": "questions.questionsetquestion",
     "pk": 1,
@@ -7499,6 +8011,24 @@
       "order": 0
     }
   },
+  {
+    "model": "questions.questionsetquestion",
+    "pk": 6,
+    "fields": {
+      "questionset": 95,
+      "question": 109,
+      "order": 0
+    }
+  },
+  {
+    "model": "questions.questionsetquestion",
+    "pk": 7,
+    "fields": {
+      "questionset": 96,
+      "question": 110,
+      "order": 0
+    }
+  },
   {
     "model": "questions.questionsetquestionset",
     "pk": 1,
@@ -7523,7 +8053,8 @@
       "title_lang2": "Einzelne Fragen",
       "title_lang3": "",
       "title_lang4": "",
-      "title_lang5": ""
+      "title_lang5": "",
+      "editors": []
     }
   },
   {
@@ -7541,7 +8072,8 @@
       "title_lang2": "Sets",
       "title_lang3": "",
       "title_lang4": "",
-      "title_lang5": ""
+      "title_lang5": "",
+      "editors": []
     }
   },
   {
@@ -7559,7 +8091,8 @@
       "title_lang2": "Bedingungen",
       "title_lang3": "",
       "title_lang4": "",
-      "title_lang5": ""
+      "title_lang5": "",
+      "editors": []
     }
   },
   {
@@ -7577,7 +8110,8 @@
       "title_lang2": "Sammlungen",
       "title_lang3": "",
       "title_lang4": "",
-      "title_lang5": ""
+      "title_lang5": "",
+      "editors": []
     }
   },
   {
@@ -7595,7 +8129,8 @@
       "title_lang2": "Optionen",
       "title_lang3": "",
       "title_lang4": "",
-      "title_lang5": ""
+      "title_lang5": "",
+      "editors": []
     }
   },
   {
@@ -7613,7 +8148,50 @@
       "title_lang2": "Blocks",
       "title_lang3": "",
       "title_lang4": "",
-      "title_lang5": ""
+      "title_lang5": "",
+      "editors": []
+    }
+  },
+  {
+    "model": "questions.section",
+    "pk": 9,
+    "fields": {
+      "created": "2023-06-26T14:08:08.408Z",
+      "updated": "2023-06-26T14:28:08.037Z",
+      "uri": "https://foo.com/terms/questions/foo-section",
+      "uri_prefix": "https://foo.com/terms",
+      "uri_path": "foo-section",
+      "comment": "",
+      "locked": false,
+      "title_lang1": "foo-section",
+      "title_lang2": "foo-section",
+      "title_lang3": "",
+      "title_lang4": "",
+      "title_lang5": "",
+      "editors": [
+        2
+      ]
+    }
+  },
+  {
+    "model": "questions.section",
+    "pk": 10,
+    "fields": {
+      "created": "2023-06-27T11:35:30.430Z",
+      "updated": "2023-06-27T11:39:21.332Z",
+      "uri": "https://bar.com/terms/questions/bar-section",
+      "uri_prefix": "https://bar.com/terms",
+      "uri_path": "bar-section",
+      "comment": "",
+      "locked": false,
+      "title_lang1": "bar-section",
+      "title_lang2": "bar-section",
+      "title_lang3": "",
+      "title_lang4": "",
+      "title_lang5": "",
+      "editors": [
+        3
+      ]
     }
   },
   {
@@ -8047,5 +8625,23 @@
       "page": 16,
       "order": 11
     }
+  },
+  {
+    "model": "questions.sectionpage",
+    "pk": 49,
+    "fields": {
+      "section": 9,
+      "page": 97,
+      "order": 1
+    }
+  },
+  {
+    "model": "questions.sectionpage",
+    "pk": 50,
+    "fields": {
+      "section": 10,
+      "page": 98,
+      "order": 0
+    }
   }
-]
\ No newline at end of file
+]
diff --git a/testing/fixtures/tasks.json b/testing/fixtures/tasks.json
index 43b3781f25..640e3501a8 100644
--- a/testing/fixtures/tasks.json
+++ b/testing/fixtures/tasks.json
@@ -7,6 +7,7 @@
       "uri_prefix": "http://example.com/terms",
       "key": "text_equal_test",
       "comment": "1",
+      "locked": false,
       "title_lang1": "text_equal_test",
       "title_lang2": "text_equal_test",
       "title_lang3": "",
@@ -21,13 +22,16 @@
       "end_attribute": 16,
       "days_before": 1,
       "days_after": 2,
-      "conditions": [
+      "available": true,
+      "catalogs": [
         1
       ],
       "sites": [
         1
       ],
-      "catalogs": [
+      "editors": [],
+      "groups": [],
+      "conditions": [
         1
       ]
     }
@@ -40,6 +44,7 @@
       "uri_prefix": "http://example.com/terms",
       "key": "null",
       "comment": "1",
+      "locked": false,
       "title_lang1": "null",
       "title_lang2": "null",
       "title_lang3": "",
@@ -54,15 +59,92 @@
       "end_attribute": null,
       "days_before": 0,
       "days_after": 0,
+      "available": true,
+      "catalogs": [
+        1
+      ],
+      "sites": [
+        1
+      ],
+      "editors": [],
+      "groups": [],
       "conditions": [
         2
+      ]
+    }
+  },
+  {
+    "model": "tasks.task",
+    "pk": 3,
+    "fields": {
+      "uri": "https://foo.com/terms/tasks/foo-task",
+      "uri_prefix": "https://foo.com/terms",
+      "key": "foo-task",
+      "comment": "",
+      "locked": false,
+      "title_lang1": "foo-task",
+      "title_lang2": "foo-task",
+      "title_lang3": "",
+      "title_lang4": "",
+      "title_lang5": "",
+      "text_lang1": "",
+      "text_lang2": "",
+      "text_lang3": "",
+      "text_lang4": "",
+      "text_lang5": "",
+      "start_attribute": null,
+      "end_attribute": null,
+      "days_before": null,
+      "days_after": null,
+      "available": true,
+      "catalogs": [
+        3
       ],
       "sites": [
-        1
+        2
       ],
+      "editors": [
+        2
+      ],
+      "groups": [],
+      "conditions": []
+    }
+  },
+  {
+    "model": "tasks.task",
+    "pk": 4,
+    "fields": {
+      "uri": "https://bar.com/terms/tasks/bar-task",
+      "uri_prefix": "https://bar.com/terms",
+      "key": "bar-task",
+      "comment": "",
+      "locked": false,
+      "title_lang1": "bar-task",
+      "title_lang2": "bar-task",
+      "title_lang3": "",
+      "title_lang4": "",
+      "title_lang5": "",
+      "text_lang1": "",
+      "text_lang2": "",
+      "text_lang3": "",
+      "text_lang4": "",
+      "text_lang5": "",
+      "start_attribute": null,
+      "end_attribute": null,
+      "days_before": null,
+      "days_after": null,
+      "available": true,
       "catalogs": [
-        1
-      ]
+        4
+      ],
+      "sites": [
+        3
+      ],
+      "editors": [
+        3
+      ],
+      "groups": [],
+      "conditions": []
     }
   }
-]
\ No newline at end of file
+]
diff --git a/testing/fixtures/users.json b/testing/fixtures/users.json
index 3632bd2ef3..5b8a476ff9 100644
--- a/testing/fixtures/users.json
+++ b/testing/fixtures/users.json
@@ -31,9 +31,7 @@
       "is_staff": false,
       "is_active": true,
       "date_joined": "2017-02-28T12:44:32Z",
-      "groups": [
-        1
-      ],
+      "groups": [],
       "user_permissions": []
     }
   },
@@ -51,9 +49,7 @@
       "is_staff": false,
       "is_active": true,
       "date_joined": "2017-02-28T12:43:44Z",
-      "groups": [
-        2
-      ],
+      "groups": [],
       "user_permissions": []
     }
   },
@@ -89,9 +85,7 @@
       "is_staff": false,
       "is_active": true,
       "date_joined": "2017-03-01T13:52:11Z",
-      "groups": [
-        4
-      ],
+      "groups": [],
       "user_permissions": []
     }
   },
@@ -163,9 +157,7 @@
       "is_staff": false,
       "is_active": true,
       "date_joined": "2017-04-25T12:27:45Z",
-      "groups": [
-        3
-      ],
+      "groups": [],
       "user_permissions": []
     }
   },
@@ -206,11 +198,220 @@
     }
   },
   {
-    "model": "authtoken.token",
-    "pk": "2bd7a740cb62579feeb00facb35d6d205f76db67",
+    "model": "auth.user",
+    "pk": 12,
+    "fields": {
+      "password": "pbkdf2_sha256$260000$ptME03p79B30xA44ZaerMT$mXJAqYUuN5oTAfMTlng43w85ridEsLn4uOPaMuZgQA0=",
+      "last_login": "2023-02-06T08:42:53Z",
+      "is_superuser": false,
+      "username": "example-user",
+      "first_name": "Uxample",
+      "last_name": "User",
+      "email": "example-user@example.com",
+      "is_staff": false,
+      "is_active": true,
+      "date_joined": "2023-02-06T08:42:27Z",
+      "groups": [],
+      "user_permissions": []
+    }
+  },
+  {
+    "model": "auth.user",
+    "pk": 13,
+    "fields": {
+      "password": "pbkdf2_sha256$260000$OeWaEah3YzoF1BGfvO35ij$UpuJQi1MSoeHYx3YldMUOfum/vkmXmnChmkEuqaTt3M=",
+      "last_login": "2023-02-06T08:42:53Z",
+      "is_superuser": false,
+      "username": "foo-user",
+      "first_name": "Ulfoo",
+      "last_name": "User",
+      "email": "foo-user@foo.com",
+      "is_staff": false,
+      "is_active": true,
+      "date_joined": "2023-02-06T08:42:27Z",
+      "groups": [],
+      "user_permissions": []
+    }
+  },
+  {
+    "model": "auth.user",
+    "pk": 14,
+    "fields": {
+      "password": "pbkdf2_sha256$260000$IcGdXALB0q5ydtqgKpPnxa$nti35Qd0RJH0iP3iUhOhGW3UmlgDd0dRYXwp5NAOY3c=",
+      "last_login": "2023-02-06T08:43:50Z",
+      "is_superuser": false,
+      "username": "bar-user",
+      "first_name": "Umbarto",
+      "last_name": "User",
+      "email": "bar-user@bar.com",
+      "is_staff": false,
+      "is_active": true,
+      "date_joined": "2023-02-06T08:43:36Z",
+      "groups": [],
+      "user_permissions": []
+    }
+  },
+  {
+    "model": "auth.user",
+    "pk": 15,
+    "fields": {
+      "password": "pbkdf2_sha256$260000$B3yapDr8QuQYVB6zdZaNJM$I3RsRbPxmpGvbexpm5OL4CJRJqkKCOPcS4vvZAjoCRY=",
+      "last_login": "2023-02-06T09:27:00Z",
+      "is_superuser": false,
+      "username": "example-manager",
+      "first_name": "Manuexample",
+      "last_name": "Manager",
+      "email": "example-manager@example.com",
+      "is_staff": false,
+      "is_active": true,
+      "date_joined": "2023-02-06T09:17:00Z",
+      "groups": [],
+      "user_permissions": []
+    }
+  },
+  {
+    "model": "auth.user",
+    "pk": 16,
+    "fields": {
+      "password": "pbkdf2_sha256$260000$B3yapDr8QuQYVB6zdZaNJM$I3RsRbPxmpGvbexpm5OL4CJRJqkKCOPcS4vvZAjoCRY=",
+      "last_login": "2023-02-06T09:27:00Z",
+      "is_superuser": false,
+      "username": "foo-manager",
+      "first_name": "Mannifoo",
+      "last_name": "Manager",
+      "email": "foo-manager@foo.com",
+      "is_staff": false,
+      "is_active": true,
+      "date_joined": "2023-02-06T09:17:00Z",
+      "groups": [],
+      "user_permissions": []
+    }
+  },
+  {
+    "model": "auth.user",
+    "pk": 17
+    ,
+    "fields": {
+      "password": "pbkdf2_sha256$260000$GMW3hAxajrYGZlSPpDkr4B$ZWcjZVRQl0LaziXqTdDPChd9K4C7ePSu3jTgmlkTs9o=",
+      "last_login": "2023-02-06T09:20:00Z",
+      "is_superuser": false,
+      "username": "bar-manager",
+      "first_name": "Mulbarry",
+      "last_name": "Manager",
+      "email": "bar-manager@bar.com",
+      "is_staff": false,
+      "is_active": true,
+      "date_joined": "2023-02-06T09:15:00Z",
+      "groups": [],
+      "user_permissions": []
+    }
+  },
+  {
+    "model": "auth.user",
+    "pk": 18,
+    "fields": {
+      "password": "pbkdf2_sha256$260000$gZIqFR1eb0tkxJc74CcmS0$VFUh82PTMFGnyd2uIcYyhPeBGnq9KTelORl6TTej3yw=",
+      "last_login": null,
+      "is_superuser": false,
+      "username": "example-editor",
+      "first_name": "Examply",
+      "last_name": "Editor",
+      "email": "example-editor@example.com",
+      "is_staff": false,
+      "is_active": true,
+      "date_joined": "2023-02-17T12:34:35Z",
+      "groups": [],
+      "user_permissions": []
+    }
+  },
+  {
+    "model": "auth.user",
+    "pk": 19,
+    "fields": {
+      "password": "pbkdf2_sha256$260000$76AzvgKfzDck6vAsME0QUX$8glueBNTjTaqCZxd6gmZkfIVXWOKMQgVHAof3LG662o=",
+      "last_login": "2023-01-16T09:44:40Z",
+      "is_superuser": false,
+      "username": "foo-editor",
+      "first_name": "Edfooey",
+      "last_name": "Editor",
+      "email": "foo-editor@foo.com",
+      "is_staff": false,
+      "is_active": true,
+      "date_joined": "2022-12-13T07:37:53Z",
+      "groups": [],
+      "user_permissions": []
+    }
+  },
+  {
+    "model": "auth.user",
+    "pk": 20,
     "fields": {
-      "user": 9,
-      "created": "2020-12-08T15:29:13.739Z"
+      "password": "pbkdf2_sha256$260000$Pjfpn2zWzUGqNZUT8UDrGu$PEDpHcQzCzn/O3q8Zkm5OlbnE59W/k5nWeuQ91IkfzQ=",
+      "last_login": "2023-01-16T15:52:22Z",
+      "is_superuser": false,
+      "username": "bar-editor",
+      "first_name": "Edbar",
+      "last_name": "Editor",
+      "email": "bar-editor@bar.com",
+      "is_staff": false,
+      "is_active": true,
+      "date_joined": "2023-01-16T15:52:22Z",
+      "groups": [],
+      "user_permissions": []
+    }
+  },
+  {
+    "model": "auth.user",
+    "pk": 21,
+    "fields": {
+      "password": "pbkdf2_sha256$260000$yxY3ODmNl8Q9HdhVfjZNZC$j/V1Wps7i1RBZ/radDZ6mYfQorqwuHDwFDiedq0d/uk=",
+      "last_login": null,
+      "is_superuser": false,
+      "username": "example-reviewer",
+      "first_name": "Rexample",
+      "last_name": "Reviewer",
+      "email": "example-reviewer@example.com",
+      "is_staff": false,
+      "is_active": true,
+      "date_joined": "2023-02-17T12:34:45Z",
+      "groups": [],
+      "user_permissions": []
+    }
+  },
+  {
+    "model": "auth.user",
+    "pk": 22,
+    "fields": {
+      "password": "pbkdf2_sha256$260000$NqfJA8rYTGuPIEwXP7sHvF$UQ2rqzBTtJh4lx189c9gEHk5wX5nmUzLmlTYiEG9kc0=",
+      "last_login": "2023-02-17T14:50:45.341Z",
+      "is_superuser": false,
+      "username": "foo-reviewer",
+      "first_name": "Rufoos",
+      "last_name": "Reviewer",
+      "email": "foo-reviewer@foo.com",
+      "is_staff": false,
+      "is_active": true,
+      "date_joined": "2023-02-17T12:30:38Z",
+      "groups": [],
+      "user_permissions": []
+    }
+  },
+  {
+    "model": "auth.user",
+    "pk": 23,
+    "fields": {
+      "password": "pbkdf2_sha256$260000$SHU2slkGZKGTfCIMkB1i62$qs58u+sW5wCtKzzgPhBAPdK0M38SrDDqSCpziYEeCZE=",
+      "last_login": "2023-02-17T12:30:00Z",
+      "is_superuser": false,
+      "username": "bar-reviewer",
+      "first_name": "Robarto",
+      "last_name": "Reviewer",
+      "email": "bar-reviewer@bar.com",
+      "is_staff": false,
+      "is_active": true,
+      "date_joined": "2023-02-17T12:20:00Z",
+      "groups": [],
+      "user_permissions": []
     }
   }
-]
\ No newline at end of file
+]
diff --git a/testing/fixtures/views.json b/testing/fixtures/views.json
index 4246f9e555..dde510b750 100644
--- a/testing/fixtures/views.json
+++ b/testing/fixtures/views.json
@@ -26,6 +26,7 @@
       "sites": [
         1
       ],
+      "editors": [],
       "groups": []
     }
   },
@@ -56,6 +57,7 @@
       "sites": [
         1
       ],
+      "editors": [],
       "groups": []
     }
   },
@@ -84,7 +86,74 @@
       "sites": [
         1
       ],
+      "editors": [],
+      "groups": []
+    }
+  },
+  {
+    "model": "views.view",
+    "pk": 4,
+    "fields": {
+      "uri": "https://foo.com/terms/views/foo-view",
+      "uri_prefix": "https://foo.com/terms",
+      "key": "foo-view",
+      "comment": "",
+      "locked": false,
+      "template": "",
+      "title_lang1": "foo-view",
+      "title_lang2": "foo-view",
+      "title_lang3": "",
+      "title_lang4": "",
+      "title_lang5": "",
+      "help_lang1": "",
+      "help_lang2": "",
+      "help_lang3": "",
+      "help_lang4": "",
+      "help_lang5": "",
+      "available": true,
+      "catalogs": [
+        3
+      ],
+      "sites": [
+        2
+      ],
+      "editors": [
+        2
+      ],
+      "groups": []
+    }
+  },
+  {
+    "model": "views.view",
+    "pk": 5,
+    "fields": {
+      "uri": "https://bar.com/terms/views/bar-view",
+      "uri_prefix": "https://bar.com/terms",
+      "key": "bar-view",
+      "comment": "",
+      "locked": false,
+      "template": "",
+      "title_lang1": "bar-view",
+      "title_lang2": "bar-view",
+      "title_lang3": "",
+      "title_lang4": "",
+      "title_lang5": "",
+      "help_lang1": "",
+      "help_lang2": "",
+      "help_lang3": "",
+      "help_lang4": "",
+      "help_lang5": "",
+      "available": true,
+      "catalogs": [
+        4
+      ],
+      "sites": [
+        3
+      ],
+      "editors": [
+        3
+      ],
       "groups": []
     }
   }
-]
\ No newline at end of file
+]