Custom icons for third party auth login buttons
- Icon images can be uploaded from the django admin - Test coverage improved
This commit is contained in:
@@ -6,6 +6,7 @@ from django.forms import models
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin import ListFilter
|
||||
from django.core.cache import caches, InvalidCacheBackendError
|
||||
from django.core.files.base import File
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -178,7 +179,16 @@ class KeyedConfigurationModelAdmin(ConfigurationModelAdmin):
|
||||
get = request.GET.copy()
|
||||
source_id = int(get.pop('source')[0])
|
||||
source = get_object_or_404(self.model, pk=source_id)
|
||||
get.update(models.model_to_dict(source))
|
||||
source_dict = models.model_to_dict(source)
|
||||
for field_name, field_value in source_dict.items():
|
||||
# read files into request.FILES, if:
|
||||
# * user hasn't ticked the "clear" checkbox
|
||||
# * user hasn't uploaded a new file
|
||||
if field_value and isinstance(field_value, File):
|
||||
clear_checkbox_name = '{0}-clear'.format(field_name)
|
||||
if request.POST.get(clear_checkbox_name) != 'on':
|
||||
request.FILES.setdefault(field_name, field_value)
|
||||
get[field_name] = field_value
|
||||
request.GET = get
|
||||
# Call our grandparent's add_view, skipping the parent code
|
||||
# because the parent code has a different way to prepopulate new configuration entries
|
||||
|
||||
@@ -37,7 +37,8 @@ class SAMLProviderConfigAdmin(KeyedConfigurationModelAdmin):
|
||||
""" Don't show every single field in the admin change list """
|
||||
return (
|
||||
'name', 'enabled', 'backend_name', 'entity_id', 'metadata_source',
|
||||
'has_data', 'icon_class', 'change_date', 'changed_by', 'edit_link'
|
||||
'has_data', 'icon_class', 'icon_image', 'change_date',
|
||||
'changed_by', 'edit_link'
|
||||
)
|
||||
|
||||
def has_data(self, inst):
|
||||
@@ -104,6 +105,7 @@ class LTIProviderConfigAdmin(KeyedConfigurationModelAdmin):
|
||||
|
||||
exclude = (
|
||||
'icon_class',
|
||||
'icon_image',
|
||||
'secondary',
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('third_party_auth', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='ltiproviderconfig',
|
||||
name='icon_image',
|
||||
field=models.FileField(help_text=b'If there is no Font Awesome icon available for this provider, upload a custom image. SVG images are recommended as they can scale to any size.', upload_to=b'', blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='oauth2providerconfig',
|
||||
name='icon_image',
|
||||
field=models.FileField(help_text=b'If there is no Font Awesome icon available for this provider, upload a custom image. SVG images are recommended as they can scale to any size.', upload_to=b'', blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='samlproviderconfig',
|
||||
name='icon_image',
|
||||
field=models.FileField(help_text=b'If there is no Font Awesome icon available for this provider, upload a custom image. SVG images are recommended as they can scale to any size.', upload_to=b'', blank=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ltiproviderconfig',
|
||||
name='icon_class',
|
||||
field=models.CharField(default=b'fa-sign-in', help_text=b'The Font Awesome (or custom) icon class to use on the login button for this provider. Examples: fa-google-plus, fa-facebook, fa-linkedin, fa-sign-in, fa-university', max_length=50, blank=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='oauth2providerconfig',
|
||||
name='icon_class',
|
||||
field=models.CharField(default=b'fa-sign-in', help_text=b'The Font Awesome (or custom) icon class to use on the login button for this provider. Examples: fa-google-plus, fa-facebook, fa-linkedin, fa-sign-in, fa-university', max_length=50, blank=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='samlproviderconfig',
|
||||
name='icon_class',
|
||||
field=models.CharField(default=b'fa-sign-in', help_text=b'The Font Awesome (or custom) icon class to use on the login button for this provider. Examples: fa-google-plus, fa-facebook, fa-linkedin, fa-sign-in, fa-university', max_length=50, blank=True),
|
||||
),
|
||||
]
|
||||
@@ -70,12 +70,25 @@ class ProviderConfig(ConfigurationModel):
|
||||
Abstract Base Class for configuring a third_party_auth provider
|
||||
"""
|
||||
icon_class = models.CharField(
|
||||
max_length=50, default='fa-sign-in',
|
||||
max_length=50,
|
||||
blank=True,
|
||||
default='fa-sign-in',
|
||||
help_text=(
|
||||
'The Font Awesome (or custom) icon class to use on the login button for this provider. '
|
||||
'Examples: fa-google-plus, fa-facebook, fa-linkedin, fa-sign-in, fa-university'
|
||||
),
|
||||
)
|
||||
# We use a FileField instead of an ImageField here because ImageField
|
||||
# doesn't support SVG. This means we don't get any image validation, but
|
||||
# that should be fine because only trusted users should be uploading these
|
||||
# anyway.
|
||||
icon_image = models.FileField(
|
||||
blank=True,
|
||||
help_text=(
|
||||
'If there is no Font Awesome icon available for this provider, upload a custom image. '
|
||||
'SVG images are recommended as they can scale to any size.'
|
||||
),
|
||||
)
|
||||
name = models.CharField(max_length=50, blank=False, help_text="Name of this provider (shown to users)")
|
||||
secondary = models.BooleanField(
|
||||
default=False,
|
||||
@@ -109,6 +122,12 @@ class ProviderConfig(ConfigurationModel):
|
||||
app_label = "third_party_auth"
|
||||
abstract = True
|
||||
|
||||
def clean(self):
|
||||
""" Ensure that either `icon_class` or `icon_image` is set """
|
||||
super(ProviderConfig, self).clean()
|
||||
if bool(self.icon_class) == bool(self.icon_image):
|
||||
raise ValidationError('Either an icon class or an icon image must be given (but not both)')
|
||||
|
||||
@property
|
||||
def provider_id(self):
|
||||
""" Unique string key identifying this provider. Must be URL and css class friendly. """
|
||||
@@ -500,9 +519,15 @@ class LTIProviderConfig(ProviderConfig):
|
||||
"""
|
||||
prefix = 'lti'
|
||||
backend_name = 'lti'
|
||||
icon_class = None # This provider is not visible to users
|
||||
secondary = False # This provider is not visible to users
|
||||
accepts_logins = False # LTI login cannot be initiated by the tool provider
|
||||
|
||||
# This provider is not visible to users
|
||||
icon_class = None
|
||||
icon_image = None
|
||||
secondary = False
|
||||
|
||||
# LTI login cannot be initiated by the tool provider
|
||||
accepts_logins = False
|
||||
|
||||
KEY_FIELDS = ('lti_consumer_key', )
|
||||
|
||||
lti_consumer_key = models.CharField(
|
||||
|
||||
85
common/djangoapps/third_party_auth/tests/test_admin.py
Normal file
85
common/djangoapps/third_party_auth/tests/test_admin.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Tests third_party_auth admin views
|
||||
"""
|
||||
import unittest
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.admin.sites import AdminSite
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.forms import models
|
||||
|
||||
from student.tests.factories import UserFactory
|
||||
from third_party_auth.admin import OAuth2ProviderConfigAdmin
|
||||
from third_party_auth.models import OAuth2ProviderConfig
|
||||
from third_party_auth.tests import testutil
|
||||
|
||||
|
||||
# This is necessary because cms does not implement third party auth
|
||||
@unittest.skipUnless(settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'), 'third party auth not enabled')
|
||||
class Oauth2ProviderConfigAdminTest(testutil.TestCase):
|
||||
"""
|
||||
Tests for oauth2 provider config admin
|
||||
"""
|
||||
def test_oauth2_provider_edit_icon_image(self):
|
||||
"""
|
||||
Test that we can update an OAuth provider's icon image from the admin
|
||||
form.
|
||||
|
||||
OAuth providers are updated using KeyedConfigurationModelAdmin, which
|
||||
updates models by adding a new instance that replaces the old one,
|
||||
instead of editing the old instance directly.
|
||||
|
||||
Updating the icon image is tricky here because
|
||||
KeyedConfigurationModelAdmin copies data over from the previous
|
||||
version by injecting its attributes into request.GET, but the icon
|
||||
ends up in request.FILES. We need to ensure that the value is
|
||||
prepopulated correctly, and that we can clear and update the image.
|
||||
"""
|
||||
# Login as a super user
|
||||
user = UserFactory.create(is_staff=True, is_superuser=True)
|
||||
user.save()
|
||||
self.client.login(username=user.username, password='test')
|
||||
|
||||
# Get baseline provider count
|
||||
providers = OAuth2ProviderConfig.objects.all()
|
||||
pcount = len(providers)
|
||||
|
||||
# Create a provider
|
||||
provider1 = self.configure_dummy_provider(
|
||||
enabled=True,
|
||||
icon_class='',
|
||||
icon_image=SimpleUploadedFile('icon.svg', '<svg><rect width="50" height="100"/></svg>'),
|
||||
)
|
||||
|
||||
# Get the provider instance with active flag
|
||||
providers = OAuth2ProviderConfig.objects.all()
|
||||
self.assertEquals(len(providers), 1)
|
||||
self.assertEquals(providers[pcount].id, provider1.id)
|
||||
|
||||
# Edit the provider via the admin edit link
|
||||
admin = OAuth2ProviderConfigAdmin(provider1, AdminSite())
|
||||
# pylint: disable=protected-access
|
||||
update_url = reverse('admin:{}_{}_add'.format(admin.model._meta.app_label, admin.model._meta.model_name))
|
||||
update_url += "?source={}".format(provider1.pk)
|
||||
|
||||
# Remove the icon_image from the POST data, to simulate unchanged icon_image
|
||||
post_data = models.model_to_dict(provider1)
|
||||
del post_data['icon_image']
|
||||
|
||||
# Change the name, to verify POST
|
||||
post_data['name'] = 'Another name'
|
||||
|
||||
# Post the edit form: expecting redirect
|
||||
response = self.client.post(update_url, post_data)
|
||||
self.assertEquals(response.status_code, 302)
|
||||
|
||||
# Editing the existing provider creates a new provider instance
|
||||
providers = OAuth2ProviderConfig.objects.all()
|
||||
self.assertEquals(len(providers), pcount + 2)
|
||||
self.assertEquals(providers[pcount].id, provider1.id)
|
||||
provider2 = providers[pcount + 1]
|
||||
|
||||
# Ensure the icon_image was preserved on the new provider instance
|
||||
self.assertEquals(provider2.icon_image, provider1.icon_image)
|
||||
self.assertEquals(provider2.name, post_data['name'])
|
||||
@@ -13,6 +13,7 @@ import django.test
|
||||
from mako.template import Template
|
||||
import mock
|
||||
import os.path
|
||||
from storages.backends.overwrite import OverwriteStorage
|
||||
|
||||
from third_party_auth.models import (
|
||||
OAuth2ProviderConfig,
|
||||
@@ -52,6 +53,17 @@ class FakeDjangoSettings(object):
|
||||
class ThirdPartyAuthTestMixin(object):
|
||||
""" Helper methods useful for testing third party auth functionality """
|
||||
|
||||
def setUp(self, *args, **kwargs):
|
||||
# Django's FileSystemStorage will rename files if they already exist.
|
||||
# This storage backend overwrites files instead, which makes it easier
|
||||
# to make assertions about filenames.
|
||||
icon_image_field = OAuth2ProviderConfig._meta.get_field('icon_image') # pylint: disable=protected-access
|
||||
patch = mock.patch.object(icon_image_field, 'storage', OverwriteStorage())
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
|
||||
super(ThirdPartyAuthTestMixin, self).setUp(*args, **kwargs)
|
||||
|
||||
def tearDown(self):
|
||||
config_cache.clear()
|
||||
super(ThirdPartyAuthTestMixin, self).tearDown()
|
||||
@@ -124,7 +136,7 @@ class ThirdPartyAuthTestMixin(object):
|
||||
|
||||
@classmethod
|
||||
def configure_dummy_provider(cls, **kwargs):
|
||||
""" Update the settings for the Twitter third party auth provider/backend """
|
||||
""" Update the settings for the Dummy third party auth provider/backend """
|
||||
kwargs.setdefault("name", "Dummy")
|
||||
kwargs.setdefault("backend_name", "dummy")
|
||||
return cls.configure_oauth_provider(**kwargs)
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
// * +Content - Text Wrap - Extend
|
||||
// * +Content - Text Truncate - Extend
|
||||
// * +Icon - Font-Awesome - Extend
|
||||
// * +Icon - SSO icon images
|
||||
|
||||
// +Font Sizing - Mixin
|
||||
// ====================
|
||||
@@ -448,3 +449,17 @@
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
||||
// * +Icon - SSO icon images
|
||||
// ====================
|
||||
|
||||
%sso-icon {
|
||||
.icon-image {
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-height: 1.4em;
|
||||
max-width: 1.4em;
|
||||
margin-top: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,8 +166,11 @@ class LoginFromCombinedPageTest(UniqueCourseTest):
|
||||
# Create a user account
|
||||
email, password = self._create_unique_user()
|
||||
|
||||
# Navigate to the login page and try to log in using "Dummy" provider
|
||||
# Navigate to the login page
|
||||
self.login_page.visit()
|
||||
self.assertScreenshot('#login .login-providers', 'login-providers')
|
||||
|
||||
# Try to log in using "Dummy" provider
|
||||
self.login_page.click_third_party_dummy_provider()
|
||||
|
||||
# The user will be redirected somewhere and then back to the login page:
|
||||
@@ -206,6 +209,7 @@ class LoginFromCombinedPageTest(UniqueCourseTest):
|
||||
# We should now be redirected to the login page
|
||||
self.login_page.wait_for_page()
|
||||
self.assertIn("Would you like to sign in using your Dummy credentials?", self.login_page.hinted_login_prompt)
|
||||
self.assertScreenshot('#hinted-login-form', 'hinted-login')
|
||||
self.login_page.click_third_party_dummy_provider()
|
||||
|
||||
# We should now be redirected to the course page
|
||||
@@ -329,8 +333,11 @@ class RegisterFromCombinedPageTest(UniqueCourseTest):
|
||||
Test that we can register using third party credentials, and that the
|
||||
third party account gets linked to the edX account.
|
||||
"""
|
||||
# Navigate to the register page and try to authenticate using the "Dummy" provider
|
||||
# Navigate to the register page
|
||||
self.register_page.visit()
|
||||
self.assertScreenshot('#register .login-providers', 'register-providers')
|
||||
|
||||
# Try to authenticate using the "Dummy" provider
|
||||
self.register_page.click_third_party_dummy_provider()
|
||||
|
||||
# The user will be redirected somewhere and then back to the register page:
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"changed_by": null,
|
||||
"name": "Google",
|
||||
"icon_class": "fa-google-plus",
|
||||
"icon_image": null,
|
||||
"backend_name": "google-oauth2",
|
||||
"key": "test",
|
||||
"secret": "test",
|
||||
@@ -23,6 +24,7 @@
|
||||
"changed_by": null,
|
||||
"name": "Facebook",
|
||||
"icon_class": "fa-facebook",
|
||||
"icon_image": null,
|
||||
"backend_name": "facebook",
|
||||
"key": "test",
|
||||
"secret": "test",
|
||||
@@ -37,7 +39,8 @@
|
||||
"change_date": "2001-02-03T04:05:06Z",
|
||||
"changed_by": null,
|
||||
"name": "Dummy",
|
||||
"icon_class": "fa-sign-in",
|
||||
"icon_class": "",
|
||||
"icon_image": "test-icon.png",
|
||||
"backend_name": "dummy",
|
||||
"key": "",
|
||||
"secret": "",
|
||||
|
||||
Reference in New Issue
Block a user