Commit bcde8e55 by Peter Fogg

Flesh out UI now that the backend is there.

parent 25df9db6
;(function (define) {
'use strict';
define(['js/api_admin/views/catalog_preview'], function (CatalogPreviewView) {
return function (options) {
var view = new CatalogPreviewView({
el: '.catalog-body',
previewUrl: options.previewUrl,
catalogApiUrl: options.catalogApiUrl,
});
return view.render();
};
});
}).call(this, define || RequireJS.define);
;(function(define) {
'use strict';
define([
'backbone',
'underscore',
'gettext',
'text!../../../templates/api_admin/catalog-results.underscore',
'text!../../../templates/api_admin/catalog-error.underscore'
], function (Backbone, _, gettext, catalogResultsTpl, catalogErrorTpl) {
return Backbone.View.extend({
events: {
'click .preview-query': 'previewQuery'
},
initialize: function (options) {
this.previewUrl = options.previewUrl;
this.catalogApiUrl = options.catalogApiUrl;
},
render: function () {
this.$('#id_query').after(
'<button class="preview-query">'+ gettext('Preview this query') + '</button>'
);
return this;
},
/*
* Return the user's query, URL-encoded.
*/
getQuery: function () {
return encodeURIComponent(this.$("#id_query").val());
},
/*
* Make a request to get the list of courses associated
* with the user's query. On success, displays the
* results, and on failure, displays an error message.
*/
previewQuery: function (event) {
event.preventDefault();
$.ajax(this.previewUrl + '?q=' + this.getQuery(), {
method: 'GET',
success: _.bind(this.renderCourses, this),
error: _.bind(function () {
this.$('.preview-results').html(_.template(catalogErrorTpl)({}));
}, this)
});
},
/*
* Render a list of courses with data returned by the
* courses API.
*/
renderCourses: function (data) {
this.$('.preview-results').html(_.template(catalogResultsTpl)({
'courses': data.results,
'catalogApiUrl': this.catalogApiUrl,
}));
},
});
});
}).call(this, define || RequireJS.define);
define([
'js/api_admin/views/catalog_preview',
'common/js/spec_helpers/ajax_helpers',
], function (
CatalogPreviewView, AjaxHelpers
) {
'use strict';
describe('Catalog preview view', function () {
var view,
previewUrl = 'http://example.com/api-admin/catalogs/preview/',
catalogApiUrl = 'http://api.example.com/catalog/v1/courses/';
beforeEach(function () {
setFixtures(
'<div class="catalog-body">' +
'<textarea id="id_query"></textarea>' +
'<div class="preview-results"></div>' +
'</div>'
);
view = new CatalogPreviewView({
el: '.catalog-body',
previewUrl: previewUrl,
catalogApiUrl: catalogApiUrl,
});
view.render();
});
it('can render itself', function () {
expect(view.$('button.preview-query').length).toBe(1);
});
it('can retrieve a list of catalogs and display them', function () {
var requests = AjaxHelpers.requests(this);
view.$('#id_query').val('*');
view.$('.preview-query').click();
AjaxHelpers.expectRequest(requests, 'GET', previewUrl + '?q=*');
AjaxHelpers.respondWithJson(requests, {
results: [{key: 'TestX', title: 'Test Course'}],
count: 1,
next: null,
prev: null,
});
expect(view.$('.preview-results').text()).toContain('Test Course');
expect(view.$('.preview-results-list li a').attr('href')).toEqual(catalogApiUrl + 'TestX');
});
it('displays an error when courses cannot be retrieved', function () {
var requests = AjaxHelpers.requests(this);
view.$('#id_query').val('*');
view.$('.preview-query').click();
AjaxHelpers.respondWithError(requests, 500);
expect(view.$('.preview-results').text()).toContain(
'There was an error retrieving preview results for this catalog.'
);
});
});
});
...@@ -769,7 +769,8 @@ ...@@ -769,7 +769,8 @@
'js/spec/learner_dashboard/sidebar_view_spec.js', 'js/spec/learner_dashboard/sidebar_view_spec.js',
'js/spec/learner_dashboard/program_card_view_spec.js', 'js/spec/learner_dashboard/program_card_view_spec.js',
'js/spec/learner_dashboard/certificate_view_spec.js', 'js/spec/learner_dashboard/certificate_view_spec.js',
'js/spec/commerce/receipt_spec.js' 'js/spec/commerce/receipt_spec.js',
'js/spec/api_admin/catalog_preview_spec.js',
]; ];
for (var i = 0; i < testFiles.length; i++) { for (var i = 0; i < testFiles.length; i++) {
......
...@@ -119,7 +119,8 @@ var fixtureFiles = [ ...@@ -119,7 +119,8 @@ var fixtureFiles = [
{pattern: 'templates/bookmarks/**/*.*', included: false}, {pattern: 'templates/bookmarks/**/*.*', included: false},
{pattern: 'templates/learner_dashboard/**/*.*', included: false}, {pattern: 'templates/learner_dashboard/**/*.*', included: false},
{pattern: 'templates/ccx/**/*.*', included: false}, {pattern: 'templates/ccx/**/*.*', included: false},
{pattern: 'templates/commerce/receipt.underscore', included: false} {pattern: 'templates/commerce/receipt.underscore', included: false},
{pattern: 'templates/api_admin/**/*.*', included: false}
]; ];
// override fixture path and other config. // override fixture path and other config.
......
...@@ -35,7 +35,8 @@ ...@@ -35,7 +35,8 @@
'support/js/certificates_factory', 'support/js/certificates_factory',
'support/js/enrollment_factory', 'support/js/enrollment_factory',
'js/bookmarks/bookmarks_factory', 'js/bookmarks/bookmarks_factory',
'js/learner_dashboard/program_list_factory' 'js/learner_dashboard/program_list_factory',
'js/api_admin/catalog_preview_factory'
]), ]),
/** /**
......
#api-access-wrapper { #api-access-wrapper {
#api-access-request-header { h1 {
@extend %t-title4; @extend %t-title4;
margin-bottom: 0; margin-bottom: 0;
padding: $baseline; padding: $baseline;
@include text-align(left); @include text-align(left);
} }
.api-access-request-subheading { h2 {
@extend %t-title5; @extend %t-title5;
margin: $baseline; margin: $baseline;
@include text-align(left); @include text-align(left);
} }
.api-tos-body { p {
@extend %t-copy-sub1; @extend %t-copy-sub1;
margin: $baseline; margin: $baseline;
} }
...@@ -40,64 +40,95 @@ ...@@ -40,64 +40,95 @@
@extend %t-copy-base; @extend %t-copy-base;
} }
.api-management-form { .catalog-body {
display: inline-block;
width: 100%;
}
padding: 0 $baseline $baseline $baseline; .api-form-container {
@include float(left);
width: 50%;
p { .api-form {
margin: 1.5*$baseline 0;
.helptext { padding: 0 $baseline $baseline $baseline;
@extend %t-copy-sub1;
display: block;
}
}
label { p {
@extend %t-copy-base; margin: 1.5*$baseline 0;
display: block;
font-style: normal;
&.tos-checkbox-label { .helptext {
display: inline-block; @extend %t-copy-sub1;
display: block;
}
} }
}
input, textarea { label {
@extend %t-copy-base; @extend %t-copy-base;
font-family: 'Open Sans'; display: block;
font-style: normal; font-style: normal;
width: 300px; }
&[type=checkbox] { input[type=checkbox] + label {
display: inline-block; display: inline-block;
width: initial;
@include margin-right(0.5*$baseline);
} }
}
.errorlist { input, textarea {
@extend %t-copy-base;
font-family: 'Open Sans';
font-style: normal;
width: 300px;
&[type=checkbox] {
display: inline-block;
width: initial;
@include margin-right(0.5*$baseline);
}
&[type=submit] {
@extend %t-copy-base;
border-radius: 3px;
border: none;
background-color: $blue;
box-shadow: none;
background-image: none;
text-shadow: none;
text-transform: none;
}
}
.errorlist {
padding: 0;
list-style-type: none;
padding: 0; li {
list-style-type: none; @extend %t-copy-base;
margin: 0;
color: $red;
}
}
li { #api-access-submit, .preview-query {
@extend %t-copy-base; @extend %t-copy-base;
margin: 0; border-radius: 3px;
color: $red; border: none;
background-color: $blue;
box-shadow: none;
background-image: none;
text-shadow: none;
text-transform: none;
} }
} }
}
#api-access-submit { .preview-results {
@extend %t-copy-base; @include float(right);
border-radius: 3px; width: 50%;
border: none; }
background-color: $blue;
box-shadow: none; .preview-query {
background-image: none; display: block;
text-shadow: none; margin-top: $baseline/2;
text-transform: none;
}
} }
.application-info { .application-info {
......
...@@ -8,13 +8,17 @@ ...@@ -8,13 +8,17 @@
<%block name="pagetitle">${_("API Access Request")}</%block> <%block name="pagetitle">${_("API Access Request")}</%block>
<div id="api-access-wrapper" class="container"> <div id="api-access-wrapper" class="container">
<h1 id="api-access-request-header"> <h1 id="api-header">
${_("{platform_name} API Access Request").format(platform_name=settings.PLATFORM_NAME)} ${_("{platform_name} API Access Request").format(platform_name=settings.PLATFORM_NAME)}
</h1> </h1>
<form action="" method="post" class="api-management-form"> <div class="catalog-body">
<input type="hidden" id="csrf_token" name="csrfmiddlewaretoken" value="${csrf_token}"> <div class="api-form-container">
${form.as_p() | n} <form action="" method="post" class="api-form">
<input id="api-access-submit" type="submit" value="${_('Request API Access')}"/> <input type="hidden" id="csrf_token" name="csrfmiddlewaretoken" value="${csrf_token}">
</form> ${form.as_p() | n}
<input id="api-access-submit" type="submit" value="${_('Request API Access')}"/>
</form>
</div>
</div>
</div> </div>
<p class="api-copy-body">
<%- gettext('There was an error retrieving preview results for this catalog. Please check that your query is correct and try again.') %>
</p>
<h2 class="api-subheading"><%- gettext("This catalog's courses:") %></h2>
<ul class="preview-results-list">
<% _.each(courses, function (course) { %>
<li><a href="<%- catalogApiUrl + encodeURIComponent(course.key) %>"><%- course.title %></a></li>
<% }); %>
</ul>
## mako
<%page expression_filter="h"/>
<%inherit file="../../main.html"/>
<%!
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _
%>
<%block name="pagetitle">${catalog.name}</%block>
<%block name="content">
<div id="api-access-wrapper">
<h1 id="api-header">${catalog.name}</h1>
<p class="api-copy-body">${catalog.query}</p>
<p class="api-copy-body"><a href="${edit_link}">${_("Edit or delete this catalog.")}</a></p>
<p class="api-copy-body"><a href="${preview_link}">${_("See a preview of this catalog's contents.")}</a></p>
</div>
</%block>
## mako
<%page expression_filter="h"/>
<%inherit file="../../main.html"/>
<%!
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _
%>
<%namespace name='static' file='/static_content.html'/>
<%block name="pagetitle">${_("Edit {catalog_name}").format(catalog_name=catalog.name)}</%block>
<%block name="js_extra">
<%static:require_module module_name="js/api_admin/catalog_preview_factory" class_name="CatalogPreviewFactory">
CatalogPreviewFactory({
previewUrl: "${preview_url}",
catalogApiUrl: "${catalog_api_url}",
});
</%static:require_module>
</%block>
<%block name="content">
<div id="api-access-wrapper">
<h1 id="api-header">${catalog.name}</h1>
<div class="catalog-body">
<div class="api-form-container">
<form class="api-form" id="catalog-update" action="${reverse('api_admin:catalog-edit', args=(catalog.id,))}" method="post">
<input type="hidden" id="csrf_token" name="csrfmiddlewaretoken" value="${csrf_token}">
<p>
<input type="checkbox" id="delete-catalog" name="delete-catalog" />
<label class="api-checkbox-label" for="delete-catalog">${_("Delete this catalog")}</label>
</p>
${form.as_p() | n}
<input id="catalog-create-submit" type="submit" value="${_('Update Catalog')}"/>
</form>
</div>
<div class="preview-results"></div>
</div>
</div>
</%block>
## mako
<%page expression_filter="h"/>
<%inherit file="../../main.html"/>
<%!
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _
%>
<%namespace name='static' file='/static_content.html'/>
<%block name="pagetitle">${_("Catalogs for {username}").format(username=username)}</%block>
<%block name="js_extra">
<%static:require_module module_name="js/api_admin/catalog_preview_factory" class_name="CatalogPreviewFactory">
CatalogPreviewFactory({
previewUrl: "${preview_url}",
catalogApiUrl: "${catalog_api_url}",
});
</%static:require_module>
</%block>
<%block name="content">
<div id="api-access-wrapper">
<h1 id="api-header">${_("Catalogs for {username}").format(username=username)}</h1>
<ul>
% for catalog in catalogs:
<li>
<a href="${reverse('api_admin:catalog-edit', args=(catalog.id,))}">${catalog.name}</a>
</li>
% endfor
</ul>
<div class="catalog-body">
<h2 class="api-subheading">${_("Create new catalog:")}</h2>
<div class="api-form-container">
<form class="api-form" id="catalog-create" action="${reverse('api_admin:catalog-list', args={'username': username})}" method="post">
<input type="hidden" id="csrf_token" name="csrfmiddlewaretoken" value="${csrf_token}">
${form.as_p() | n}
<input id="catalog-create-submit" type="submit" value="${_('Create Catalog')}"/>
</form>
</div>
<div class="preview-results"></div>
</div>
</div>
</%block>
## mako
<%page expression_filter="h"/>
<%inherit file="../../main.html"/>
<%!
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _
%>
<%block name="pagetitle">${_("Catalog search")}</%block>
<%block name="content">
<div id="api-access-wrapper">
<h1 id="api-header">${_("Catalog Search")}</h1>
<div class="catalog-body">
<h2 class="api-subheading">${_("Enter a username to view catalogs belonging to that user.")}</h2>
<div class="api-form-container">
<form class="api-form" method="post" action="">
<input type="hidden" id="csrf_token" name="csrfmiddlewaretoken" value="${csrf_token}">
<p>
<input name="username" type="text" maxlength="30" placeholder="${_('Username')}" />
</p>
<input id="catalog-search-submit" type="submit" value="${_('Search')}"/>
</form>
</div>
</div>
</div>
</%block>
...@@ -9,7 +9,7 @@ from openedx.core.djangolib.markup import Text, HTML ...@@ -9,7 +9,7 @@ from openedx.core.djangolib.markup import Text, HTML
%> %>
<div id="api-access-wrapper"> <div id="api-access-wrapper">
<h1 id="api-access-request-header">${_("{platform_name} API Access Request").format(platform_name=settings.PLATFORM_NAME)}</h1> <h1 id="api-header">${_("{platform_name} API Access Request").format(platform_name=settings.PLATFORM_NAME)}</h1>
<div class="request-status request-${status}"> <div class="request-status request-${status}">
<p id="api-access-status"> <p id="api-access-status">
% if status == ApiAccessRequest.PENDING: % if status == ApiAccessRequest.PENDING:
...@@ -41,11 +41,15 @@ from openedx.core.djangolib.markup import Text, HTML ...@@ -41,11 +41,15 @@ from openedx.core.djangolib.markup import Text, HTML
<p>${_('If you would like to regenerate your API client information, please use the form below.')}</p> <p>${_('If you would like to regenerate your API client information, please use the form below.')}</p>
% endif % endif
<form id="api-form-fields" method="post" class="api-management-form"> <div class="catalog-body">
<input type="hidden" id="csrf_token" name="csrfmiddlewaretoken" value="${csrf_token}"> <div class="api-form-container">
${form.as_p() | n} <form id="api-form-fields" method="post" class="api-form">
<input id="api-access-submit" type="submit" value="${_('Generate API client credentials')}"/> <input type="hidden" id="csrf_token" name="csrfmiddlewaretoken" value="${csrf_token}">
</form> ${form.as_p() | n}
<input id="api-access-submit" type="submit" value="${_('Generate API client credentials')}"/>
</form>
</div>
</div>
% endif % endif
</p> </p>
......
...@@ -113,9 +113,6 @@ urlpatterns = ( ...@@ -113,9 +113,6 @@ urlpatterns = (
# URLs for API access management # URLs for API access management
url(r'^api-admin/', include('openedx.core.djangoapps.api_admin.urls', namespace='api_admin')), url(r'^api-admin/', include('openedx.core.djangoapps.api_admin.urls', namespace='api_admin')),
url(r'^admin/api_admin/catalog/add/$', 'openedx.core.djangoapps.api_admin.views.catalog_changeform'),
url(r'^admin/api_admin/catalog/(?P<id>\d+)/$', 'openedx.core.djangoapps.api_admin.views.catalog_changeform'),
url(r'^admin/api_admin/catalog/$', 'openedx.core.djangoapps.api_admin.views.catalog_changelist'),
) )
urlpatterns += ( urlpatterns += (
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
from django.contrib import admin from django.contrib import admin
from config_models.admin import ConfigurationModelAdmin from config_models.admin import ConfigurationModelAdmin
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest, ApiAccessConfig, Catalog from openedx.core.djangoapps.api_admin.models import ApiAccessRequest, ApiAccessConfig
@admin.register(ApiAccessRequest) @admin.register(ApiAccessRequest)
...@@ -15,8 +15,4 @@ class ApiAccessRequestAdmin(admin.ModelAdmin): ...@@ -15,8 +15,4 @@ class ApiAccessRequestAdmin(admin.ModelAdmin):
readonly_fields = ('user', 'website', 'reason', 'company_name', 'company_address', 'contacted', ) readonly_fields = ('user', 'website', 'reason', 'company_name', 'company_address', 'contacted', )
exclude = ('site',) exclude = ('site',)
@admin.register(Catalog)
class CatalogAdmin (admin.ModelAdmin):
name="Catalog"
admin.site.register(ApiAccessConfig, ConfigurationModelAdmin) admin.site.register(ApiAccessConfig, ConfigurationModelAdmin)
"""Forms for API management.""" """Forms for API management."""
from django import forms from django import forms
from django.contrib.auth.models import User
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest from openedx.core.djangoapps.api_admin.models import ApiAccessRequest, Catalog
from openedx.core.djangoapps.api_admin.widgets import TermsOfServiceCheckboxInput from openedx.core.djangoapps.api_admin.widgets import TermsOfServiceCheckboxInput
...@@ -34,11 +35,48 @@ class ApiAccessRequestForm(forms.ModelForm): ...@@ -34,11 +35,48 @@ class ApiAccessRequestForm(forms.ModelForm):
super(ApiAccessRequestForm, self).__init__(*args, **kwargs) super(ApiAccessRequestForm, self).__init__(*args, **kwargs)
class CatalogForm(forms.Form): class ViewersWidget(forms.widgets.TextInput):
id = forms.IntegerField(required=False, widget=forms.HiddenInput) """Form widget to display a comma-separated list of usernames."""
name = forms.CharField(required=True, help_text="The name of this catalog")
query = forms.CharField( def render(self, name, value, attrs=None):
required=True, return super(ViewersWidget, self).render(name, ', '.join(value), attrs)
help_text="The query for courses to be returned by catalog",
widget=forms.Textarea
) class ViewersField(forms.Field):
"""Custom form field for a comma-separated list of usernames."""
widget = ViewersWidget
default_error_messages = {
'invalid': 'Enter a comma-separated list of usernames.',
}
def to_python(self, value):
"""Parse out a comma-separated list of usernames."""
return [username.strip() for username in value.split(',')]
def validate(self, value):
super(ViewersField, self).validate(value)
nonexistent_users = []
for username in value:
try:
User.objects.get(username=username)
except User.DoesNotExist:
nonexistent_users.append(username)
if nonexistent_users:
raise forms.ValidationError(
_('The following users do not exist: {usernames}.').format(usernames=nonexistent_users)
)
class CatalogForm(forms.ModelForm):
"""Form to create a catalog."""
viewers = ViewersField()
class Meta(object):
model = Catalog
fields = ('name', 'query', 'viewers')
help_texts = {
'viewers': _('Comma-separated list of usernames which will be able to view this catalog.'),
}
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api_admin', '0005_auto_20160414_1232'),
]
operations = [
migrations.CreateModel(
name='Catalog',
fields=[
('id', models.IntegerField(serialize=False, primary_key=True)),
('name', models.CharField(max_length=255)),
('query', models.TextField()),
('viewers', models.TextField()),
],
options={
'managed': False,
},
),
]
...@@ -181,22 +181,43 @@ def _send_decision_email(instance): ...@@ -181,22 +181,43 @@ def _send_decision_email(instance):
log.exception('Error sending API user notification email for request [%s].', instance.id) log.exception('Error sending API user notification email for request [%s].', instance.id)
class CatalogManager(object): class Catalog(models.Model):
def get(self, key): """A (non-Django-managed) model for Catalogs in the course discovery service."""
log.info("GET api call: %s", key)
return None id = models.IntegerField(primary_key=True) # pylint: disable=invalid-name
name = models.CharField(max_length=255, null=False, blank=False)
def all(self): query = models.TextField(null=False, blank=False)
log.info("ALL api call") viewers = models.TextField()
return []
class Meta(object):
def filter(self, **kwargs): # Catalogs live in course discovery, so we do not create any
log.info("FILTER api call: %s", kwargs) # tables in LMS. Instead we override the save method to not
return [] # touch the database, and use our API client to communicate
# with discovery.
managed = False
def __init__(self, *args, **kwargs):
attributes = kwargs.get('attributes')
if attributes:
self.id = attributes['id'] # pylint: disable=invalid-name
self.name = attributes['name']
self.query = attributes['query']
self.viewers = attributes['viewers']
else:
super(Catalog, self).__init__(*args, **kwargs)
def save(self, **kwargs): # pylint: disable=unused-argument
return None
class Catalog(models.Model): @property
objects = CatalogManager() def attributes(self):
"""Return a dictionary representation of this catalog."""
return {
'id': self.id,
'name': self.name,
'query': self.query,
'viewers': self.viewers,
}
class Meta: def __unicode__(self):
managed = False return u'Catalog {name} [{query}]'.format(name=self.name, query=self.query)
"""Factories for API management.""" """Factories for API management."""
import factory import factory
from factory.fuzzy import FuzzyInteger, FuzzyText
from factory.django import DjangoModelFactory from factory.django import DjangoModelFactory
from oauth2_provider.models import get_application_model from oauth2_provider.models import get_application_model
from microsite_configuration.tests.factories import SiteFactory from microsite_configuration.tests.factories import SiteFactory
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest from openedx.core.djangoapps.api_admin.models import ApiAccessRequest, Catalog
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
...@@ -27,3 +28,14 @@ class ApplicationFactory(DjangoModelFactory): ...@@ -27,3 +28,14 @@ class ApplicationFactory(DjangoModelFactory):
authorization_grant_type = Application.GRANT_CLIENT_CREDENTIALS authorization_grant_type = Application.GRANT_CLIENT_CREDENTIALS
client_type = Application.CLIENT_CONFIDENTIAL client_type = Application.CLIENT_CONFIDENTIAL
class CatalogFactory(DjangoModelFactory):
"""Factory for Catalog objects."""
class Meta(object):
model = Catalog
id = FuzzyInteger(0, 999) # pylint: disable=invalid-name
query = '*'
name = FuzzyText(prefix='test-catalog')
#pylint: disable=missing-docstring #pylint: disable=missing-docstring
import unittest import unittest
import json
from urlparse import urljoin
import ddt import ddt
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from edx_oauth2_provider.tests.factories import ClientFactory
import httpretty
from oauth2_provider.models import get_application_model from oauth2_provider.models import get_application_model
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest, ApiAccessConfig from openedx.core.djangoapps.api_admin.models import ApiAccessRequest, ApiAccessConfig
from openedx.core.djangoapps.api_admin.tests.factories import ApiAccessRequestFactory, ApplicationFactory from openedx.core.djangoapps.api_admin.tests.factories import (
ApiAccessRequestFactory, ApplicationFactory, CatalogFactory
)
from openedx.core.djangoapps.api_admin.tests.utils import VALID_DATA from openedx.core.djangoapps.api_admin.tests.utils import VALID_DATA
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
Application = get_application_model() # pylint: disable=invalid-name Application = get_application_model() # pylint: disable=invalid-name
MOCK_CATALOG_API_URL_ROOT = 'https://api.example.com/'
class ApiAdminTest(TestCase): class ApiAdminTest(TestCase):
...@@ -206,3 +214,169 @@ class ApiTosViewTest(ApiAdminTest): ...@@ -206,3 +214,169 @@ class ApiTosViewTest(ApiAdminTest):
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertIn('Terms of Service', response.content) self.assertIn('Terms of Service', response.content)
class CatalogTest(ApiAdminTest):
def setUp(self):
super(CatalogTest, self).setUp()
password = 'abc123'
self.user = UserFactory(password=password, is_staff=True)
self.client.login(username=self.user.username, password=password)
ClientFactory(user=self.user, name='course-discovery', url=MOCK_CATALOG_API_URL_ROOT)
def mock_catalog_api(self, url, data, method=httpretty.GET, status_code=200):
self.assertTrue(httpretty.is_enabled(), msg='httpretty must be enabled to mock Catalog API calls.')
httpretty.reset()
httpretty.register_uri(
method,
urljoin(MOCK_CATALOG_API_URL_ROOT, url),
body=json.dumps(data),
content_type='application/json',
status=status_code
)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class CatalogSearchViewTest(CatalogTest):
def setUp(self):
super(CatalogSearchViewTest, self).setUp()
self.url = reverse('api_admin:catalog-search')
def test_get(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
@httpretty.activate
def test_post(self):
catalog_user = UserFactory()
self.mock_catalog_api('api/v1/catalogs/', {'results': []})
response = self.client.post(self.url, {'username': catalog_user.username})
self.assertRedirects(response, reverse('api_admin:catalog-list', kwargs={'username': catalog_user.username}))
def test_post_without_username(self):
response = self.client.post(self.url, {'username': ''})
self.assertRedirects(response, reverse('api_admin:catalog-search'))
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class CatalogListViewTest(CatalogTest):
def setUp(self):
super(CatalogListViewTest, self).setUp()
self.catalog_user = UserFactory()
self.url = reverse('api_admin:catalog-list', kwargs={'username': self.catalog_user.username})
@httpretty.activate
def test_get(self):
catalog = CatalogFactory(viewers=[self.catalog_user.username])
self.mock_catalog_api('api/v1/catalogs/', {
'results': [catalog.attributes]
})
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertIn(catalog.name, response.content.decode('utf-8'))
@httpretty.activate
def test_post(self):
catalog_data = {
'name': 'test-catalog',
'query': '*',
'viewers': [self.catalog_user.username]
}
catalog_id = 123
self.mock_catalog_api('api/v1/catalogs/', dict(catalog_data, id=catalog_id), method=httpretty.POST)
response = self.client.post(self.url, catalog_data)
self.assertEqual(httpretty.last_request().method, 'POST')
self.mock_catalog_api('api/v1/catalogs/{}/'.format(catalog_id), CatalogFactory().attributes)
self.assertRedirects(response, reverse('api_admin:catalog-edit', kwargs={'catalog_id': catalog_id}))
@httpretty.activate
def test_post_invalid(self):
catalog = CatalogFactory(viewers=[self.catalog_user.username])
self.mock_catalog_api('api/v1/catalogs/', {
'results': [catalog.attributes]
})
response = self.client.post(self.url, {
'name': '',
'query': '*',
'viewers': [self.catalog_user.username]
})
self.assertEqual(response.status_code, 400)
# Assert that no POST was made to the catalog API
self.assertEqual(len([r for r in httpretty.httpretty.latest_requests if r.method == 'POST']), 0)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class CatalogEditViewTest(CatalogTest):
def setUp(self):
super(CatalogEditViewTest, self).setUp()
self.catalog_user = UserFactory()
self.catalog = CatalogFactory(viewers=[self.catalog_user.username])
self.url = reverse('api_admin:catalog-edit', kwargs={'catalog_id': self.catalog.id})
@httpretty.activate
def test_get(self):
self.mock_catalog_api('api/v1/catalogs/{}/'.format(self.catalog.id), self.catalog.attributes)
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertIn(self.catalog.name, response.content.decode('utf-8'))
@httpretty.activate
def test_delete(self):
self.mock_catalog_api(
'api/v1/catalogs/{}/'.format(self.catalog.id),
self.catalog.attributes,
method=httpretty.DELETE
)
response = self.client.post(self.url, {'delete-catalog': 'on'})
self.assertRedirects(response, reverse('api_admin:catalog-search'))
self.assertEqual(httpretty.last_request().method, 'DELETE')
self.assertEqual(
httpretty.last_request().path,
'/api/v1/catalogs/{}/'.format(self.catalog.id)
)
self.assertEqual(len(httpretty.httpretty.latest_requests), 1)
@httpretty.activate
def test_edit(self):
self.mock_catalog_api(
'api/v1/catalogs/{}/'.format(self.catalog.id),
self.catalog.attributes, method=httpretty.PATCH
)
new_attributes = dict(self.catalog.attributes, **{'delete-catalog': 'off', 'name': 'changed'})
response = self.client.post(self.url, new_attributes)
self.mock_catalog_api('api/v1/catalogs/{}/'.format(self.catalog.id), new_attributes)
self.assertRedirects(response, reverse('api_admin:catalog-edit', kwargs={'catalog_id': self.catalog.id}))
@httpretty.activate
def test_edit_invalid(self):
self.mock_catalog_api('api/v1/catalogs/{}/'.format(self.catalog.id), self.catalog.attributes)
new_attributes = dict(self.catalog.attributes, **{'delete-catalog': 'off', 'name': ''})
response = self.client.post(self.url, new_attributes)
self.assertEqual(response.status_code, 400)
# Assert that no PATCH was made to the Catalog API
self.assertEqual(len([r for r in httpretty.httpretty.latest_requests if r.method == 'PATCH']), 0)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class CatalogPreviewViewTest(CatalogTest):
def setUp(self):
super(CatalogPreviewViewTest, self).setUp()
self.url = reverse('api_admin:catalog-preview')
@httpretty.activate
def test_get(self):
data = {'count': 1, 'results': ['test data'], 'next': None, 'prev': None}
self.mock_catalog_api('api/v1/courses/', data)
response = self.client.get(self.url, {'q': '*'})
self.assertEqual(response.status_code, 200)
self.assertEqual(json.loads(response.content), data)
def test_get_without_query(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertEqual(json.loads(response.content), {'count': 0, 'results': [], 'next': None, 'prev': None})
"""URLs for API access management.""" """URLs for API access management."""
from django.conf.urls import url from django.conf.urls import url
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from openedx.core.djangoapps.api_admin.decorators import api_access_enabled_or_404 from openedx.core.djangoapps.api_admin.decorators import api_access_enabled_or_404
from openedx.core.djangoapps.api_admin.views import ApiRequestView, ApiRequestStatusView, ApiTosView from openedx.core.djangoapps.api_admin.views import (
ApiRequestView, ApiRequestStatusView, ApiTosView, CatalogListView, CatalogEditView,
CatalogPreviewView, CatalogSearchView
)
urlpatterns = ( urlpatterns = (
url( url(
...@@ -18,6 +22,42 @@ urlpatterns = ( ...@@ -18,6 +22,42 @@ urlpatterns = (
name="api-tos" name="api-tos"
), ),
url( url(
r'^catalogs/preview/$',
staff_member_required(
api_access_enabled_or_404(CatalogPreviewView.as_view()),
login_url='dashboard',
redirect_field_name=None
),
name='catalog-preview',
),
url(
r'^catalogs/user/(?P<username>[\w.@+-]+)/$',
staff_member_required(
api_access_enabled_or_404(CatalogListView.as_view()),
login_url='dashboard',
redirect_field_name=None
),
name='catalog-list',
),
url(
r'^catalogs/(?P<catalog_id>\d+)/$',
staff_member_required(
api_access_enabled_or_404(CatalogEditView.as_view()),
login_url='dashboard',
redirect_field_name=None
),
name='catalog-edit',
),
url(
r'^catalogs/$',
staff_member_required(
api_access_enabled_or_404(CatalogSearchView.as_view()),
login_url='dashboard',
redirect_field_name=None
),
name='catalog-search',
),
url(
r'^$', r'^$',
api_access_enabled_or_404(login_required(ApiRequestView.as_view())), api_access_enabled_or_404(login_required(ApiRequestView.as_view())),
name="api-request" name="api-request"
......
""" Course Discovery API Service. """
from edx_rest_api_client.client import EdxRestApiClient
from openedx.core.lib.token_utils import get_id_token
from provider.oauth2.models import Client
CLIENT_NAME = 'course-discovery'
def course_discovery_api_client(user):
""" Returns a Course Discovery API client setup with authentication for the specified user. """
course_discovery_client = Client.objects.get(name=CLIENT_NAME)
return EdxRestApiClient(
course_discovery_client.url,
jwt=get_id_token(user, CLIENT_NAME)
)
...@@ -2,14 +2,11 @@ ...@@ -2,14 +2,11 @@
import logging import logging
from django.conf import settings from django.conf import settings
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.sites.shortcuts import get_current_site from django.contrib.sites.shortcuts import get_current_site
from django.core.urlresolvers import reverse_lazy, reverse from django.core.urlresolvers import reverse_lazy, reverse
from django.http import HttpResponseRedirect from django.http.response import JsonResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect
from django.template import RequestContext
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.views.decorators.cache import never_cache
from django.views.generic import View from django.views.generic import View
from django.views.generic.base import TemplateView from django.views.generic.base import TemplateView
from django.views.generic.edit import CreateView from django.views.generic.edit import CreateView
...@@ -18,11 +15,10 @@ from oauth2_provider.models import get_application_model ...@@ -18,11 +15,10 @@ from oauth2_provider.models import get_application_model
from oauth2_provider.views import ApplicationRegistration from oauth2_provider.views import ApplicationRegistration
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
from edx_rest_api_client.client import EdxRestApiClient
from openedx.core.djangoapps.api_admin.decorators import require_api_access from openedx.core.djangoapps.api_admin.decorators import require_api_access
from openedx.core.djangoapps.api_admin.forms import ApiAccessRequestForm, CatalogForm from openedx.core.djangoapps.api_admin.forms import ApiAccessRequestForm, CatalogForm
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest from openedx.core.djangoapps.api_admin.models import ApiAccessRequest, Catalog
from openedx.core.lib.token_utils import get_asymmetric_token from openedx.core.djangoapps.api_admin.utils import course_discovery_api_client
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -123,72 +119,110 @@ class ApiTosView(TemplateView): ...@@ -123,72 +119,110 @@ class ApiTosView(TemplateView):
template_name = 'api_admin/terms_of_service.html' template_name = 'api_admin/terms_of_service.html'
@never_cache class CatalogSearchView(View):
@staff_member_required """View to search for catalogs belonging to a user."""
def catalog_changelist(request):
# TODO: get catalogs def get(self, request):
catalogs = [ """Display a form to search for catalogs belonging to a user."""
{ return render_to_response('api_admin/catalogs/search.html')
'id': '1',
'name': 'test1',
'query': '*'
}
]
return render(
RequestContext(request),
'api_admin/catalog_changelist.html',
{
'catalogs': catalogs,
}
)
def post(self, request):
"""Redirect to the list view for the given user."""
username = request.POST.get('username')
# If no username is provided, bounce back to this page.
if not username:
return redirect(reverse('api_admin:catalog-search'))
return redirect(reverse('api_admin:catalog-list', kwargs={'username': username}))
class CatalogListView(View):
"""View to list existing catalogs and create new ones."""
template = 'api_admin/catalogs/list.html'
def get(self, request, username):
"""Display a list of a user's catalogs."""
client = course_discovery_api_client(request.user)
response = client.api.v1.catalogs.get(username=username)
catalogs = [Catalog(attributes=catalog) for catalog in response['results']]
return render_to_response(self.template, {
'username': username,
'catalogs': catalogs,
'form': CatalogForm(initial={'viewers': [username]}),
'preview_url': reverse('api_admin:catalog-preview'),
'catalog_api_url': client.api.v1.courses.url(),
})
@never_cache def post(self, request, username):
@staff_member_required """Create a new catalog for a user."""
def catalog_changeform(request, id=None):
# import pdb; pdb.set_trace()
if request.method == 'POST':
form = CatalogForm(request.POST) form = CatalogForm(request.POST)
change = False client = course_discovery_api_client(request.user)
if form.is_valid():
if id is None: if not form.is_valid():
log.info("CREATE NEW CATALOGUE") # create new catalog response = client.api.v1.catalogs.get(username=username)
else: catalogs = [Catalog(attributes=catalog) for catalog in response['results']]
change = True return render_to_response(self.template, {
log.info("UPDATE CATALOGUE") # update catalog 'form': form,
return HttpResponseRedirect('..') 'catalogs': catalogs,
else: 'username': username,
if id is None: # Create new catalog 'preview_url': reverse('api_admin:catalog-preview'),
change = False 'catalog_api_url': client.api.v1.courses.url(),
form = CatalogForm() }, status=400)
else: # Update existing catalog
change = True attrs = form.instance.attributes
catalog = { catalog = client.api.v1.catalogs.post(attrs)
'id': '2', return redirect(reverse('api_admin:catalog-edit', kwargs={'catalog_id': catalog['id']}))
'name': 'test2',
'query': 'test*'
} # Get catalogs class CatalogEditView(View):
"""View to edit an individual catalog."""
form = CatalogForm(catalog)
# del form.fields['hidden_field'] def get(self, request, catalog_id):
return render( """Display a form to edit this catalog."""
request, client = course_discovery_api_client(request.user)
'api_admin/catalog_changeform.html', response = client.api.v1.catalogs(catalog_id).get()
{ catalog = Catalog(attributes=response)
'change': change, form = CatalogForm(instance=catalog)
return render_to_response('api_admin/catalogs/edit.html', {
'catalog': catalog,
'form': form, 'form': form,
} 'preview_url': reverse('api_admin:catalog-preview'),
) 'catalog_api_url': client.api.v1.courses.url(),
})
def catalog_client(user): def post(self, request, catalog_id):
token = get_asymmetric_token(user, 'course-discovery') """Update or delete this catalog."""
return EdxRestApiClient( client = course_discovery_api_client(request.user)
"http://18.111.106.34:8008/api/v1/", if request.POST.get('delete-catalog') == 'on':
jwt=token client.api.v1.catalogs(catalog_id).delete()
) return redirect(reverse('api_admin:catalog-search'))
form = CatalogForm(request.POST)
if not form.is_valid():
response = client.api.v1.catalogs(catalog_id).get()
catalog = Catalog(attributes=response)
return render_to_response('api_admin/catalogs/edit.html', {
'catalog': catalog,
'form': form,
'preview_url': reverse('api_admin:catalog-preview'),
'catalog_api_url': client.api.v1.courses.url(),
}, status=400)
catalog = client.api.v1.catalogs(catalog_id).patch(form.instance.attributes)
return redirect(reverse('api_admin:catalog-edit', kwargs={'catalog_id': catalog['id']}))
class CatalogPreviewView(View):
"""Endpoint to preview courses for a query."""
# from openedx.core.djangoapps.api_admin.views import catalog_client def get(self, request):
# from django.contrib.auth.models import User """
# user = User.objects.all()[1] Return the results of a query against the course catalog API. If no
# c = catalog_client(user) query parameter is given, returns an empty result set.
"""
client = course_discovery_api_client(request.user)
# Just pass along the request params including limit/offset pagination
if 'q' in request.GET:
results = client.api.v1.courses.get(**request.GET)
# Ensure that we don't just return all the courses if no query is given
else:
results = {'count': 0, 'results': [], 'next': None, 'prev': None}
return JsonResponse(results)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment