diff --git a/dev-requirements.txt b/dev-requirements.txt index 1c697eea..00e40306 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -17,3 +17,4 @@ ipdb # ipython breakpoints django-extensions # Django Sugar for development faker # less fake fake data coveralls # Publish code coverage to coveralls +beautifulsoup4 # Structure HTML documents into navigable things for test assertions diff --git a/docs/configuration.md b/docs/configuration.md index f374c0aa..494bf362 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -64,7 +64,7 @@ JAZZMIN_SETTINGS = { 'name': 'Make Messages', 'url': 'make_messages', 'icon': 'fa-comments', - 'permissions': ['polls.view_polls'] + 'permissions': ['polls.view_poll'] }] }, @@ -115,7 +115,7 @@ Example: 'icon': 'fa-comments', # a list of permissions the user must have to see this link (optional) - 'permissions': ['polls.view_polls'] + 'permissions': ['polls.view_poll'] }] }, diff --git a/jazzmin/__init__.py b/jazzmin/__init__.py index 346ff7e4..96b2b159 100644 --- a/jazzmin/__init__.py +++ b/jazzmin/__init__.py @@ -1,2 +1,2 @@ -version = '2.0.1' +version = '2.1.1' default_app_config = 'jazzmin.apps.JazzminConfig' diff --git a/jazzmin/settings.py b/jazzmin/settings.py index caa2327c..b77f7de8 100644 --- a/jazzmin/settings.py +++ b/jazzmin/settings.py @@ -141,13 +141,19 @@ def get_settings(): if 'url' in link: link['url'] = get_custom_url(link['url']) elif 'model' in link: - link['name'] = get_model_meta(link['model']).verbose_name_plural.title() + model_meta = get_model_meta(link['model']) + link['name'] = model_meta.verbose_name_plural.title() if model_meta else link['model'] link['url'] = get_admin_url(link['model']) elif 'app' in link: link['name'] = link['app'].title() link['app_children'] = get_app_admin_urls(link['app']) + if type(jazzmin_settings['hide_apps']) == str: + jazzmin_settings['hide_apps'] = [jazzmin_settings['hide_apps']] jazzmin_settings['hide_apps'] = [x.lower() for x in jazzmin_settings['hide_apps']] + + if type(jazzmin_settings['hide_models']) == str: + jazzmin_settings['hide_models'] = [jazzmin_settings['hide_models']] jazzmin_settings['hide_models'] = [x.lower() for x in jazzmin_settings['hide_models']] return jazzmin_settings diff --git a/jazzmin/templatetags/jazzmin.py b/jazzmin/templatetags/jazzmin.py index 11854101..0a465dfa 100644 --- a/jazzmin/templatetags/jazzmin.py +++ b/jazzmin/templatetags/jazzmin.py @@ -13,13 +13,11 @@ from .. import version from ..settings import get_settings, get_ui_tweaks -from ..utils import order_with_respect_to, get_filter_id, get_custom_url, get_admin_url, get_model_permissions +from ..utils import order_with_respect_to, get_filter_id, get_custom_url, get_admin_url, get_view_permissions User = get_user_model() register = Library() logger = logging.getLogger(__name__) -OPTIONS = get_settings() -UI_TWEAKS = get_ui_tweaks() @register.simple_tag(takes_context=True) @@ -27,17 +25,19 @@ def get_side_menu(context): """ Get the list of apps and models to render out in the side menu and on the dashboard page """ + user = context.get('user') if not user: return [] - model_permissions = get_model_permissions(user) + model_permissions = get_view_permissions(user) + options = get_settings() menu = [] available_apps = copy.deepcopy(context.get('available_apps', [])) for app in available_apps: app_label = app['app_label'].lower() - if app_label in OPTIONS['hide_apps']: + if app_label in options['hide_apps']: continue allowed_models = [] @@ -45,13 +45,13 @@ def get_side_menu(context): model_str = '{app_label}.{model}'.format(app_label=app_label, model=model["object_name"]).lower() if model_str not in model_permissions: continue - if model_str in OPTIONS.get('hide_models', []): + if model_str in options.get('hide_models', []): continue - model['icon'] = OPTIONS.get('icons', {}).get(model_str) + model['icon'] = options.get('icons', {}).get(model_str) allowed_models.append(model) - for custom_link in OPTIONS.get('custom_links', {}).get(app_label, []): + for custom_link in options.get('custom_links', {}).get(app_label, []): perm_matches = [] for perm in custom_link.get('permissions', []): @@ -71,8 +71,8 @@ def get_side_menu(context): app['models'] = allowed_models menu.append(app) - if OPTIONS.get('order_with_respect_to'): - menu = order_with_respect_to(menu, OPTIONS['order_with_respect_to']) + if options.get('order_with_respect_to'): + menu = order_with_respect_to(menu, options['order_with_respect_to']) return menu @@ -82,10 +82,11 @@ def get_top_menu(user): if not user: return [] - model_permissions = get_model_permissions(user) + model_permissions = get_view_permissions(user) + options = get_settings() menu = [] - for item in get_settings().get('topmenu_links', []): + for item in options.get('topmenu_links', []): perm_matches = [] for perm in item.get('permissions', []): @@ -112,21 +113,15 @@ def get_jazzmin_settings(): """ Return Jazzmin settings """ - return OPTIONS + return get_settings() @register.simple_tag def get_jazzmin_ui_tweaks(): """ Return Jazzmin ui tweaks - - Find all the places references in ui-builder.js - - and get template variables in there - - ensure we have sane defaults """ - return UI_TWEAKS + return get_ui_tweaks() @register.simple_tag @@ -143,11 +138,12 @@ def get_user_avatar(user): For the given user, try to get the avatar image """ no_avatar = static("adminlte/img/user2-160x160.jpg") + options = get_settings() - if not OPTIONS.get('user_avatar'): + if not options.get('user_avatar'): return no_avatar - avatar_field = getattr(user, OPTIONS['user_avatar'], None) + avatar_field = getattr(user, options['user_avatar'], None) if avatar_field: return avatar_field.url diff --git a/jazzmin/utils.py b/jazzmin/utils.py index 27147e7e..0849439e 100644 --- a/jazzmin/utils.py +++ b/jazzmin/utils.py @@ -26,7 +26,7 @@ def order_with_respect_to(first, reference): def get_admin_url(instance, **kwargs): """ - Return the admin URL for the given instance, model class or / string + Return the admin URL for the given instance, model class or . string """ url = '#' @@ -52,7 +52,7 @@ def get_admin_url(instance, **kwargs): app_label=app_label, model_name=model_name ), args=(instance.pk,)) - except NoReverseMatch: + except (NoReverseMatch, ValueError): logger.error('Couldnt reverse url from {instance}'.format(instance=instance)) if kwargs: @@ -88,9 +88,12 @@ def get_model_meta(model_str): """ Get the plural name """ - app, model = model_str.split('.') - Model = apps.get_registered_model(app, model) - return Model._meta + try: + app, model = model_str.split('.') + Model = apps.get_registered_model(app, model) + return Model._meta + except (ValueError, LookupError): + return None def get_app_admin_urls(app): @@ -118,16 +121,8 @@ def get_app_admin_urls(app): return models -def get_model_permissions(user): +def get_view_permissions(user): """ - Create model permissions from the users permissions, - - e.g having any of auth.view_user, auth.change_user, auth.delete_user will grant you auth.user + Get model names based on a users view permissions """ - permissions = set() - for permission in user.get_all_permissions(): - app_label, model = permission.split('.') - model = model.split('_')[1] - permissions.add('{app_label}.{model}'.format(app_label=app_label, model=model)) - - return permissions + return {x.replace('view_', '') for x in user.get_all_permissions() if 'view' in x} diff --git a/tests/test_admin_permissions.py b/tests/test_admin_permissions.py index 0bf60367..90d99577 100644 --- a/tests/test_admin_permissions.py +++ b/tests/test_admin_permissions.py @@ -1,41 +1,64 @@ import pytest +from django.urls import reverse +from tests.test_app.polls.models import Poll +from tests.utils import user_with_permissions, parse_sidemenu -@pytest.mark.skip -def test_no_delete_permission(): + +@pytest.mark.django_db +def test_no_delete_permission(client): """ When our user has no delete permission, they dont see things they are not supposed to """ - pass + user = user_with_permissions('polls.view_poll') + poll = Poll.objects.create(owner=user, text='question') + + url = reverse('admin:polls_poll_change', args=(poll.pk,)) + delete_url = reverse('admin:polls_poll_delete', args=(poll.pk,)) + client.force_login(user) + + response = client.get(url) + assert delete_url not in response.content.decode() -@pytest.mark.skip -def test_no_add_permission(): +@pytest.mark.django_db +def test_no_add_permission(client): """ When our user has no add permission, they dont see things they are not supposed to """ - pass + user = user_with_permissions('polls.view_poll') + url = reverse('admin:polls_poll_changelist') + add_url = reverse('admin:polls_poll_add') + client.force_login(user) + response = client.get(url) -@pytest.mark.skip -def test_no_change_permission(): - """ - When our user has no change permission, they dont see things they are not supposed to - """ - pass + assert add_url not in response.content.decode() -@pytest.mark.skip -def test_no_view_permission(): +@pytest.mark.django_db +def test_no_view_permission(client): """ When our user has no view permission, they dont see things they are not supposed to """ - pass + user = user_with_permissions('polls.change_poll') + url = reverse('admin:index') + client.force_login(user) -@pytest.mark.skip -def test_no_permission(): + response = client.get(url) + assert parse_sidemenu(response) == {'Global': ['/admin/']} + + +@pytest.mark.django_db +def test_no_permission(client): """ When our user has no permissions at all, they see no menu or dashboard """ - pass + user = user_with_permissions() + + url = reverse('admin:index') + client.force_login(user) + + response = client.get(url) + assert parse_sidemenu(response) == {'Global': ['/admin/']} diff --git a/tests/test_app/settings.py b/tests/test_app/settings.py index f19e279b..ef923f64 100644 --- a/tests/test_app/settings.py +++ b/tests/test_app/settings.py @@ -112,7 +112,7 @@ 'custom_links': { 'polls': [{ 'name': 'Make Messages', 'url': 'make_messages', 'icon': 'fa-comments', - 'permissions': ['polls.view_polls'] + 'permissions': ['polls.view_poll'] }] }, diff --git a/tests/test_jazzmin.py b/tests/test_jazzmin.py index ce425f30..d7d72a1b 100644 --- a/tests/test_jazzmin.py +++ b/tests/test_jazzmin.py @@ -1,49 +1,99 @@ import pytest +from bs4 import BeautifulSoup from django.urls import reverse +from tests.utils import parse_sidemenu, user_with_permissions, parse_topmenu + @pytest.mark.django_db -def test_side_menu(admin_client): +def test_side_menu(admin_client, settings): """ All menu tweaking settings work as expected """ url = reverse('admin:index') response = admin_client.get(url) - app_list = response.context['app_list'] - # TODO: override settings, and confirm that our app_list is built the way we want it to - # given that app_list is what builds out the menu and the dashboard - assert app_list is not None + assert parse_sidemenu(response) == { + 'Global': ['/admin/'], + 'Polls': ['/admin/polls/choice/', '/admin/polls/poll/', '/admin/polls/vote/', '/make_messages/'], + 'Administration': ['/admin/admin/logentry/'], + 'Authentication and Authorization': ['/admin/auth/group/', '/admin/auth/user/'] + } + + settings.JAZZMIN_SETTINGS['hide_models'] = ['auth.user'] + response = admin_client.get(url) + assert parse_sidemenu(response) == { + 'Global': ['/admin/'], + 'Polls': ['/admin/polls/choice/', '/admin/polls/poll/', '/admin/polls/vote/', '/make_messages/'], + 'Administration': ['/admin/admin/logentry/'], + 'Authentication and Authorization': ['/admin/auth/group/'] + } -@pytest.mark.skip -def test_update_site_logo(admin_client): + +@pytest.mark.django_db +def test_update_site_logo(admin_client, settings): """ We can add a site logo, and it renders out """ - pass + url = reverse('admin:index') + settings.JAZZMIN_SETTINGS['site_logo'] = 'polls/img/logo.png' + response = admin_client.get(url) + soup = BeautifulSoup(response.content, 'html.parser') -@pytest.mark.skip -def test_ui_customisations(admin_client): - """ - All UI settings work as expected - """ - pass + assert soup.find('a', class_="brand-link").find('img')['src'] == '/static/polls/img/logo.png' -@pytest.mark.skip -def test_permissions_on_custom_links(admin_client): +@pytest.mark.django_db +def test_permissions_on_custom_links(client, settings): """ - We honour permissions for the rendering of custom links + we honour permissions for the rendering of custom links """ - pass + user = user_with_permissions() + user2 = user_with_permissions('polls.view_poll') + + url = reverse('admin:index') + + settings.JAZZMIN_SETTINGS['custom_links'] = { + 'polls': [{ + 'name': 'Make Messages', 'url': 'make_messages', + 'icon': 'fa-comments', 'permissions': ['polls.view_poll'] + }] + } + + client.force_login(user) + response = client.get(url) + assert parse_sidemenu(response) == {'Global': ['/admin/']} + + client.force_login(user2) + response = client.get(url) + assert parse_sidemenu(response) == {'Global': ['/admin/'], 'Polls': ['/admin/polls/poll/', '/make_messages/']} -@pytest.mark.skip -def test_top_menu(admin_client): +def test_top_menu(admin_client, settings): """ Top menu renders out as expected """ - pass + url = reverse('admin:index') + + settings.JAZZMIN_SETTINGS['topmenu_links'] = [ + {'name': 'Home', 'url': 'admin:index', 'permissions': ['auth.view_user']}, + {'name': 'Support', 'url': 'https://github.com/farridav/django-jazzmin/issues', 'new_window': True}, + {'model': 'auth.User'}, + {'app': 'polls'}, + ] + + response = admin_client.get(url) + + assert parse_topmenu(response) == [ + {'name': 'Home', 'link': '/admin/'}, + {'name': 'Support', 'link': 'https://github.com/farridav/django-jazzmin/issues'}, + {'name': 'Users', 'link': '/admin/auth/user/'}, + {'name': 'Polls', 'link': '#', 'children': [ + {'name': 'Polls', 'link': '/admin/polls/poll/'}, + {'name': 'Choices', 'link': '/admin/polls/choice/'}, + {'name': 'Votes', 'link': '/admin/polls/vote/'}, + ]} + ] diff --git a/tests/test_utils.py b/tests/test_utils.py index dcbb61cb..b118ff38 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,49 +1,84 @@ import pytest +from django.urls import reverse + +from jazzmin.utils import ( + order_with_respect_to, get_admin_url, get_custom_url, get_model_meta, get_app_admin_urls, get_view_permissions +) +from tests.test_app.polls.models import Poll +from tests.utils import user_with_permissions -@pytest.mark.skip def test_order_with_respect_to(): """ - When we ask fore ordering, we get it as expected + When we ask for ordering, we get it as expected """ - pass + + def apps(*args): + return [{'app_label': x} for x in args] + + original_list = apps('b', 'c', 'a') + + assert order_with_respect_to(original_list, ['c', 'b']) == apps('c', 'b', 'a') + assert order_with_respect_to(original_list, ['nothing']) == original_list + assert order_with_respect_to(original_list, ['a'])[0]['app_label'] == 'a' -@pytest.mark.skip -def test_get_admin_url(): +@pytest.mark.django_db +def test_get_admin_url(admin_user): """ We can get admin urls for Model classes, instances, or app.model strings """ - pass + poll = Poll.objects.create(owner=admin_user, text='question') + + assert get_admin_url(poll) == reverse('admin:polls_poll_change', args=(poll.pk,)) + assert get_admin_url(Poll) == reverse('admin:polls_poll_changelist') + assert get_admin_url(Poll, q='test') == reverse('admin:polls_poll_changelist') + '?q=test' + assert get_admin_url('polls.Poll') == reverse('admin:polls_poll_changelist') + assert get_admin_url('cheese:bad_pattern') == '#' + assert get_admin_url('fake_app.fake_model') == '#' + assert get_admin_url(1) == '#' -@pytest.mark.skip def test_get_custom_url(): """ We handle urls that can be reversed, and that cant, and external links """ - pass + assert get_custom_url('http://somedomain.com') == 'http://somedomain.com' + assert get_custom_url('/relative/path') == '/relative/path' + assert get_custom_url('admin:polls_poll_changelist') == '/admin/polls/poll/' -@pytest.mark.skip -def test_get_model_meta(): +@pytest.mark.django_db +def test_get_model_meta(admin_user): """ We can fetch model meta """ - pass + assert get_model_meta('auth.user') == admin_user._meta + assert get_model_meta('polls.poll') == Poll._meta + assert get_model_meta('nothing') is None + assert get_model_meta('nothing.nothing') is None -@pytest.mark.skip +@pytest.mark.django_db def test_get_app_admin_urls(): """ We can get all the admin urls for an app """ - pass + assert get_app_admin_urls('polls') == [ + {'model': 'polls.poll', 'name': 'Polls', 'url': reverse('admin:polls_poll_changelist')}, + {'model': 'polls.choice', 'name': 'Choices', 'url': reverse('admin:polls_choice_changelist')}, + {'model': 'polls.vote', 'name': 'Votes', 'url': reverse('admin:polls_vote_changelist')} + ] + + assert get_app_admin_urls('nothing') == [] -@pytest.mark.skip +@pytest.mark.django_db def test_get_model_permissions(): """ We can create the correct model permissions from user permissions """ - pass + + user = user_with_permissions('polls.view_poll', 'polls.view_choice') + + assert get_view_permissions(user) == {'polls.poll', 'polls.choice'} diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 00000000..0318b6d5 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,75 @@ +from collections import defaultdict + +from bs4 import BeautifulSoup, Tag +from django.contrib.auth.models import User, Permission +from faker import Faker + +fake = Faker() + + +def user_with_permissions(*permissions): + """ + Create a user with the given permissions, e.g user_with_permissions('polls.view_poll', 'auth.change_user') + """ + available_permissions = [ + '{}.{}'.format(x[0], x[1]) for x in Permission.objects.values_list('content_type__app_label', 'codename') + ] + + first_name = fake.first_name() + last_name = fake.last_name() + user = User.objects.create_user( + first_name=first_name, last_name=last_name, + email=first_name + "." + last_name + "@fakermail.com", + username=first_name + last_name, password="password", is_staff=True + ) + + for permission in permissions: + assert permission in available_permissions, '{} not in {}'.format(permission, available_permissions) + + app, perm = permission.split('.') + perm_obj = Permission.objects.get(content_type__app_label=app, codename=perm) + user.user_permissions.add(perm_obj) + + return user + + +def parse_sidemenu(response): + """ + Convert the side menu to a dict keyed on app name, containing a list of links + """ + menu = defaultdict(list) + current_app = 'Global' + soup = BeautifulSoup(response.content, 'html.parser') + + for li in soup.find(id='jazzy-sidebar').find('ul').find_all('li'): + if 'nav-header' in li['class']: + current_app = li.contents[0] + elif 'nav-item' in li['class']: + href = li.find('a')['href'] + menu[current_app].append(href) + + return menu + + +def parse_topmenu(response): + """ + Convert the top menu to a list of dicts representing menus, items with submenus will have key 'children' + """ + menu = [] + soup = BeautifulSoup(response.content, 'html.parser') + + for li in soup.find(id='jazzy-navbar').find('ul').find_all('li'): + anchor = li.find('a') + + # Skip brand link and menu button + if type(anchor.contents[0]) == Tag: + continue + + item = {'name': anchor.contents[0].strip(), 'link': anchor['href']} + dropdown = li.find('div', class_='dropdown-menu') + if dropdown: + item['children'] = [{'name': a.contents[0], 'link': a['href']} for a in dropdown.find_all('a')] + + menu.append(item) + + return menu