diff --git a/openwisp_utils/__init__.py b/openwisp_utils/__init__.py
new file mode 100644
index 00000000..3cf46865
--- /dev/null
+++ b/openwisp_utils/__init__.py
@@ -0,0 +1,14 @@
+VERSION = (0, 1, 0, 'alpha')
+__version__ = VERSION # alias
+
+
+def get_version():
+ version = '%s.%s.%s' % (VERSION[0], VERSION[1], VERSION[2])
+ if VERSION[3] != 'final':
+ first_letter = VERSION[3][0:1]
+ try:
+ suffix = VERSION[4]
+ except IndexError:
+ suffix = 0
+ version = '%s%s%s' % (version, first_letter, suffix)
+ return version
diff --git a/openwisp_utils/admin.py b/openwisp_utils/admin.py
new file mode 100644
index 00000000..30384762
--- /dev/null
+++ b/openwisp_utils/admin.py
@@ -0,0 +1,98 @@
+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 = []
+
+ 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)
+ if request.user.is_superuser:
+ return qs
+ organizations = request.user.organizations_pk
+ return qs.filter(organization__in=organizations)
+
+ 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 MultitenantObjectFilter(MultitenantOrgFilter):
+ """
+ Admin filter that shows only objects of
+ organizations the current user is associated with
+ """
+ multitenant_lookup = 'organization__in'
+
+
+class TimeReadonlyAdminMixin(object):
+ """
+ mixin that automatically flags
+ `created` and `modified` as readonly
+ """
+ def __init__(self, *args, **kwargs):
+ self.readonly_fields += ('created', 'modified',)
+ super(TimeReadonlyAdminMixin, self).__init__(*args, **kwargs)
diff --git a/openwisp_utils/admin_theme/__init__.py b/openwisp_utils/admin_theme/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/openwisp_utils/admin_theme/admin.py b/openwisp_utils/admin_theme/admin.py
new file mode 100644
index 00000000..875f8a27
--- /dev/null
+++ b/openwisp_utils/admin_theme/admin.py
@@ -0,0 +1,13 @@
+from django.contrib import admin
+from django.utils.translation import ugettext_lazy
+
+
+def openwisp_admin(site_url=None):
+ #
+ admin.site.site_title = ugettext_lazy('OpenWISP2 Admin')
+ # link to frontend
+ admin.site.site_url = site_url
+ # h1 text
+ admin.site.site_header = ugettext_lazy('OpenWISP')
+ # text at the top of the admin index page
+ admin.site.index_title = ugettext_lazy('Network administration')
diff --git a/openwisp_utils/admin_theme/static/admin/css/openwisp.css b/openwisp_utils/admin_theme/static/admin/css/openwisp.css
new file mode 100644
index 00000000..1d12c4b4
--- /dev/null
+++ b/openwisp_utils/admin_theme/static/admin/css/openwisp.css
@@ -0,0 +1,102 @@
+#header{
+ background-color: #464646;
+ color: #fff;
+ height: auto
+}
+
+#user-tools{ margin-top: 12px }
+
+#site-name a{
+ display: block;
+ width: 150px;
+ height: 70px;
+ background: url(../../../static/ui/openwisp/images/openwisp-logo.svg) no-repeat scroll 0 50% / 100%;
+ text-indent: -2000px;
+ overflow: hidden;
+}
+
+.login #branding{
+ float: none;
+ padding: 8px 0
+}
+.login #site-name a{ margin: 0 auto }
+
+.button.default,
+input[type=submit].default,
+.submit-row input.default,
+.object-tools a:focus,
+.object-tools a:hover{
+ background-color: #333;
+}
+
+.button.default:active, input[type=submit].default:active,
+.button.default:focus, input[type=submit].default:focus,
+.button.default:hover, input[type=submit].default:hover{
+ background-color: #000;
+}
+
+#branding h1, #branding h1 a:link, #branding h1 a:visited {
+ color: #fff;
+}
+
+div.breadcrumbs,
+.module h2, .module caption, .inline-group h2,
+#container .selector-chosen h2{
+ background-color: #df5d43;
+ color: #fff;
+}
+
+div.breadcrumbs{
+ color: #ffcbc0
+}
+
+div.breadcrumbs a:focus, div.breadcrumbs a:hover{
+ color: #ffe8e3
+}
+
+.button, input[type=submit], input[type=button], .submit-row input, a.button{
+ background-color: #666;
+}
+
+.button:active, input[type=submit]:active, input[type=button]:active,
+.button:focus, input[type=submit]:focus, input[type=button]:focus,
+.button:hover, input[type=submit]:hover, input[type=button]:hover {
+ background-color: #444;
+}
+
+#user-tools a:focus, #user-tools a:hover{
+ border-bottom-color: #ffcbc0;
+ color: #ffcbc0;
+}
+
+#container .object-tools a{
+ padding: 5px 15px;
+}
+
+#container .object-tools a.addlink{ padding-right: 28px }
+
+table thead th.sorted .sortoptions a.sortremove:focus:after,
+table thead th.sorted .sortoptions a.sortremove:hover:after,
+a:link, a:visited {
+ color: #777;
+}
+
+a:focus, a:hover {
+ color: #000;
+}
+
+#changelist-filter li.selected a{
+ color: #df5d43 !important
+}
+
+#netjsonconfig-hint, #netjsonconfig-hint a,
+#container .field-backend a{ color: #df5d43 }
+
+/* temporary frontend fix */
+a.button.secondaryAction{
+ background: transparent;
+ border: 0 none;
+ padding: 0;
+ margin: 15px 0;
+ display: block;
+}
diff --git a/openwisp_utils/admin_theme/static/ui/openwisp/images/favicon.png b/openwisp_utils/admin_theme/static/ui/openwisp/images/favicon.png
new file mode 100644
index 00000000..2f1b50f5
Binary files /dev/null and b/openwisp_utils/admin_theme/static/ui/openwisp/images/favicon.png differ
diff --git a/openwisp_utils/admin_theme/static/ui/openwisp/images/openwisp-logo.svg b/openwisp_utils/admin_theme/static/ui/openwisp/images/openwisp-logo.svg
new file mode 100644
index 00000000..f038e331
--- /dev/null
+++ b/openwisp_utils/admin_theme/static/ui/openwisp/images/openwisp-logo.svg
@@ -0,0 +1,139 @@
+
+
+
+
\ No newline at end of file
diff --git a/openwisp_utils/admin_theme/templates/admin/base_site.html b/openwisp_utils/admin_theme/templates/admin/base_site.html
new file mode 100644
index 00000000..e95ec4db
--- /dev/null
+++ b/openwisp_utils/admin_theme/templates/admin/base_site.html
@@ -0,0 +1,15 @@
+{% extends "admin/base.html" %}
+{% load static %}
+
+{% block title %}{{ title }} | {{ site_title }}{% endblock %}
+
+{% block extrastyle %}
+
+
+{% endblock %}
+
+{% block branding %}
+
+{% endblock %}
+
+{% block nav-global %}{% endblock %}
diff --git a/openwisp_utils/admin_theme/templates/base.html b/openwisp_utils/admin_theme/templates/base.html
new file mode 100644
index 00000000..7e2012df
--- /dev/null
+++ b/openwisp_utils/admin_theme/templates/base.html
@@ -0,0 +1,2 @@
+{% extends 'admin/base_site.html' %}
+{% block title %}OpenWISP2{% endblock %}
diff --git a/openwisp_utils/base.py b/openwisp_utils/base.py
new file mode 100644
index 00000000..5d74db67
--- /dev/null
+++ b/openwisp_utils/base.py
@@ -0,0 +1,18 @@
+import uuid
+
+from django.db import models
+from django.utils.translation import ugettext_lazy as _
+from model_utils.fields import AutoCreatedField, AutoLastModifiedField
+
+
+class TimeStampedEditableModel(models.Model):
+ """
+ An abstract base class model that provides self-updating
+ ``created`` and ``modified`` fields.
+ """
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ created = AutoCreatedField(_('created'), editable=True)
+ modified = AutoLastModifiedField(_('modified'), editable=True)
+
+ class Meta:
+ abstract = True
diff --git a/openwisp_utils/loaders.py b/openwisp_utils/loaders.py
new file mode 100644
index 00000000..6146b25b
--- /dev/null
+++ b/openwisp_utils/loaders.py
@@ -0,0 +1,20 @@
+import os
+
+from django.template.loaders.filesystem import Loader as FilesystemLoader
+
+from .settings import EXTENDED_APPS
+
+
+class DependencyLoader(FilesystemLoader):
+ """
+ A template loader that looks in templates dir of
+ django-apps listed in dependencies. Default values is []
+ """
+ dependencies = EXTENDED_APPS
+
+ def get_dirs(self):
+ dirs = []
+ for dependency in self.dependencies:
+ module = __import__(dependency)
+ dirs.append('{0}/templates'.format(os.path.dirname(module.__file__)))
+ return dirs
diff --git a/openwisp_utils/settings.py b/openwisp_utils/settings.py
new file mode 100644
index 00000000..ec26f2d7
--- /dev/null
+++ b/openwisp_utils/settings.py
@@ -0,0 +1,4 @@
+from django.conf import settings
+
+
+EXTENDED_APPS = getattr(settings, 'EXTENDED_APPS', [])
diff --git a/openwisp_utils/staticfiles.py b/openwisp_utils/staticfiles.py
new file mode 100644
index 00000000..10390910
--- /dev/null
+++ b/openwisp_utils/staticfiles.py
@@ -0,0 +1,28 @@
+import collections
+import os
+
+from django.contrib.staticfiles.finders import FileSystemFinder
+from django.core.files.storage import FileSystemStorage
+
+from .settings import EXTENDED_APPS
+
+
+class DependencyFinder(FileSystemFinder):
+ """
+ A static files finder that finds static files of
+ django-apps listed in dependencies
+ """
+ dependencies = EXTENDED_APPS
+
+ def __init__(self, app_names=None, *args, **kwargs):
+ self.locations = []
+ self.storages = collections.OrderedDict()
+ for dependency in self.dependencies:
+ module = __import__(dependency)
+ path = '{0}/static'.format(os.path.dirname(module.__file__))
+ self.locations.append(('', path))
+ for prefix, root in self.locations:
+ filesystem_storage = FileSystemStorage(location=root)
+ filesystem_storage.prefix = prefix
+ self.storages[root] = filesystem_storage
+ super(FileSystemFinder, self).__init__(*args, **kwargs)
diff --git a/openwisp_utils/tests/__init__.py b/openwisp_utils/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/openwisp_utils/tests/utils.py b/openwisp_utils/tests/utils.py
new file mode 100644
index 00000000..226ea419
--- /dev/null
+++ b/openwisp_utils/tests/utils.py
@@ -0,0 +1,87 @@
+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 openwisp_users.models import OrganizationUser
+
+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}'.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)
diff --git a/requirements-test.txt b/requirements-test.txt
index e49e1d15..2ed2aec5 100644
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -2,3 +2,5 @@ coverage
coveralls
isort
flake8
+# For testing Dependency loaders
+django_netjsonconfig
diff --git a/requirements.txt b/requirements.txt
index 5c1e7ec3..fe4a0276 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1 +1,2 @@
+django-model-utils
openwisp-users<0.2
diff --git a/runtests.py b/runtests.py
index 6870ba00..226754a3 100755
--- a/runtests.py
+++ b/runtests.py
@@ -11,5 +11,5 @@
from django.core.management import execute_from_command_line
args = sys.argv
args.insert(1, "test")
- args.insert(2, "openwisp_utils")
-execute_from_command_line(args)
+ args.insert(2, "test_project")
+ execute_from_command_line(args)
diff --git a/tests/settings.py b/tests/settings.py
index 84b54d36..c8e1210d 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -8,16 +8,29 @@
ALLOWED_HOSTS = []
-
INSTALLED_APPS = [
- 'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
+ 'openwisp_utils.admin_theme',
+ # all-auth
+ 'django.contrib.sites',
+ 'allauth',
+ 'allauth.account',
+ 'allauth.socialaccount',
+ 'django_extensions',
+ # openwisp2 modules
+ 'openwisp_users',
+ # test project
+ 'test_project',
+ # admin
+ 'django.contrib.admin',
]
+EXTENDED_APPS = ['django_netjsonconfig'] # Just for testing purposes
+
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
@@ -41,8 +54,12 @@
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
- 'APP_DIRS': True,
'OPTIONS': {
+ 'loaders': [
+ 'django.template.loaders.filesystem.Loader',
+ 'django.template.loaders.app_directories.Loader',
+ 'openwisp_utils.loaders.DependencyLoader',
+ ],
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
@@ -60,3 +77,14 @@
}
}
+AUTH_USER_MODEL = 'openwisp_users.User'
+SITE_ID = '1'
+EMAIL_PORT = '1025'
+LOGIN_REDIRECT_URL = 'admin:index'
+ACCOUNT_LOGOUT_REDIRECT_URL = LOGIN_REDIRECT_URL
+
+# local settings must be imported before test runner otherwise they'll be ignored
+try:
+ from local_settings import *
+except ImportError:
+ pass
diff --git a/tests/test_project/__init__.py b/tests/test_project/__init__.py
new file mode 100644
index 00000000..5ace57c9
--- /dev/null
+++ b/tests/test_project/__init__.py
@@ -0,0 +1 @@
+default_app_config = 'test_project.apps.TestAppConfig'
diff --git a/tests/test_project/admin.py b/tests/test_project/admin.py
new file mode 100644
index 00000000..7f193434
--- /dev/null
+++ b/tests/test_project/admin.py
@@ -0,0 +1,30 @@
+from django.contrib import admin
+
+from openwisp_utils.admin import (MultitenantAdminMixin,
+ MultitenantObjectFilter,
+ MultitenantOrgFilter,
+ TimeReadonlyAdminMixin)
+
+from .models import Book, Shelf
+
+
+class BaseAdmin(MultitenantAdminMixin, TimeReadonlyAdminMixin, 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', MultitenantObjectFilter)]
+ fields = ['name', 'author', 'organization', 'shelf', 'created', 'modified']
+ multitenant_shared_relations = ['shelf']
+
+
+admin.site.register(Shelf, ShelfAdmin)
+admin.site.register(Book, BookAdmin)
diff --git a/tests/test_project/apps.py b/tests/test_project/apps.py
new file mode 100644
index 00000000..faff0087
--- /dev/null
+++ b/tests/test_project/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class TestAppConfig(AppConfig):
+ name = 'test_project'
+ label = 'test_project'
diff --git a/tests/test_project/migrations/0001_initial.py b/tests/test_project/migrations/0001_initial.py
new file mode 100644
index 00000000..f1c97aef
--- /dev/null
+++ b/tests/test_project/migrations/0001_initial.py
@@ -0,0 +1,53 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.2 on 2017-06-23 14:45
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import model_utils.fields
+import uuid
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ('openwisp_users', '0001_initial'),
+ ]
+
+ 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,
+ },
+ ),
+ 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,
+ },
+ ),
+ migrations.AddField(
+ model_name='book',
+ name='shelf',
+ field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='test_project.Shelf'),
+ ),
+ ]
diff --git a/tests/test_project/migrations/__init__.py b/tests/test_project/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/test_project/models.py b/tests/test_project/models.py
new file mode 100644
index 00000000..8b0415c7
--- /dev/null
+++ b/tests/test_project/models.py
@@ -0,0 +1,27 @@
+from django.db import models
+from django.utils.translation import ugettext_lazy as _
+
+from openwisp_users.mixins import OrgMixin
+from openwisp_utils.base import TimeStampedEditableModel
+
+
+class Shelf(OrgMixin, TimeStampedEditableModel):
+ name = models.CharField(_('name'), max_length=64)
+
+ def __str__(self):
+ return self.name
+
+ class Meta:
+ abstract = False
+
+
+class Book(OrgMixin, TimeStampedEditableModel):
+ name = models.CharField(_('name'), max_length=64)
+ author = models.CharField(_('author'), max_length=64)
+ shelf = models.ForeignKey('test_project.Shelf')
+
+ def __str__(self):
+ return self.name
+
+ class Meta:
+ abstract = False
diff --git a/tests/test_project/tests/__init__.py b/tests/test_project/tests/__init__.py
new file mode 100644
index 00000000..87258ebb
--- /dev/null
+++ b/tests/test_project/tests/__init__.py
@@ -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
diff --git a/tests/test_project/tests/test_admin.py b/tests/test_project/tests/test_admin.py
new file mode 100644
index 00000000..d7dc4114
--- /dev/null
+++ b/tests/test_project/tests/test_admin.py
@@ -0,0 +1,90 @@
+from django.test import TestCase
+from django.urls import reverse
+
+from openwisp_users.tests.utils import TestOrganizationMixin
+from openwisp_utils.tests.utils import TestMultitenantAdminMixin
+
+from . import CreateMixin
+from ..models import Book, Shelf
+
+
+class TestAdmin(CreateMixin, TestMultitenantAdminMixin,
+ TestOrganizationMixin, TestCase):
+ book_model = Book
+ shelf_model = Shelf
+ operator_permission_filter = [
+ {'codename__endswith': 'book'},
+ {'codename__endswith': 'shelf'},
+ ]
+
+ def _create_multitenancy_test_env(self):
+ org1 = self._create_org(name='org1')
+ org2 = self._create_org(name='org2')
+ inactive = self._create_org(name='inactive-org', is_active=False)
+ operator = self._create_operator(organizations=[org1, inactive])
+ s1 = self._create_shelf(name='shell1', organization=org1)
+ s2 = self._create_shelf(name='shell2', organization=org2)
+ s3 = self._create_shelf(name='shell3', organization=inactive)
+ b1 = self._create_book(name='book1', organization=org1, shelf=s1)
+ b2 = self._create_book(name='book2', organization=org2, shelf=s2)
+ b3 = self._create_book(name='book3', organization=inactive, shelf=s3)
+ data = dict(s1=s1, s2=s2, s3_inactive=s3,
+ b1=b1, b2=b2, b3_inactive=b3,
+ org1=org1, org2=org2,
+ inactive=inactive,
+ operator=operator)
+ return data
+
+ def test_shelf_queryset(self):
+ data = self._create_multitenancy_test_env()
+ self._test_multitenant_admin(
+ url=reverse('admin:test_project_shelf_changelist'),
+ visible=[data['s1'].name, data['org1'].name],
+ hidden=[data['s2'].name, data['org2'].name,
+ data['s3_inactive'].name]
+ )
+
+ def test_shelf_organization_fk_queryset(self):
+ data = self._create_multitenancy_test_env()
+ self._test_multitenant_admin(
+ url=reverse('admin:test_project_shelf_add'),
+ visible=[data['org1'].name],
+ hidden=[data['org2'].name, data['inactive']],
+ select_widget=True
+ )
+
+ def test_book_queryset(self):
+ data = self._create_multitenancy_test_env()
+ self._test_multitenant_admin(
+ url=reverse('admin:test_project_book_changelist'),
+ visible=[data['b1'].name, data['org1'].name],
+ hidden=[data['b2'].name, data['org2'].name,
+ data['b3_inactive'].name]
+ )
+
+ def test_book_organization_fk_queryset(self):
+ data = self._create_multitenancy_test_env()
+ self._test_multitenant_admin(
+ url=reverse('admin:test_project_book_add'),
+ visible=[data['org1'].name],
+ hidden=[data['org2'].name, data['inactive']],
+ select_widget=True
+ )
+
+ def test_book_shelf_filter(self):
+ data = self._create_multitenancy_test_env()
+ s_special = self._create_shelf(name='special', organization=data['org1'])
+ self._test_multitenant_admin(
+ url=reverse('admin:test_project_book_changelist'),
+ visible=[data['s1'].name, s_special.name],
+ hidden=[data['s2'].name, data['s3_inactive'].name]
+ )
+
+ def test_book_shelf_fk_queryset(self):
+ data = self._create_multitenancy_test_env()
+ self._test_multitenant_admin(
+ url=reverse('admin:test_project_book_add'),
+ visible=[data['s1'].name],
+ hidden=[data['s2'].name, data['s3_inactive'].name],
+ select_widget=True
+ )
diff --git a/tests/test_project/tests/test_staticfinders.py b/tests/test_project/tests/test_staticfinders.py
new file mode 100644
index 00000000..983fc9f2
--- /dev/null
+++ b/tests/test_project/tests/test_staticfinders.py
@@ -0,0 +1,10 @@
+import unittest
+
+from openwisp_utils.staticfiles import DependencyFinder
+
+
+class TestStaticFinders(unittest.TestCase):
+ def test_dependency_finder(self):
+ finder = DependencyFinder()
+ self.assertIsInstance(finder.locations, list)
+ self.assertIn('django_netjsonconfig', finder.locations[0][1])
diff --git a/tests/urls.py b/tests/urls.py
index a9bad669..b230065e 100644
--- a/tests/urls.py
+++ b/tests/urls.py
@@ -1,21 +1,10 @@
-"""testapp URL Configuration
+from django.conf.urls import include, url
-The `urlpatterns` list routes URLs to views. For more information please see:
- https://docs.djangoproject.com/en/1.11/topics/http/urls/
-Examples:
-Function views
- 1. Add an import: from my_app import views
- 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home')
-Class-based views
- 1. Add an import: from other_app.views import Home
- 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home')
-Including another URLconf
- 1. Import the include() function: from django.conf.urls import url, include
- 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls'))
-"""
-from django.conf.urls import url
-from django.contrib import admin
+from openwisp_utils.admin_theme.admin import admin, openwisp_admin
+
+openwisp_admin()
urlpatterns = [
- url(r'^admin/', admin.site.urls),
+ url(r'^accounts/', include('openwisp_users.accounts.urls')),
+ url(r'^admin/', include(admin.site.urls)),
]