Skip to content

Commit

Permalink
Merge pull request #56 from OltarzewskiK/issues/45
Browse files Browse the repository at this point in the history
Move MultitenantAdminMixin from openwisp-utils to openwisp-users
  • Loading branch information
nemesifier authored Dec 2, 2018
2 parents d3798ed + f1e1df1 commit cf5cd64
Show file tree
Hide file tree
Showing 10 changed files with 442 additions and 0 deletions.
12 changes: 12 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,18 @@ is enabled or not.
It is disabled by default because `OpenWISP <http://openwisp.org>`_ does not use
this feature of `django-organizations <https://github.com/bennylope/django-organizations>`_ yet.

Multitenancy mixins
-------------------

* **MultitenantAdminMixin**: adding this mixin to a ``ModelAdmin`` class will make it multitenant.
Set ``multitenant_shared_relations`` to the list of parameters you wish to have only organization
specific options.

* **MultitenantOrgFilter**: admin filter that shows only organizations the current user is associated with in its available choices.

* **MultitenantRelatedOrgFilter**: similar ``MultitenantOrgFilter`` but shows only objects which have a relation with
one of the organizations the current user is associated with.

Contributing
------------

Expand Down
102 changes: 102 additions & 0 deletions openwisp_users/multitenancy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
from django.contrib import admin
from django.db.models import Q
from django.utils.translation import ugettext_lazy as _


class MultitenantAdminMixin(object):
"""
Mixin that makes a ModelAdmin class multitenant:
users will see only the objects related to the organizations
they are associated with.
"""
multitenant_shared_relations = []
multitenant_parent = None

def __init__(self, *args, **kwargs):
super(MultitenantAdminMixin, self).__init__(*args, **kwargs)
parent = self.multitenant_parent
shared_relations = self.multitenant_shared_relations
if parent and parent not in shared_relations:
self.multitenant_shared_relations.append(parent)

def get_repr(self, obj):
return str(obj)

get_repr.short_description = _('name')

def get_queryset(self, request):
"""
If current user is not superuser, show only the
objects associated to organizations he/she is associated with
"""
qs = super(MultitenantAdminMixin, self).get_queryset(request)
user = request.user
if user.is_superuser:
return qs
if hasattr(self.model, 'organization'):
return qs.filter(organization__in=user.organizations_pk)
elif not self.multitenant_parent:
return qs
else:
qsarg = '{0}__organization__in'.format(self.multitenant_parent)
return qs.filter(**{qsarg: user.organizations_pk})

def _edit_form(self, request, form):
"""
Modifies the form querysets as follows;
if current user is not superuser:
* show only relevant organizations
* show only relations associated to relevant organizations
or shared relations
else show everything
"""
fields = form.base_fields
if not request.user.is_superuser:
orgs_pk = request.user.organizations_pk
# organizations relation;
# may be readonly and not present in field list
if 'organization' in fields:
org_field = fields['organization']
org_field.queryset = org_field.queryset.filter(pk__in=orgs_pk)
# other relations
q = Q(organization__in=orgs_pk) | Q(organization=None)
for field_name in self.multitenant_shared_relations:
# each relation may be readonly
# and not present in field list
if field_name not in fields:
continue
field = fields[field_name]
field.queryset = field.queryset.filter(q)

def get_form(self, request, obj=None, **kwargs):
form = super(MultitenantAdminMixin, self).get_form(request, obj, **kwargs)
self._edit_form(request, form)
return form

def get_formset(self, request, obj=None, **kwargs):
formset = super(MultitenantAdminMixin, self).get_formset(request, obj=None, **kwargs)
self._edit_form(request, formset.form)
return formset


class MultitenantOrgFilter(admin.RelatedFieldListFilter):
"""
Admin filter that shows only organizations the current
user is associated with in its available choices
"""
multitenant_lookup = 'pk__in'

def field_choices(self, field, request, model_admin):
if request.user.is_superuser:
return super(MultitenantOrgFilter, self).field_choices(field, request, model_admin)
organizations = request.user.organizations_pk
return field.get_choices(include_blank=False,
limit_choices_to={self.multitenant_lookup: organizations})


class MultitenantRelatedOrgFilter(MultitenantOrgFilter):
"""
Admin filter that shows only objects which have a relation with
one of the organizations the current user is associated with
"""
multitenant_lookup = 'organization__in'
84 changes: 84 additions & 0 deletions openwisp_users/tests/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,91 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.db.models import Q
from django.urls import reverse

from ..models import Organization, OrganizationOwner, OrganizationUser, User

user_model = get_user_model()


class TestMultitenantAdminMixin(object):
def setUp(self):
user_model.objects.create_superuser(username='admin',
password='tester',
email='admin@admin.com')

def _login(self, username='admin', password='tester'):
self.client.login(username=username, password=password)

def _logout(self):
self.client.logout()

operator_permission_filters = []

def get_operator_permissions(self):
filters = Q()
for filter in self.operator_permission_filters:
filters = filters | Q(**filter)
return Permission.objects.filter(filters)

def _create_operator(self, organizations=[]):
operator = user_model.objects.create_user(username='operator',
password='tester',
email='operator@test.com',
is_staff=True)
operator.user_permissions.add(*self.get_operator_permissions())
for organization in organizations:
OrganizationUser.objects.create(user=operator, organization=organization)
return operator

def _test_multitenant_admin(self, url, visible, hidden, select_widget=False):
"""
reusable test function that ensures different users
can see the right objects.
an operator with limited permissions will not be able
to see the elements contained in ``hidden``, while
a superuser can see everything.
"""
self._login(username='operator', password='tester')
response = self.client.get(url)

# utility format function
def _f(el, select_widget=False):
if select_widget:
return '{0}</option>'.format(el)
return el

# ensure elements in visible list are visible to operator
for el in visible:
self.assertContains(response, _f(el, select_widget),
msg_prefix='[operator contains]')
# ensure elements in hidden list are not visible to operator
for el in hidden:
self.assertNotContains(response, _f(el, select_widget),
msg_prefix='[operator not-contains]')

# now become superuser
self._logout()
self._login(username='admin', password='tester')
response = self.client.get(url)
# ensure all elements are visible to superuser
all_elements = visible + hidden
for el in all_elements:
self.assertContains(response, _f(el, select_widget),
msg_prefix='[superuser contains]')

def _test_changelist_recover_deleted(self, app_label, model_label):
self._test_multitenant_admin(
url=reverse('admin:{0}_{1}_changelist'.format(app_label, model_label)),
visible=[],
hidden=['Recover deleted']
)

def _test_recoverlist_operator_403(self, app_label, model_label):
self._login(username='operator', password='tester')
response = self.client.get(reverse('admin:{0}_{1}_recoverlist'.format(app_label, model_label)))
self.assertEqual(response.status_code, 403)


class TestOrganizationMixin(object):
def _create_user(self, **kwargs):
Expand Down
1 change: 1 addition & 0 deletions runtests.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@
args.insert(1, "test")
args.insert(2, "openwisp_users")
args.insert(3, "testapp.tests")
args.insert(4, "testapp.test_multitenancy")
execute_from_command_line(args)
17 changes: 17 additions & 0 deletions tests/testapp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
class CreateMixin(object):
def _create_book(self, **kwargs):
options = dict(name='test-book',
author='test-author')
options.update(kwargs)
b = self.book_model(**options)
b.full_clean()
b.save()
return b

def _create_shelf(self, **kwargs):
options = dict(name='test-shelf')
options.update(kwargs)
s = self.shelf_model(**options)
s.full_clean()
s.save()
return s
48 changes: 48 additions & 0 deletions tests/testapp/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from django.contrib import admin
from openwisp_users.multitenancy import (MultitenantAdminMixin,
MultitenantOrgFilter,
MultitenantRelatedOrgFilter)

from .models import Book, Shelf


class BaseAdmin(MultitenantAdminMixin, admin.ModelAdmin):
pass


class ShelfAdmin(BaseAdmin):
list_display = ['name', 'organization']
list_filter = [('organization', MultitenantOrgFilter)]
fields = ['name', 'organization', 'created', 'modified']


class BookAdmin(BaseAdmin):
list_display = ['name', 'author', 'organization', 'shelf']
list_filter = [('organization', MultitenantOrgFilter),
('shelf', MultitenantRelatedOrgFilter)]
fields = ['name', 'author', 'organization', 'shelf', 'created', 'modified']
multitenant_shared_relations = ['shelf']

def change_view(self, request, object_id, form_url='', extra_context=None):
extra_context = extra_context or {}
extra_context.update({
'additional_buttons': [
{
'type': 'button',
'url': 'DUMMY',
'class': 'previewbook',
'value': 'Preview book',
},
{
'type': 'button',
'url': 'DUMMY',
'class': 'downloadbook',
'value': 'Download book',
}
]
})
return super(BookAdmin, self).change_view(request, object_id, form_url, extra_context)


admin.site.register(Shelf, ShelfAdmin)
admin.site.register(Book, BookAdmin)
6 changes: 6 additions & 0 deletions tests/testapp/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class TestAppConfig(AppConfig):
name = 'testapp'
label = 'testapp'
53 changes: 53 additions & 0 deletions tests/testapp/migrations/0003_multitenancy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Generated by Django 2.1.3 on 2018-11-29 21:44

from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
import openwisp_users.mixins
import uuid


class Migration(migrations.Migration):

dependencies = [
('openwisp_users', '0004_default_groups'),
('testapp', '0002_config_template'),
]

operations = [
migrations.CreateModel(
name='Book',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('name', models.CharField(max_length=64, verbose_name='name')),
('author', models.CharField(max_length=64, verbose_name='author')),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='openwisp_users.Organization', verbose_name='organization')),
],
options={
'abstract': False,
},
bases=(openwisp_users.mixins.ValidateOrgMixin, models.Model),
),
migrations.CreateModel(
name='Shelf',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('name', models.CharField(max_length=64, verbose_name='name')),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='openwisp_users.Organization', verbose_name='organization')),
],
options={
'abstract': False,
},
bases=(openwisp_users.mixins.ValidateOrgMixin, models.Model),
),
migrations.AddField(
model_name='book',
name='shelf',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='testapp.Shelf'),
),
]
30 changes: 30 additions & 0 deletions tests/testapp/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import ugettext_lazy as _
from openwisp_users.mixins import OrgMixin, ShareableOrgMixin
from openwisp_utils.base import TimeStampedEditableModel


class Template(ShareableOrgMixin):
Expand All @@ -13,3 +16,30 @@ class Config(OrgMixin):

def clean(self):
self._validate_org_relation('template')


class Shelf(OrgMixin, TimeStampedEditableModel):
name = models.CharField(_('name'), max_length=64)

def __str__(self):
return self.name

class Meta:
abstract = False

def clean(self):
if self.name == "Intentional_Test_Fail":
raise ValidationError('Intentional_Test_Fail')
return self


class Book(OrgMixin, TimeStampedEditableModel):
name = models.CharField(_('name'), max_length=64)
author = models.CharField(_('author'), max_length=64)
shelf = models.ForeignKey('testapp.Shelf', on_delete=models.CASCADE)

def __str__(self):
return self.name

class Meta:
abstract = False
Loading

0 comments on commit cf5cd64

Please sign in to comment.