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 ...@@ -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_ISSUER = ENV_TOKENS.get('JWT_ISSUER', JWT_ISSUER)
JWT_EXPIRATION = ENV_TOKENS.get('JWT_EXPIRATION', JWT_EXPIRATION) JWT_EXPIRATION = ENV_TOKENS.get('JWT_EXPIRATION', JWT_EXPIRATION)
JWT_AUTH.update(ENV_TOKENS.get('JWT_AUTH', {})) 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 ################## ################# PROCTORING CONFIGURATION ##################
......
...@@ -2784,6 +2784,10 @@ LTI_AGGREGATE_SCORE_PASSBACK_DELAY = 15 * 60 ...@@ -2784,6 +2784,10 @@ LTI_AGGREGATE_SCORE_PASSBACK_DELAY = 15 * 60
JWT_EXPIRATION = 30 JWT_EXPIRATION = 30
JWT_ISSUER = None 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 # Credit notifications settings
NOTIFICATION_EMAIL_CSS = "templates/credit_notifications/credit_notification.css" NOTIFICATION_EMAIL_CSS = "templates/credit_notifications/credit_notification.css"
NOTIFICATION_EMAIL_EDX_LOGO = "templates/credit_notifications/edx-logo-header.png" NOTIFICATION_EMAIL_EDX_LOGO = "templates/credit_notifications/edx-logo-header.png"
......
...@@ -225,6 +225,47 @@ CORS_ORIGIN_WHITELIST = () ...@@ -225,6 +225,47 @@ CORS_ORIGIN_WHITELIST = ()
CORS_ORIGIN_ALLOW_ALL = True CORS_ORIGIN_ALLOW_ALL = True
# JWT settings for devstack # 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_AUTH.update({
'JWT_ALGORITHM': 'HS256', 'JWT_ALGORITHM': 'HS256',
'JWT_SECRET_KEY': 'lms-secret', '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 @@ ...@@ -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 {
......
{% 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 @@ ...@@ -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>
{% 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 ...@@ -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>
......
...@@ -15,5 +15,4 @@ class ApiAccessRequestAdmin(admin.ModelAdmin): ...@@ -15,5 +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.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
...@@ -32,3 +33,50 @@ class ApiAccessRequestForm(forms.ModelForm): ...@@ -32,3 +33,50 @@ class ApiAccessRequestForm(forms.ModelForm):
# Get rid of the colons at the end of the field labels. # Get rid of the colons at the end of the field labels.
kwargs.setdefault('label_suffix', '') kwargs.setdefault('label_suffix', '')
super(ApiAccessRequestForm, self).__init__(*args, **kwargs) 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): ...@@ -179,3 +179,45 @@ def _send_decision_email(instance):
instance.contacted = True instance.contacted = True
except SMTPException: except SMTPException:
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 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.""" """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)
)
...@@ -4,6 +4,7 @@ import logging ...@@ -4,6 +4,7 @@ import logging
from django.conf import settings from django.conf import settings
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.response import JsonResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.views.generic import View from django.views.generic import View
...@@ -15,8 +16,9 @@ from oauth2_provider.views import ApplicationRegistration ...@@ -15,8 +16,9 @@ from oauth2_provider.views import ApplicationRegistration
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
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 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.djangoapps.api_admin.utils import course_discovery_api_client
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -115,3 +117,112 @@ class ApiTosView(TemplateView): ...@@ -115,3 +117,112 @@ class ApiTosView(TemplateView):
"""View to show the API Terms of Service.""" """View to show the API Terms of Service."""
template_name = 'api_admin/terms_of_service.html' 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.""" """Utilities for working with ID tokens."""
import datetime 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.conf import settings
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
import jwt import jwt
...@@ -63,3 +65,51 @@ def get_id_token(user, client_name): ...@@ -63,3 +65,51 @@ def get_id_token(user, client_name):
} }
return jwt.encode(payload, client.client_secret) 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 ...@@ -10,6 +10,7 @@ bleach==1.4
html5lib==0.999 html5lib==0.999
boto==2.39.0 boto==2.39.0
celery==3.1.18 celery==3.1.18
cryptography==1.3.1
cssselect==0.9.1 cssselect==0.9.1
dealer==2.0.4 dealer==2.0.4
defusedxml==0.4.1 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