Commit 4a417f28 by Peter Fogg

Merge pull request #12372 from edx/feature/catalog-admin

Merge catalog admin into master.
parents e6f3af65 85a6954d
......@@ -769,6 +769,8 @@ CREDIT_HELP_LINK_URL = ENV_TOKENS.get('CREDIT_HELP_LINK_URL', CREDIT_HELP_LINK_U
JWT_ISSUER = ENV_TOKENS.get('JWT_ISSUER', JWT_ISSUER)
JWT_EXPIRATION = ENV_TOKENS.get('JWT_EXPIRATION', JWT_EXPIRATION)
JWT_AUTH.update(ENV_TOKENS.get('JWT_AUTH', {}))
PUBLIC_RSA_KEY = ENV_TOKENS.get('PUBLIC_RSA_KEY', PUBLIC_RSA_KEY)
PRIVATE_RSA_KEY = ENV_TOKENS.get('PRIVATE_RSA_KEY', PRIVATE_RSA_KEY)
################# PROCTORING CONFIGURATION ##################
......
......@@ -2784,6 +2784,10 @@ LTI_AGGREGATE_SCORE_PASSBACK_DELAY = 15 * 60
JWT_EXPIRATION = 30
JWT_ISSUER = None
# For help generating a key pair import and run `openedx.core.lib.rsa_key_utils.generate_rsa_key_pair()`
PUBLIC_RSA_KEY = None
PRIVATE_RSA_KEY = None
# Credit notifications settings
NOTIFICATION_EMAIL_CSS = "templates/credit_notifications/credit_notification.css"
NOTIFICATION_EMAIL_EDX_LOGO = "templates/credit_notifications/edx-logo-header.png"
......
......@@ -225,6 +225,47 @@ CORS_ORIGIN_WHITELIST = ()
CORS_ORIGIN_ALLOW_ALL = True
# JWT settings for devstack
PUBLIC_RSA_KEY = """\
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApCujf5oZBGK4MafMRGY9
+zdRRI9YDm1r+81coDCysSrwkhTkFIwP2dmS6lYvJuQ5wifuQa3WFv1Kh9Nr2XRJ
1m9OL3/JpmMyTi/YuwD7tIf65tab1SOSRYkoxOKRuuvZuXQG9nWbXrGDncnwuWxf
eymwWaIrAhALUS5+nDa7dauj8VngsWauMrEA/MWShEzsR53wGKlciEZA1r/AfQ55
XS42GvBobhhy9SeZ3B6LHiaAEywpwFmKPssuoHSNhbPa49LW3gXJ6CsFGRDcBFKd
xJ/l8O847Q7kg1lvckpLsKyu5167NK9Qj1X/O3SwVBL3cxx1HpQ6+q3SGLZ4ngow
hwIDAQAB
-----END PUBLIC KEY-----"""
PRIVATE_RSA_KEY = """\
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCkK6N/mhkEYrgx
p8xEZj37N1FEj1gObWv7zVygMLKxKvCSFOQUjA/Z2ZLqVi8m5DnCJ+5BrdYW/UqH
02vZdEnWb04vf8mmYzJOL9i7APu0h/rm1pvVI5JFiSjE4pG669m5dAb2dZtesYOd
yfC5bF97KbBZoisCEAtRLn6cNrt1q6PxWeCxZq4ysQD8xZKETOxHnfAYqVyIRkDW
v8B9DnldLjYa8GhuGHL1J5ncHoseJoATLCnAWYo+yy6gdI2Fs9rj0tbeBcnoKwUZ
ENwEUp3En+Xw7zjtDuSDWW9ySkuwrK7nXrs0r1CPVf87dLBUEvdzHHUelDr6rdIY
tnieCjCHAgMBAAECggEBAJvTiAdQPzq4cVlAilTKLz7KTOsknFJlbj+9t5OdZZ9g
wKQIDE2sfEcti5O+Zlcl/eTaff39gN6lYR73gMEQ7h0J3U6cnsy+DzvDkpY94qyC
/ZYqUhPHBcnW3Mm0vNqNj0XGae15yBXjrKgSy9lUknSXJ3qMwQHeNL/DwA2KrfiL
g0iVjk32dvSSHWcBh0M+Qy1WyZU0cf9VWzx+Q1YLj9eUCHteStVubB610XV3JUZt
UTWiUCffpo2okHsTBuKPVXK/5BL+BpGplcxRSlnSbMaI611kN3iKlO8KGISXHBz7
nOPdkfZC9poEXt5SshtINuGGCCc8hDxpg1otYqCLaYECgYEA1MSCPs3pBkEagchV
g0rxYmDUC8QkeIOBuZFjhkdoUgZ6rFntyRZd1NbCUi3YBbV1YC12ZGohqWUWom1S
AtNbQ2ZTbqEnDKWbNvLBRwkdp/9cKBce85lCCD6+U2o2Ha8C0+hKeLBn8un1y0zY
1AQTqLAz9ItNr0aDPb89cs5voWcCgYEAxYdC8vR3t8iYMUnK6LWYDrKSt7YiorvF
qXIMANcXQrnO0ptC0B56qrUCgKHNrtPi5bGpNBJ0oKMfbmGfwX+ca8sCUlLvq/O8
S2WZwSJuaHH4lEBi8ErtY++8F4B4l3ENCT84Hyy5jiMpbpkHEnh/1GNcvvmyI8ud
3jzovCNZ4+ECgYEA0r+Oz0zAOzyzV8gqw7Cw5iRJBRqUkXaZQUj8jt4eO9lFG4C8
IolwCclrk2Drb8Qsbka51X62twZ1ZA/qwve9l0Y88ADaIBHNa6EKxyUFZglvrBoy
w1GT8XzMou06iy52G5YkZeU+IYOSvnvw7hjXrChUXi65lRrAFqJd6GEIe5MCgYA/
0LxDa9HFsWvh+JoyZoCytuSJr7Eu7AUnAi54kwTzzL3R8tE6Fa7BuesODbg6tD/I
v4YPyaqePzUnXyjSxdyOQq8EU8EUx5Dctv1elTYgTjnmA4szYLGjKM+WtC3Bl4eD
pkYGZFeqYRfAoHXVdNKvlk5fcKIpyF2/b+Qs7CrdYQKBgQCc/t+JxC9OpI+LhQtB
tEtwvklxuaBtoEEKJ76P9vrK1semHQ34M1XyNmvPCXUyKEI38MWtgCCXcdmg5syO
PBXdDINx+wKlW7LPgaiRL0Mi9G2aBpdFNI99CWVgCr88xqgSE24KsOxViMwmi0XB
Ld/IRK0DgpGP5EJRwpKsDYe/UQ==
-----END PRIVATE KEY-----"""
JWT_AUTH.update({
'JWT_ALGORITHM': 'HS256',
'JWT_SECRET_KEY': 'lms-secret',
......
;(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 @@
'js/spec/learner_dashboard/sidebar_view_spec.js',
'js/spec/learner_dashboard/program_card_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++) {
......
......@@ -119,7 +119,8 @@ var fixtureFiles = [
{pattern: 'templates/bookmarks/**/*.*', included: false},
{pattern: 'templates/learner_dashboard/**/*.*', 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.
......
......@@ -35,7 +35,8 @@
'support/js/certificates_factory',
'support/js/enrollment_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-request-header {
h1 {
@extend %t-title4;
margin-bottom: 0;
padding: $baseline;
@include text-align(left);
}
.api-access-request-subheading {
h2 {
@extend %t-title5;
margin: $baseline;
@include text-align(left);
}
.api-tos-body {
p {
@extend %t-copy-sub1;
margin: $baseline;
}
......@@ -40,64 +40,95 @@
@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 {
margin: 1.5*$baseline 0;
.api-form {
.helptext {
@extend %t-copy-sub1;
display: block;
}
}
padding: 0 $baseline $baseline $baseline;
label {
@extend %t-copy-base;
display: block;
font-style: normal;
p {
margin: 1.5*$baseline 0;
&.tos-checkbox-label {
display: inline-block;
.helptext {
@extend %t-copy-sub1;
display: block;
}
}
}
input, textarea {
@extend %t-copy-base;
font-family: 'Open Sans';
font-style: normal;
width: 300px;
label {
@extend %t-copy-base;
display: block;
font-style: normal;
}
&[type=checkbox] {
input[type=checkbox] + label {
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;
list-style-type: none;
li {
@extend %t-copy-base;
margin: 0;
color: $red;
}
}
li {
#api-access-submit, .preview-query {
@extend %t-copy-base;
margin: 0;
color: $red;
border-radius: 3px;
border: none;
background-color: $blue;
box-shadow: none;
background-image: none;
text-shadow: none;
text-transform: none;
}
}
}
#api-access-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;
}
.preview-results {
@include float(right);
width: 50%;
}
.preview-query {
display: block;
margin-top: $baseline/2;
}
.application-info {
......
{% extends "admin/base_site.html" %}
{% load admin_modify adminmedia %}
{% block extrahead %}
{{ block.super }}
{{ media }}
{% endblock %}
{% block extrastyle %}{{ block.super }}<link rel="stylesheet" type="text/css" href="{% admin_media_prefix %}css/forms.css" />{% endblock %}
{% block coltype %}colMS{% endblock %}
{% block bodyclass %} change-form{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="../../../">Home</a> &rsaquo;
<a href="../../">Api_Admin</a> &rsaquo;
<a href="../">Catalogs</a> &rsaquo;
{% if change %}
{{form.name}}
{% else %}
Add Catalog
{% endif %}
</div>
{% endblock %}
{% block content %}
<div id="content-main">
{% block object-tools %}
{% endblock %}
<form action="." method="post" enctype="multipart/form-data">
<fieldset class="module aligned {{ fieldset.classes }}">
{% for field in form.visible_fields %}
<div class="form-row">
{{ field.errors }}
{{ field.label_tag }}{{ field }}
{% if field.field.help_text %}<p class="help">{{ field.field.help_text|safe }}</p>{% endif %}
</div>
{% endfor %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
</fieldset>
<input type="submit" value="Save" />
</form>
</div>
{% endblock %}
{% extends "admin/base_site.html" %}
{% block extrahead %}
{{ block.super }}
{% endblock %}
{% block innercontent %}
<ul class="object-tools">
<li>
<a class="addlink" href="add/">Add Catalog</a>
</li>
</ul>
<table cellspacing="0" style="margin-top: 20px;">
<thead>
<tr>
<th></th>
<th>Name</th>
</tr>
</thead>
{% for catalog in catalogs %}
<tr class="{% cycle 'row1' 'row2' %}">
<td>&nbsp;</td>
<td>
<a href="{{catalog.id}}">{{catalog.name}}</a>
</td>
</tr>
{% endfor %}
</table>
{% endblock %}
......@@ -8,13 +8,17 @@
<%block name="pagetitle">${_("API Access Request")}</%block>
<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)}
</h1>
<form action="" method="post" class="api-management-form">
<input type="hidden" id="csrf_token" name="csrfmiddlewaretoken" value="${csrf_token}">
${form.as_p() | n}
<input id="api-access-submit" type="submit" value="${_('Request API Access')}"/>
</form>
<div class="catalog-body">
<div class="api-form-container">
<form action="" method="post" class="api-form">
<input type="hidden" id="csrf_token" name="csrfmiddlewaretoken" value="${csrf_token}">
${form.as_p() | n}
<input id="api-access-submit" type="submit" value="${_('Request API Access')}"/>
</form>
</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>
{% extends "admin/base_site.html" %}
{% load admin_modify staticfiles %}
{% block extrahead %}
{{ block.super }}
{{ media }}
{% endblock %}
{% block extrastyle %}{{ block.super }}<link rel="stylesheet" type="text/css" href="{% static 'css/forms.css' %}" />{% endblock %}
{% block coltype %}colMS{% endblock %}
{% block bodyclass %} change-form{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="../../../">Home</a> &rsaquo;
<a href="../../">Api_Admin</a> &rsaquo;
<a href="../">Catalogs</a> &rsaquo;
{% if change %}
{{form.name}}
{% else %}
Add Catalog
{% endif %}
</div>
{% endblock %}
{% block content %}
<div id="content-main">
{% block object-tools %}
{% endblock %}
<form action="." method="post" enctype="multipart/form-data">{% csrf_token %}
<fieldset class="module aligned {{ fieldset.classes }}">
{% for field in form.visible_fields %}
<div class="form-row">
{{ field.errors }}
{{ field.label_tag }}{{ field }}
{% if field.field.help_text %}<p class="help">{{ field.field.help_text|safe }}</p>{% endif %}
</div>
{% endfor %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
</fieldset>
<input type="submit" value="Save" />
</form>
</div>
{% endblock %}
{% extends "admin/base_site.html" %}
{% load i18n admin_urls static admin_list %}
{% block extrastyle %}
{{ block.super }}
<link rel="stylesheet" type="text/css" href="{% static "admin/css/changelists.css" %}" />
{% if cl.formset %}
<link rel="stylesheet" type="text/css" href="{% static "admin/css/forms.css" %}" />
{% endif %}
{% if cl.formset or action_form %}
<script type="text/javascript" src="{% url 'admin:jsi18n' %}"></script>
{% endif %}
{{ media.css }}
{% if not actions_on_top and not actions_on_bottom %}
<style>
#changelist table thead th:first-child {width: inherit}
</style>
{% endif %}
{% endblock %}
{% block extrahead %}
{{ block.super }}
{{ media.js }}
{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="../../../">Home</a> &rsaquo;
<a href="../../">Api_Admin</a> &rsaquo;
Catalogs
</div>
{% endblock %}
{% block coltype %}flex{% endblock %}
{% block content %}
{% block innercontent %}
<ul class="object-tools">
<li>
<a class="addlink" href="add/">Add Catalog</a>
</li>
</ul>
<table cellspacing="0" style="margin-top: 20px;">
<thead>
<tr>
<th></th>
<th>Name</th>
</tr>
</thead>
{% for catalog in catalogs %}
<tr class="{% cycle 'row1' 'row2' %}">
<td>&nbsp;</td>
<td>
<a href="{{catalog.id}}">{{catalog.name}}</a>
</td>
</tr>
{% endfor %}
</table>
{% endblock %}
{% endblock %}
## 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
%>
<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}">
<p id="api-access-status">
% if status == ApiAccessRequest.PENDING:
......@@ -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>
% endif
<form id="api-form-fields" method="post" class="api-management-form">
<input type="hidden" id="csrf_token" name="csrfmiddlewaretoken" value="${csrf_token}">
${form.as_p() | n}
<input id="api-access-submit" type="submit" value="${_('Generate API client credentials')}"/>
</form>
<div class="catalog-body">
<div class="api-form-container">
<form id="api-form-fields" method="post" class="api-form">
<input type="hidden" id="csrf_token" name="csrfmiddlewaretoken" value="${csrf_token}">
${form.as_p() | n}
<input id="api-access-submit" type="submit" value="${_('Generate API client credentials')}"/>
</form>
</div>
</div>
% endif
</p>
......
......@@ -15,5 +15,4 @@ class ApiAccessRequestAdmin(admin.ModelAdmin):
readonly_fields = ('user', 'website', 'reason', 'company_name', 'company_address', 'contacted', )
exclude = ('site',)
admin.site.register(ApiAccessConfig, ConfigurationModelAdmin)
"""Forms for API management."""
from django import forms
from django.contrib.auth.models import User
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
......@@ -32,3 +33,50 @@ class ApiAccessRequestForm(forms.ModelForm):
# Get rid of the colons at the end of the field labels.
kwargs.setdefault('label_suffix', '')
super(ApiAccessRequestForm, self).__init__(*args, **kwargs)
class ViewersWidget(forms.widgets.TextInput):
"""Form widget to display a comma-separated list of usernames."""
def render(self, name, value, attrs=None):
return super(ViewersWidget, self).render(name, ', '.join(value), attrs)
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,
},
),
]
......@@ -179,3 +179,45 @@ def _send_decision_email(instance):
instance.contacted = True
except SMTPException:
log.exception('Error sending API user notification email for request [%s].', instance.id)
class Catalog(models.Model):
"""A (non-Django-managed) model for Catalogs in the course discovery service."""
id = models.IntegerField(primary_key=True) # pylint: disable=invalid-name
name = models.CharField(max_length=255, null=False, blank=False)
query = models.TextField(null=False, blank=False)
viewers = models.TextField()
class Meta(object):
# Catalogs live in course discovery, so we do not create any
# tables in LMS. Instead we override the save method to not
# 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
@property
def attributes(self):
"""Return a dictionary representation of this catalog."""
return {
'id': self.id,
'name': self.name,
'query': self.query,
'viewers': self.viewers,
}
def __unicode__(self):
return u'Catalog {name} [{query}]'.format(name=self.name, query=self.query)
"""Factories for API management."""
import factory
from factory.fuzzy import FuzzyInteger, FuzzyText
from factory.django import DjangoModelFactory
from oauth2_provider.models import get_application_model
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
......@@ -27,3 +28,14 @@ class ApplicationFactory(DjangoModelFactory):
authorization_grant_type = Application.GRANT_CLIENT_CREDENTIALS
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
import unittest
import json
from urlparse import urljoin
import ddt
from django.conf import settings
from django.core.urlresolvers import reverse
from django.test import TestCase
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 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 student.tests.factories import UserFactory
Application = get_application_model() # pylint: disable=invalid-name
MOCK_CATALOG_API_URL_ROOT = 'https://api.example.com/'
class ApiAdminTest(TestCase):
......@@ -206,3 +214,169 @@ class ApiTosViewTest(ApiAdminTest):
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
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."""
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 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 = (
url(
......@@ -18,6 +22,42 @@ urlpatterns = (
name="api-tos"
),
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'^$',
api_access_enabled_or_404(login_required(ApiRequestView.as_view())),
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)
)
......@@ -4,6 +4,7 @@ import logging
from django.conf import settings
from django.contrib.sites.shortcuts import get_current_site
from django.core.urlresolvers import reverse_lazy, reverse
from django.http.response import JsonResponse
from django.shortcuts import redirect
from django.utils.translation import ugettext as _
from django.views.generic import View
......@@ -15,8 +16,9 @@ from oauth2_provider.views import ApplicationRegistration
from edxmako.shortcuts import render_to_response
from openedx.core.djangoapps.api_admin.decorators import require_api_access
from openedx.core.djangoapps.api_admin.forms import ApiAccessRequestForm
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest
from openedx.core.djangoapps.api_admin.forms import ApiAccessRequestForm, CatalogForm
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest, Catalog
from openedx.core.djangoapps.api_admin.utils import course_discovery_api_client
log = logging.getLogger(__name__)
......@@ -115,3 +117,112 @@ class ApiTosView(TemplateView):
"""View to show the API Terms of Service."""
template_name = 'api_admin/terms_of_service.html'
class CatalogSearchView(View):
"""View to search for catalogs belonging to a user."""
def get(self, request):
"""Display a form to search for catalogs belonging to a user."""
return render_to_response('api_admin/catalogs/search.html')
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(),
})
def post(self, request, username):
"""Create a new catalog for a user."""
form = CatalogForm(request.POST)
client = course_discovery_api_client(request.user)
if not form.is_valid():
response = client.api.v1.catalogs.get(username=username)
catalogs = [Catalog(attributes=catalog) for catalog in response['results']]
return render_to_response(self.template, {
'form': form,
'catalogs': catalogs,
'username': username,
'preview_url': reverse('api_admin:catalog-preview'),
'catalog_api_url': client.api.v1.courses.url(),
}, status=400)
attrs = form.instance.attributes
catalog = client.api.v1.catalogs.post(attrs)
return redirect(reverse('api_admin:catalog-edit', kwargs={'catalog_id': catalog['id']}))
class CatalogEditView(View):
"""View to edit an individual catalog."""
def get(self, request, catalog_id):
"""Display a form to edit this catalog."""
client = course_discovery_api_client(request.user)
response = client.api.v1.catalogs(catalog_id).get()
catalog = Catalog(attributes=response)
form = CatalogForm(instance=catalog)
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(),
})
def post(self, request, catalog_id):
"""Update or delete this catalog."""
client = course_discovery_api_client(request.user)
if request.POST.get('delete-catalog') == 'on':
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."""
def get(self, request):
"""
Return the results of a query against the course catalog API. If no
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)
""" Utils for RSA keys"""
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization import(
Encoding, PublicFormat, PrivateFormat, NoEncryption
)
def generate_rsa_key_pair(key_size=2048):
""" Generates a public and private RSA PEM encoded key pair"""
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=key_size,
backend=default_backend()
)
private_key_str = private_key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
public_key_str = private_key.public_key().public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo)
# Not intented for programmatic use, so we print the keys out
print public_key_str
print private_key_str
"""Utilities for working with ID tokens."""
import datetime
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
import jwt
......@@ -63,3 +65,51 @@ def get_id_token(user, client_name):
}
return jwt.encode(payload, client.client_secret)
def get_asymmetric_token(user, client_id):
"""Construct a JWT signed with this app's private key.
The JWT includes the following claims:
preferred_username (str): The user's username. The claim name is borrowed from edx-oauth2-provider.
name (str): The user's full name.
email (str): The user's email address.
administrator (Boolean): Whether the user has staff permissions.
iss (str): Registered claim. Identifies the principal that issued the JWT.
exp (int): Registered claim. Identifies the expiration time on or after which
the JWT must NOT be accepted for processing.
iat (int): Registered claim. Identifies the time at which the JWT was issued.
sub (int): Registered claim. Identifies the user. This implementation uses the raw user id.
Arguments:
user (User): User for which to generate the JWT.
Returns:
str: the JWT
"""
private_key = load_pem_private_key(settings.PRIVATE_RSA_KEY, None, default_backend())
try:
# Service users may not have user profiles.
full_name = UserProfile.objects.get(user=user).name
except UserProfile.DoesNotExist:
full_name = None
now = datetime.datetime.utcnow()
expires_in = getattr(settings, 'OAUTH_ID_TOKEN_EXPIRATION', 30)
payload = {
'preferred_username': user.username,
'name': full_name,
'email': user.email,
'administrator': user.is_staff,
'iss': settings.OAUTH_OIDC_ISSUER,
'exp': now + datetime.timedelta(seconds=expires_in),
'iat': now,
'aud': client_id,
'sub': anonymous_id_for_user(user, None),
}
return jwt.encode(payload, private_key, algorithm='RS512')
......@@ -10,6 +10,7 @@ bleach==1.4
html5lib==0.999
boto==2.39.0
celery==3.1.18
cryptography==1.3.1
cssselect==0.9.1
dealer==2.0.4
defusedxml==0.4.1
......
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