Commit bcde8e55 by Peter Fogg

Flesh out UI now that the backend is there.

parent 25df9db6
;(function (define) {
'use strict';
define(['js/api_admin/views/catalog_preview'], function (CatalogPreviewView) {
return function (options) {
var view = new CatalogPreviewView({
el: '.catalog-body',
previewUrl: options.previewUrl,
catalogApiUrl: options.catalogApiUrl,
});
return view.render();
};
});
}).call(this, define || RequireJS.define);
;(function(define) {
'use strict';
define([
'backbone',
'underscore',
'gettext',
'text!../../../templates/api_admin/catalog-results.underscore',
'text!../../../templates/api_admin/catalog-error.underscore'
], function (Backbone, _, gettext, catalogResultsTpl, catalogErrorTpl) {
return Backbone.View.extend({
events: {
'click .preview-query': 'previewQuery'
},
initialize: function (options) {
this.previewUrl = options.previewUrl;
this.catalogApiUrl = options.catalogApiUrl;
},
render: function () {
this.$('#id_query').after(
'<button class="preview-query">'+ gettext('Preview this query') + '</button>'
);
return this;
},
/*
* Return the user's query, URL-encoded.
*/
getQuery: function () {
return encodeURIComponent(this.$("#id_query").val());
},
/*
* Make a request to get the list of courses associated
* with the user's query. On success, displays the
* results, and on failure, displays an error message.
*/
previewQuery: function (event) {
event.preventDefault();
$.ajax(this.previewUrl + '?q=' + this.getQuery(), {
method: 'GET',
success: _.bind(this.renderCourses, this),
error: _.bind(function () {
this.$('.preview-results').html(_.template(catalogErrorTpl)({}));
}, this)
});
},
/*
* Render a list of courses with data returned by the
* courses API.
*/
renderCourses: function (data) {
this.$('.preview-results').html(_.template(catalogResultsTpl)({
'courses': data.results,
'catalogApiUrl': this.catalogApiUrl,
}));
},
});
});
}).call(this, define || RequireJS.define);
define([
'js/api_admin/views/catalog_preview',
'common/js/spec_helpers/ajax_helpers',
], function (
CatalogPreviewView, AjaxHelpers
) {
'use strict';
describe('Catalog preview view', function () {
var view,
previewUrl = 'http://example.com/api-admin/catalogs/preview/',
catalogApiUrl = 'http://api.example.com/catalog/v1/courses/';
beforeEach(function () {
setFixtures(
'<div class="catalog-body">' +
'<textarea id="id_query"></textarea>' +
'<div class="preview-results"></div>' +
'</div>'
);
view = new CatalogPreviewView({
el: '.catalog-body',
previewUrl: previewUrl,
catalogApiUrl: catalogApiUrl,
});
view.render();
});
it('can render itself', function () {
expect(view.$('button.preview-query').length).toBe(1);
});
it('can retrieve a list of catalogs and display them', function () {
var requests = AjaxHelpers.requests(this);
view.$('#id_query').val('*');
view.$('.preview-query').click();
AjaxHelpers.expectRequest(requests, 'GET', previewUrl + '?q=*');
AjaxHelpers.respondWithJson(requests, {
results: [{key: 'TestX', title: 'Test Course'}],
count: 1,
next: null,
prev: null,
});
expect(view.$('.preview-results').text()).toContain('Test Course');
expect(view.$('.preview-results-list li a').attr('href')).toEqual(catalogApiUrl + 'TestX');
});
it('displays an error when courses cannot be retrieved', function () {
var requests = AjaxHelpers.requests(this);
view.$('#id_query').val('*');
view.$('.preview-query').click();
AjaxHelpers.respondWithError(requests, 500);
expect(view.$('.preview-results').text()).toContain(
'There was an error retrieving preview results for this catalog.'
);
});
});
});
......@@ -769,7 +769,8 @@
'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 {
......
......@@ -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>
## 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>
......
......@@ -7,99 +7,99 @@ from django.utils.translation import ugettext as _
%>
<div id="api-access-wrapper">
<h1 id="api-access-request-header">${_("Terms of Service for {platform_name} APIs").format(platform_name=settings.PLATFORM_NAME)}</h1>
<h2 class="api-access-request-subheading">${_("Effective Date: April 12th, 2016")}</h2>
<h1 id="api-header">${_("Terms of Service for {platform_name} APIs").format(platform_name=settings.PLATFORM_NAME)}</h1>
<h2 class="api-subheading">${_("Effective Date: April 12th, 2016")}</h2>
<p class="api-tos-body">${_("Welcome to {platform_name}. Thank you for using {platform_name}'s Course Discovery API and any additional APIs that we may offer from time to time (collectively, the \"APIs\"). Please read these Terms of Service prior to accessing or using the APIs. These Terms of Service, any additional terms within accompanying API documentation, and any applicable policies and guidelines that {platform_name} makes available and/or updates from time to time are agreements (collectively, the \"Terms\") between you and {platform_name}. By accessing or using the APIs, you accept and agree to be legally bound by the Terms, whether or not you are a registered user. If you do not understand or do not wish to be bound by the Terms, you should not use the APIs.").format(platform_name=settings.PLATFORM_NAME)}</p>
<p class="api-copy-body">${_("Welcome to {platform_name}. Thank you for using {platform_name}'s Course Discovery API and any additional APIs that we may offer from time to time (collectively, the \"APIs\"). Please read these Terms of Service prior to accessing or using the APIs. These Terms of Service, any additional terms within accompanying API documentation, and any applicable policies and guidelines that {platform_name} makes available and/or updates from time to time are agreements (collectively, the \"Terms\") between you and {platform_name}. By accessing or using the APIs, you accept and agree to be legally bound by the Terms, whether or not you are a registered user. If you do not understand or do not wish to be bound by the Terms, you should not use the APIs.").format(platform_name=settings.PLATFORM_NAME)}</p>
<h2 class="api-access-request-subheading">${_("API Access")}</h2>
<h2 class="api-subheading">${_("API Access")}</h2>
<p class="api-tos-body">${_("To access the APIs, you will need to create an {platform_name} user account for your application (not for personal use). This account will provide you with access to our API request page at {request_url}. On that page, you must complete the API request form including a description of your proposed uses for the APIs. Any account and registration information that you provide to {platform_name} must be accurate and up to date, and you agree to inform us promptly of any changes. {platform_name} will review your API request form and, upon approval in {platform_name}'s sole discretion, will provide you with instructions for obtaining your API shared secret and client ID.").format(platform_name=settings.PLATFORM_NAME, request_url=reverse('api_admin:api-request'))}</p>
<p class="api-copy-body">${_("To access the APIs, you will need to create an {platform_name} user account for your application (not for personal use). This account will provide you with access to our API request page at {request_url}. On that page, you must complete the API request form including a description of your proposed uses for the APIs. Any account and registration information that you provide to {platform_name} must be accurate and up to date, and you agree to inform us promptly of any changes. {platform_name} will review your API request form and, upon approval in {platform_name}'s sole discretion, will provide you with instructions for obtaining your API shared secret and client ID.").format(platform_name=settings.PLATFORM_NAME, request_url=reverse('api_admin:api-request'))}</p>
<h2 class="api-access-request-subheading">${_("Permissible Use")}</h2>
<h2 class="api-subheading">${_("Permissible Use")}</h2>
<p class="api-tos-body">${_("You agree to use the APIs solely for the purpose of delivering content that is accessed through the APIs (the \"API Content\") to your own website, mobile site, app, blog, email distribution list, or social media property or for another commercial use that you described in your request for access and that {platform_name} has approved on a case-by-case basis. {platform_name} may monitor your use of the APIs for compliance with the Terms and may deny your access or shut down your integration if you try to go around or exceed the requirements and limitations set by {platform_name}. Your Application or other approved use of the API or the API Content must not prompt your end users to provide their {platform_name} username, password or other {platform_name} user credentials anywhere other than the {platform_name} website at {platform_url}.").format(platform_name=settings.PLATFORM_NAME, platform_url='TODO')}</p>
<p class="api-copy-body">${_("You agree to use the APIs solely for the purpose of delivering content that is accessed through the APIs (the \"API Content\") to your own website, mobile site, app, blog, email distribution list, or social media property or for another commercial use that you described in your request for access and that {platform_name} has approved on a case-by-case basis. {platform_name} may monitor your use of the APIs for compliance with the Terms and may deny your access or shut down your integration if you try to go around or exceed the requirements and limitations set by {platform_name}. Your Application or other approved use of the API or the API Content must not prompt your end users to provide their {platform_name} username, password or other {platform_name} user credentials anywhere other than the {platform_name} website at {platform_url}.").format(platform_name=settings.PLATFORM_NAME, platform_url='TODO')}</p>
<h2 class="api-access-request-subheading">${_("Prohibited Uses and Activities")}</h2>
<h2 class="api-subheading">${_("Prohibited Uses and Activities")}</h2>
<p class="api-tos-body">${_("{platform_name} shall have the sole right to determine whether or not any given use of the APIs is acceptable, and {platform_name} reserves the right to revoke API access for any use that {platform_name} determines at any time, in its sole discretion, does not benefit or serve the best interests of {platform_name}, its users and its partners.").format(platform_name=settings.PLATFORM_NAME)}</p>
<p class="api-copy-body">${_("{platform_name} shall have the sole right to determine whether or not any given use of the APIs is acceptable, and {platform_name} reserves the right to revoke API access for any use that {platform_name} determines at any time, in its sole discretion, does not benefit or serve the best interests of {platform_name}, its users and its partners.").format(platform_name=settings.PLATFORM_NAME)}</p>
<p class="api-tos-body">${_("The following activities are not acceptable when using the APIs (this is not an exhaustive list):")}</p>
<p class="api-copy-body">${_("The following activities are not acceptable when using the APIs (this is not an exhaustive list):")}</p>
<ul>
<li class="api-tos-body">${_("collecting or storing the names, passwords, or other credentials of {platform_name} users;").format(platform_name=settings.PLATFORM_NAME)}</li>
<li class="api-tos-body">${_("scraping or similar techniques to aggregate or otherwise create permanent copies of API Content;")}</li>
<li class="api-tos-body">${_("violating, misappropriating or infringing any copyright, trademark rights, rights of privacy or publicity, confidential information or any other right of any third party;")}</li>
<li class="api-tos-body">${_("altering or removing any trademark, copyright or other proprietary or legal notices contained in, or appearing on, the APIs or any API Content;")}</li>
<li class="api-tos-body">${_("altering or editing any content or graphics in the API Content")}</li>
<li class="api-tos-body">${_("sublicensing, re-distributing, renting, selling or leasing access to the APIs or your client secret to any third party;")}</li>
<li class="api-tos-body">${_("distributing any virus, Trojan horse, spyware, adware, malware, bot, time bomb, worm, or other harmful or malicious component; or")}</li>
<li class="api-tos-body">${_("using the APIs for any purpose which or might overburden, impair or disrupt the {platform_name} platform, servers or networks.").format(platform_name=settings.PLATFORM_NAME)}</li>
<li class="api-copy-body">${_("collecting or storing the names, passwords, or other credentials of {platform_name} users;").format(platform_name=settings.PLATFORM_NAME)}</li>
<li class="api-copy-body">${_("scraping or similar techniques to aggregate or otherwise create permanent copies of API Content;")}</li>
<li class="api-copy-body">${_("violating, misappropriating or infringing any copyright, trademark rights, rights of privacy or publicity, confidential information or any other right of any third party;")}</li>
<li class="api-copy-body">${_("altering or removing any trademark, copyright or other proprietary or legal notices contained in, or appearing on, the APIs or any API Content;")}</li>
<li class="api-copy-body">${_("altering or editing any content or graphics in the API Content")}</li>
<li class="api-copy-body">${_("sublicensing, re-distributing, renting, selling or leasing access to the APIs or your client secret to any third party;")}</li>
<li class="api-copy-body">${_("distributing any virus, Trojan horse, spyware, adware, malware, bot, time bomb, worm, or other harmful or malicious component; or")}</li>
<li class="api-copy-body">${_("using the APIs for any purpose which or might overburden, impair or disrupt the {platform_name} platform, servers or networks.").format(platform_name=settings.PLATFORM_NAME)}</li>
</ul>
<h2 class="api-access-request-subheading">${_("Usage and Quotas")}</h2>
<h2 class="api-subheading">${_("Usage and Quotas")}</h2>
<p class="api-tos-body">${_("{platform_name} reserves the right, in its discretion, to impose restrictions and limitations on the number and frequency of calls made by you or your Application to the APIs. You must not attempt to circumvent any restrictions or limitations that we impose.").format(platform_name=settings.PLATFORM_NAME)}</p>
<p class="api-copy-body">${_("{platform_name} reserves the right, in its discretion, to impose restrictions and limitations on the number and frequency of calls made by you or your Application to the APIs. You must not attempt to circumvent any restrictions or limitations that we impose.").format(platform_name=settings.PLATFORM_NAME)}</p>
<h2 class="api-access-request-subheading">${_("Compliance")}</h2>
<h2 class="api-subheading">${_("Compliance")}</h2>
<p class="api-tos-body">${_("You agree to comply with all applicable law, regulation, and third party rights (including without limitation laws regarding the import or export of data or software, privacy, copyright, and local laws). You will not use the APIs to encourage or promote illegal activity or violation of third party rights. You will not violate any other terms of service with {platform_name}. You will only access (or attempt to access) an API by the means described in the documentation of that API. You will not misrepresent or mask either your identity or yourApplication's identity when using the APIs.").format(platform_name=settings.PLATFORM_NAME)}</p>
<p class="api-copy-body">${_("You agree to comply with all applicable law, regulation, and third party rights (including without limitation laws regarding the import or export of data or software, privacy, copyright, and local laws). You will not use the APIs to encourage or promote illegal activity or violation of third party rights. You will not violate any other terms of service with {platform_name}. You will only access (or attempt to access) an API by the means described in the documentation of that API. You will not misrepresent or mask either your identity or yourApplication's identity when using the APIs.").format(platform_name=settings.PLATFORM_NAME)}</p>
<h2 class="api-access-request-subheading">${_("Ownership")}</h2>
<h2 class="api-subheading">${_("Ownership")}</h2>
<p class="api-tos-body">${_("You acknowledge and agree that the APIs and all API Content contain valuable intellectual property of {platform_name} and its partners. The APIs and all API Content are protected by United States and foreign copyright, trademark, and other laws. All rights in the APIs and the API Content, if not expressly granted, are reserved. By using the APIs or any API Content, you do not acquire ownership of any rights in the APIs or API Content. You must not claim or attempt to claim ownership in the APIs or any API Content or misrepresent yourself or your company or your Application as being the source of any API Content. You may not modify, create derivative works of, or attempt to use, license, or in any way exploit any API Content in whole or in part on your own behalf or on behalf of any third party. You may not distribute or modify the APIs or any API Content (including adaptation, editing, excerpting, or creating derivative works).")}</p>
<p class="api-copy-body">${_("You acknowledge and agree that the APIs and all API Content contain valuable intellectual property of {platform_name} and its partners. The APIs and all API Content are protected by United States and foreign copyright, trademark, and other laws. All rights in the APIs and the API Content, if not expressly granted, are reserved. By using the APIs or any API Content, you do not acquire ownership of any rights in the APIs or API Content. You must not claim or attempt to claim ownership in the APIs or any API Content or misrepresent yourself or your company or your Application as being the source of any API Content. You may not modify, create derivative works of, or attempt to use, license, or in any way exploit any API Content in whole or in part on your own behalf or on behalf of any third party. You may not distribute or modify the APIs or any API Content (including adaptation, editing, excerpting, or creating derivative works).")}</p>
<p class="api-tos-body">${_("All names, logos and seals (\"Trademarks\") that appear in the APIs, API Content, or on or through the services made available on or through the APIs, if any, are the property of their respective owners. You may not remove, alter, or obscure any copyright, Trademark, or other proprietary rightrs notices incorporated in or accompanying the API Content. If any third party revokes access to API Content owned or controlled by that third party, including without limitation any Trademarks, you must ensure that all API Content pertaining to that third party is deleted from your app, networks, systems and servers as soon as reasonably possible. If you stop using the APIs altogether or if your API access is revoked, you must delete all API Content in the same way.")}</p>
<p class="api-copy-body">${_("All names, logos and seals (\"Trademarks\") that appear in the APIs, API Content, or on or through the services made available on or through the APIs, if any, are the property of their respective owners. You may not remove, alter, or obscure any copyright, Trademark, or other proprietary rightrs notices incorporated in or accompanying the API Content. If any third party revokes access to API Content owned or controlled by that third party, including without limitation any Trademarks, you must ensure that all API Content pertaining to that third party is deleted from your app, networks, systems and servers as soon as reasonably possible. If you stop using the APIs altogether or if your API access is revoked, you must delete all API Content in the same way.")}</p>
<p class="api-tos-body">${_("To the extent that you submit any content to {platform_name} in connection with your use of the APIs or any API Content, you hereby grant to {platform_name} a worldwide, non-exclusive, transferable, assignable, sub licensable, fully paid-up, royalty-free, perpetual, irrevocable right and license to host, transfer, display, perform, reproduce, modify, distribute, re-distribute, relicense and otherwise use, make available and exploit such content, in whole or in part, in any form and in any media formats and through any media channels (now known or hereafter developed).").format(platform_name=settings.PLATFORM_NAME)}</p>
<p class="api-copy-body">${_("To the extent that you submit any content to {platform_name} in connection with your use of the APIs or any API Content, you hereby grant to {platform_name} a worldwide, non-exclusive, transferable, assignable, sub licensable, fully paid-up, royalty-free, perpetual, irrevocable right and license to host, transfer, display, perform, reproduce, modify, distribute, re-distribute, relicense and otherwise use, make available and exploit such content, in whole or in part, in any form and in any media formats and through any media channels (now known or hereafter developed).").format(platform_name=settings.PLATFORM_NAME)}</p>
<h2 class="api-access-request-subheading">${_("Privacy")}</h2>
<h2 class="api-subheading">${_("Privacy")}</h2>
<p class="api-tos-body">${_("You agree to comply with all applicable privacy laws and regulations and to be transparent with respect to any collection and use of end user data. You will provide and adhere to a privacy policy for your Application that clearly and accurately describes to your end users what user information you collect and how you may use and share such information (including for advertising) with {platform_name} and other third parties.").format(platform_name=settings.PLATFORM_NAME)}</p>
<p class="api-copy-body">${_("You agree to comply with all applicable privacy laws and regulations and to be transparent with respect to any collection and use of end user data. You will provide and adhere to a privacy policy for your Application that clearly and accurately describes to your end users what user information you collect and how you may use and share such information (including for advertising) with {platform_name} and other third parties.").format(platform_name=settings.PLATFORM_NAME)}</p>
<h2 class="api-access-request-subheading">${_("Right to Charge")}</h2>
<h2 class="api-subheading">${_("Right to Charge")}</h2>
<p class="api-tos-body">${_("{platform_name} reserves the right to modify the Terms at any time without advance notice. Any changes to the Terms will be effective immediately upon posting on this page, with an updated effective date. By accessing or using the APIs after any changes have been made, you signify your agreement on a prospective basis to the modified Terms and all of the changes. Be sure to return to this page periodically to ensure familiarity with the most current version of the Terms.").format(platform_name=settings.PLATFORM_NAME)}</p>
<p class="api-copy-body">${_("{platform_name} reserves the right to modify the Terms at any time without advance notice. Any changes to the Terms will be effective immediately upon posting on this page, with an updated effective date. By accessing or using the APIs after any changes have been made, you signify your agreement on a prospective basis to the modified Terms and all of the changes. Be sure to return to this page periodically to ensure familiarity with the most current version of the Terms.").format(platform_name=settings.PLATFORM_NAME)}</p>
<p class="api-tos-body">${_("{platform_name} may also update or modify the APIs from time to time without advance notice. These changes may affect your use of the APIs or the way your integration interacts with the API. If we make a change that is unacceptable to you, you should stop using the APIs. Continued use of the APIs means you accept the change.").format(platform_name=settings.PLATFORM_NAME)}</p>
<p class="api-copy-body">${_("{platform_name} may also update or modify the APIs from time to time without advance notice. These changes may affect your use of the APIs or the way your integration interacts with the API. If we make a change that is unacceptable to you, you should stop using the APIs. Continued use of the APIs means you accept the change.").format(platform_name=settings.PLATFORM_NAME)}</p>
<h2 class="api-access-request-subheading">${_("Confidentiality")}</h2>
<h2 class="api-subheading">${_("Confidentiality")}</h2>
<p class="api-tos-body">${_("Your credentials (such as client secret and IDs) are intended to be used solely by you. You will keep your credentials confidential and discourage others from using your credentials. Your credentials may not be embedded in open source projects.")}</p>
<p class="api-copy-body">${_("Your credentials (such as client secret and IDs) are intended to be used solely by you. You will keep your credentials confidential and discourage others from using your credentials. Your credentials may not be embedded in open source projects.")}</p>
<p class="api-tos-body">${_("In the event that {platform_name} provides you with access to information specific to {platform_name} and/or the APIs that is either marked as \"Confidential\" or which a reasonable person would assume to be confidential or proprietary given the terms of its disclosure (\"Confidential Information\"), you agree to use this information only to use and build with the APIs. You may not disclose the Confidential Information to anyone without {platform_name}'s prior written consent, and you agree to protect the Confidential Information from unauthorized use and disclosure in the same way that you would protect your own confidential information. Confidential information does not include information that you independently developed, that was rightfully given to you by a third party without confidentiality obligation, or that becomes public through no fault of your own. You may disclose Confidential Information when compelled to do so by law if you provide {platform_name} with reasonable prior notice, unless a court orders that {platform_name} not receive notice.").format(platform_name=settings.PLATFORM_NAME)}</p>
<p class="api-copy-body">${_("In the event that {platform_name} provides you with access to information specific to {platform_name} and/or the APIs that is either marked as \"Confidential\" or which a reasonable person would assume to be confidential or proprietary given the terms of its disclosure (\"Confidential Information\"), you agree to use this information only to use and build with the APIs. You may not disclose the Confidential Information to anyone without {platform_name}'s prior written consent, and you agree to protect the Confidential Information from unauthorized use and disclosure in the same way that you would protect your own confidential information. Confidential information does not include information that you independently developed, that was rightfully given to you by a third party without confidentiality obligation, or that becomes public through no fault of your own. You may disclose Confidential Information when compelled to do so by law if you provide {platform_name} with reasonable prior notice, unless a court orders that {platform_name} not receive notice.").format(platform_name=settings.PLATFORM_NAME)}</p>
<h2 class="api-access-request-subheading">${_("Disclaimer of Warranty / Limitation of Liabilities")}</h2>
<h2 class="api-subheading">${_("Disclaimer of Warranty / Limitation of Liabilities")}</h2>
<p class="api-tos-body">${_("THE APIS AND ANY INFORMATION, API CONTENT OR SERVICES MADE AVAILABLE ON OR THROUGH THE APIS ARE PROVIDED \"AS IS\" AND \"AS AVAILABLE\" WITHOUT WARRANTY OF ANY KIND (EXPRESS, IMPLIED OR OTHERWISE), INCLUDING, WITHOUT LIMITATION, ANY IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT, EXCEPT INSOFAR AS ANY SUCH IMPLIED WARRANTIES MAY NOT BE DISCLAIMED UNDER APPLICABLE LAW.")}</p>
<p class="api-copy-body">${_("THE APIS AND ANY INFORMATION, API CONTENT OR SERVICES MADE AVAILABLE ON OR THROUGH THE APIS ARE PROVIDED \"AS IS\" AND \"AS AVAILABLE\" WITHOUT WARRANTY OF ANY KIND (EXPRESS, IMPLIED OR OTHERWISE), INCLUDING, WITHOUT LIMITATION, ANY IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT, EXCEPT INSOFAR AS ANY SUCH IMPLIED WARRANTIES MAY NOT BE DISCLAIMED UNDER APPLICABLE LAW.")}</p>
<p class="api-tos-body">${_("{platform_name} AND THE {platform_name} PARTICIPANTS (AS HERINAFTER DEFINED) DO NOT WARRANT THAT THE APIS WILL OPERATE IN AN UNINTERRUPTED OR ERROR-FREE MANNER, THAT THE APIS ARE FREE OF VIRUSES OR OTHER HARMFUL COMPONENTS, OR THAT THE APIS OR API CONTENT PROVIDED WILL MEET YOUR NEEDS OR EXPECTATIONS. {platform_name} AND THE {platform_name} PARTICIPANTS ALSO MAKE NO WARRANTY ABOUT THE ACCURACY, COMPLETENESS, TIMELINESS, OR QUALITY OF THE APIS OR ANY API CONTENT, OR THAT ANY PARTICULAR API CONTENT WILL CONTINUE TO BE MADE AVAILABLE. \"{platform_name} PARTICIPANTS\" MEANS MIT, HARVARD, THE OTHER MEMBERS, THE ENTITIES PROVIDING INFORMATION, API CONTENT OR SERVICES FOR THE APIS, THE COURSE INSTRUCTORS AND THEIR STAFFS.").format(platform_name=settings.PLATFORM_NAME.upper())}</p>
<p class="api-copy-body">${_("{platform_name} AND THE {platform_name} PARTICIPANTS (AS HERINAFTER DEFINED) DO NOT WARRANT THAT THE APIS WILL OPERATE IN AN UNINTERRUPTED OR ERROR-FREE MANNER, THAT THE APIS ARE FREE OF VIRUSES OR OTHER HARMFUL COMPONENTS, OR THAT THE APIS OR API CONTENT PROVIDED WILL MEET YOUR NEEDS OR EXPECTATIONS. {platform_name} AND THE {platform_name} PARTICIPANTS ALSO MAKE NO WARRANTY ABOUT THE ACCURACY, COMPLETENESS, TIMELINESS, OR QUALITY OF THE APIS OR ANY API CONTENT, OR THAT ANY PARTICULAR API CONTENT WILL CONTINUE TO BE MADE AVAILABLE. \"{platform_name} PARTICIPANTS\" MEANS MIT, HARVARD, THE OTHER MEMBERS, THE ENTITIES PROVIDING INFORMATION, API CONTENT OR SERVICES FOR THE APIS, THE COURSE INSTRUCTORS AND THEIR STAFFS.").format(platform_name=settings.PLATFORM_NAME.upper())}</p>
<p class="api-tos-body">${_("USE OF THE APIS, AND THE API CONTENT AND ANY SERVICES OBTAINED FROM OR THROUGH THE APIS, IS AT YOUR OWN RISK. YOUR ACCESS TO OR DOWNLOAD OF INFORMATION, MATERIALS OR DATA THROUGH THE APIS IS AT YOUR OWN DISCRETION AND RISK, AND YOU WILL BE SOLELY RESPONSIBLE FOR ANY DAMAGE TO YOUR PROPERTY (INCLUDING YOUR COMPUTER SYSTEM) OR LOSS OF DATA THAT RESULTS FROM THE DOWNLOAD OR USE OF SUCH MATERIAL OR DATA, UNLESS OTHERWISE EXPRESSLY PROVIDED FOR IN THE {platform_name} PRIVACY POLICY.").format(platform_name=settings.PLATFORM_NAME.upper())}</p>
<p class="api-copy-body">${_("USE OF THE APIS, AND THE API CONTENT AND ANY SERVICES OBTAINED FROM OR THROUGH THE APIS, IS AT YOUR OWN RISK. YOUR ACCESS TO OR DOWNLOAD OF INFORMATION, MATERIALS OR DATA THROUGH THE APIS IS AT YOUR OWN DISCRETION AND RISK, AND YOU WILL BE SOLELY RESPONSIBLE FOR ANY DAMAGE TO YOUR PROPERTY (INCLUDING YOUR COMPUTER SYSTEM) OR LOSS OF DATA THAT RESULTS FROM THE DOWNLOAD OR USE OF SUCH MATERIAL OR DATA, UNLESS OTHERWISE EXPRESSLY PROVIDED FOR IN THE {platform_name} PRIVACY POLICY.").format(platform_name=settings.PLATFORM_NAME.upper())}</p>
<p class="api-tos-body">${_("TO THE FULLEST EXTENT PERMITTED BY APPLICABLE LAW, YOU AGREE THAT NEITHER {platform_name} NOR ANY OF THE {platform_name} PARTICIPANTS WILL BE LIABLE TO YOU FOR ANY LOSS OR DAMAGES, EITHER ACTUAL OR CONSEQUENTIAL, ARISING OUT OF OR RELATING TO THESE TERMS, OR YOUR (OR ANY THIRD PARTY'S) USE OF OR INABILITY TO USE THE APIS OR ANY API CONTENT, OR YOUR RELIANCE UPON INFORMATION OBTAINED FROM OR THROUGH THE APIS, WHETHER YOUR CLAIM IS BASED IN CONTRACT, TORT, STATUTORY OR OTHER LAW.").format(platform_name=settings.PLATFORM_NAME.upper())}</p>
<p class="api-copy-body">${_("TO THE FULLEST EXTENT PERMITTED BY APPLICABLE LAW, YOU AGREE THAT NEITHER {platform_name} NOR ANY OF THE {platform_name} PARTICIPANTS WILL BE LIABLE TO YOU FOR ANY LOSS OR DAMAGES, EITHER ACTUAL OR CONSEQUENTIAL, ARISING OUT OF OR RELATING TO THESE TERMS, OR YOUR (OR ANY THIRD PARTY'S) USE OF OR INABILITY TO USE THE APIS OR ANY API CONTENT, OR YOUR RELIANCE UPON INFORMATION OBTAINED FROM OR THROUGH THE APIS, WHETHER YOUR CLAIM IS BASED IN CONTRACT, TORT, STATUTORY OR OTHER LAW.").format(platform_name=settings.PLATFORM_NAME.upper())}</p>
<p class="api-tos-body">${_("IN PARTICULAR, TO THE FULLEST EXTENT PERMITTED BY APPLICABLE LAW, NEITHER {platform_name} NOR ANY OF THE {platform_name} PARTICIPANTS WILL HAVE ANY LIABILITY FOR ANY CONSEQUENTIAL, INDIRECT, PUNITIVE, SPECIAL, EXEMPLARY OR INCIDENTAL DAMAGES, WHETHER FORESEEABLE OR UNFORESEEABLE AND WHETHER OR NOT {platform_name} OR ANY OF THE {platform_name} PARTICIPANTS HAS BEEN NEGLIGENT OR OTHERWISE AT FAULT (INCLUDING, BUT NOT LIMITED TO, CLAIMS FOR DEFAMATION, ERRORS, LOSS OF PROFITS, LOSS OF DATA OR INTERRUPTION IN AVAILABILITY OF DATA).").format(platform_name=settings.PLATFORM_NAME.upper())}</p>
<p class="api-copy-body">${_("IN PARTICULAR, TO THE FULLEST EXTENT PERMITTED BY APPLICABLE LAW, NEITHER {platform_name} NOR ANY OF THE {platform_name} PARTICIPANTS WILL HAVE ANY LIABILITY FOR ANY CONSEQUENTIAL, INDIRECT, PUNITIVE, SPECIAL, EXEMPLARY OR INCIDENTAL DAMAGES, WHETHER FORESEEABLE OR UNFORESEEABLE AND WHETHER OR NOT {platform_name} OR ANY OF THE {platform_name} PARTICIPANTS HAS BEEN NEGLIGENT OR OTHERWISE AT FAULT (INCLUDING, BUT NOT LIMITED TO, CLAIMS FOR DEFAMATION, ERRORS, LOSS OF PROFITS, LOSS OF DATA OR INTERRUPTION IN AVAILABILITY OF DATA).").format(platform_name=settings.PLATFORM_NAME.upper())}</p>
<p class="api-tos-body">${_("CERTAIN STATE LAWS DO NOT ALLOW LIMITATIONS ON IMPLIED WARRANTIES OR THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES. IF THESE LAWS APPLY TO YOU, SOME OR ALL OF THE ABOVE DISCLAIMERS, EXCLUSIONS, OR LIMITATIONS MAY NOT APPLY TO YOU, AND YOU MIGHT HAVE ADDITIONAL RIGHTS.")}</p>
<p class="api-copy-body">${_("CERTAIN STATE LAWS DO NOT ALLOW LIMITATIONS ON IMPLIED WARRANTIES OR THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES. IF THESE LAWS APPLY TO YOU, SOME OR ALL OF THE ABOVE DISCLAIMERS, EXCLUSIONS, OR LIMITATIONS MAY NOT APPLY TO YOU, AND YOU MIGHT HAVE ADDITIONAL RIGHTS.")}</p>
<p class="api-tos-body">${_("The APIs and API Content may include hyperlinks to sites maintained or controlled by others. {platform_name} and the {platform_name} Participants are not responsible for and do not routinely screen, approve, review or endorse the contents of or use of any of the products or services that may be offered at these sites. If you decide to access linked third-party websites, you do so at your own risk.").format(platform_name=settings.PLATFORM_NAME)}</p>
<p class="api-copy-body">${_("The APIs and API Content may include hyperlinks to sites maintained or controlled by others. {platform_name} and the {platform_name} Participants are not responsible for and do not routinely screen, approve, review or endorse the contents of or use of any of the products or services that may be offered at these sites. If you decide to access linked third-party websites, you do so at your own risk.").format(platform_name=settings.PLATFORM_NAME)}</p>
<h2 class="api-access-request-subheading">Indemnification</h2>
<h2 class="api-subheading">Indemnification</h2>
<p class="api-tos-body">${_("To the maximum extent permitted by applicable law, you agree to defend, hold harmless and indemnify {platform_name} and the {platform_name} Participants, and their respective subsidiaries, affiliates, officers, faculty, students, fellows, governing board members, agents and employees from and against any third-party claims, actions or demands arising out of, resulting from or in any way related to your use of the APIs and any API Content, including any liability or expense arising from any and all claims, losses, damages (actual and consequential), suits, judgments, litigation costs and attorneys' fees, of every kind and nature. In such a case, {platform_name} or one of the {platform_name} Participants will provide you with written notice of such claim, suit or action.").format(platform_name=settings.PLATFORM_NAME)}/</p>
<p class="api-copy-body">${_("To the maximum extent permitted by applicable law, you agree to defend, hold harmless and indemnify {platform_name} and the {platform_name} Participants, and their respective subsidiaries, affiliates, officers, faculty, students, fellows, governing board members, agents and employees from and against any third-party claims, actions or demands arising out of, resulting from or in any way related to your use of the APIs and any API Content, including any liability or expense arising from any and all claims, losses, damages (actual and consequential), suits, judgments, litigation costs and attorneys' fees, of every kind and nature. In such a case, {platform_name} or one of the {platform_name} Participants will provide you with written notice of such claim, suit or action.").format(platform_name=settings.PLATFORM_NAME)}/</p>
<h2 class="api-access-request-subheading">${_("General Legal Terms")}</h2>
<h2 class="api-subheading">${_("General Legal Terms")}</h2>
<p class="api-tos-body">${_("The Terms constitute the entire agreement between you and {platform_name} with respect to your use of the APIs and API Content, superseding any prior agreements between you and {platform_name} regarding your use of the APIs and API Content. The failure of {platform_name} to exercise or enforce any right or provision of the Terms shall not constitute a waiver of such right or provision. If any provision of the Terms is found by a court of competent jurisdiction to be invalid, the parties nevertheless agree that the court should endeavor to give effect to the parties' intentions as reflected in the provision and the other provisions of the Terms shall remain in full force and effect. The Terms do not create any third party beneficiary rights or any agency, partnership, or joint venture. For any notice provided to you by {platform_name} under these Terms, {platform_name} may notify you via the email address associated with your {platform_name} account.").format(platform_name=settings.PLATFORM_NAME)}</p>
<p class="api-copy-body">${_("The Terms constitute the entire agreement between you and {platform_name} with respect to your use of the APIs and API Content, superseding any prior agreements between you and {platform_name} regarding your use of the APIs and API Content. The failure of {platform_name} to exercise or enforce any right or provision of the Terms shall not constitute a waiver of such right or provision. If any provision of the Terms is found by a court of competent jurisdiction to be invalid, the parties nevertheless agree that the court should endeavor to give effect to the parties' intentions as reflected in the provision and the other provisions of the Terms shall remain in full force and effect. The Terms do not create any third party beneficiary rights or any agency, partnership, or joint venture. For any notice provided to you by {platform_name} under these Terms, {platform_name} may notify you via the email address associated with your {platform_name} account.").format(platform_name=settings.PLATFORM_NAME)}</p>
<p class="api-tos-body">${_("You agree that the Terms, the APIs, and any claim or dispute arising out of or relating to the Terms or the APIs will be governed by the laws of the Commonwealth of Massachusetts, excluding its conflicts of law provisions. You agree that all such claims and disputes will be heard and resolved exclusively in the federal or state courts located in and serving Cambridge, Massachusetts, U.S.A. You consent to the personal jurisdiction of those courts over you for this purpose, and you waive and agree not to assert any objection to such proceedings in those courts (including any defense or objection of lack of proper jurisdiction or venue or inconvenience of forum). Notwithstanding the foregoing, you agree that {platform_name} shall still be allowed to apply to injunctive remedies (or an equivalent type of urgent legal relief) in any jursdiction.")}</p>
<p class="api-copy-body">${_("You agree that the Terms, the APIs, and any claim or dispute arising out of or relating to the Terms or the APIs will be governed by the laws of the Commonwealth of Massachusetts, excluding its conflicts of law provisions. You agree that all such claims and disputes will be heard and resolved exclusively in the federal or state courts located in and serving Cambridge, Massachusetts, U.S.A. You consent to the personal jurisdiction of those courts over you for this purpose, and you waive and agree not to assert any objection to such proceedings in those courts (including any defense or objection of lack of proper jurisdiction or venue or inconvenience of forum). Notwithstanding the foregoing, you agree that {platform_name} shall still be allowed to apply to injunctive remedies (or an equivalent type of urgent legal relief) in any jursdiction.")}</p>
<h2 class="api-access-request-subheading">${_("Termination")}</h2>
<h2 class="api-subheading">${_("Termination")}</h2>
<p class="api-tos-body">${_("You may stop using the APIs at any time. You agree that {platform_name}, in its sole discretion and at any time, may terminate your use of the APIs or any API Content for any reason or no reason, without prior notice or liabiliy.").format(platform_name=settings.PLATFORM_NAME)}</p>
<p class="api-copy-body">${_("You may stop using the APIs at any time. You agree that {platform_name}, in its sole discretion and at any time, may terminate your use of the APIs or any API Content for any reason or no reason, without prior notice or liabiliy.").format(platform_name=settings.PLATFORM_NAME)}</p>
<p class="api-tos-body">${_("{platform_name} and the {platform_name} Participants reserve the right at any time in their sole discretion to cancel, delay, reschedule or alter the format of any API or API Content offered through {platform_name}, or to cease providing any part or all of the APIs or API Content or related services, and you agree that neither {platform_name} nor any of the {platform_name} Participants will have any liability to you for such an action.").format(platform_name=settings.PLATFORM_NAME)}</p>
<p class="api-copy-body">${_("{platform_name} and the {platform_name} Participants reserve the right at any time in their sole discretion to cancel, delay, reschedule or alter the format of any API or API Content offered through {platform_name}, or to cease providing any part or all of the APIs or API Content or related services, and you agree that neither {platform_name} nor any of the {platform_name} Participants will have any liability to you for such an action.").format(platform_name=settings.PLATFORM_NAME)}</p>
<p class="api-tos-body">${_("Upon any termination of the Terms or discontinuation of your access to an API for any reason, your right to use any API and API Content will immediately cease. You will immediately stop using the APIs and delete any cached or stored API Content. All provisions of the Terms that by their nature should survive termination shall survive termination, including, without limitation, ownership provisions, warranty disclaimers, and limitation of liability. Termination of your access to and use of the APIs and API Content shall not relieve you of any obligations arising or accrusing prior to such termination or limit any liability that you otherwise may have to {platform_name}, including without limitation any indemnification obligations contained herein.").format(platform_name=settings.PLATFORM_NAME)}</p>
<p class="api-copy-body">${_("Upon any termination of the Terms or discontinuation of your access to an API for any reason, your right to use any API and API Content will immediately cease. You will immediately stop using the APIs and delete any cached or stored API Content. All provisions of the Terms that by their nature should survive termination shall survive termination, including, without limitation, ownership provisions, warranty disclaimers, and limitation of liability. Termination of your access to and use of the APIs and API Content shall not relieve you of any obligations arising or accrusing prior to such termination or limit any liability that you otherwise may have to {platform_name}, including without limitation any indemnification obligations contained herein.").format(platform_name=settings.PLATFORM_NAME)}</p>
</div>
......@@ -113,9 +113,6 @@ urlpatterns = (
# URLs for API access management
url(r'^api-admin/', include('openedx.core.djangoapps.api_admin.urls', namespace='api_admin')),
url(r'^admin/api_admin/catalog/add/$', 'openedx.core.djangoapps.api_admin.views.catalog_changeform'),
url(r'^admin/api_admin/catalog/(?P<id>\d+)/$', 'openedx.core.djangoapps.api_admin.views.catalog_changeform'),
url(r'^admin/api_admin/catalog/$', 'openedx.core.djangoapps.api_admin.views.catalog_changelist'),
)
urlpatterns += (
......
......@@ -2,7 +2,7 @@
from django.contrib import admin
from config_models.admin import ConfigurationModelAdmin
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest, ApiAccessConfig, Catalog
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest, ApiAccessConfig
@admin.register(ApiAccessRequest)
......@@ -15,8 +15,4 @@ class ApiAccessRequestAdmin(admin.ModelAdmin):
readonly_fields = ('user', 'website', 'reason', 'company_name', 'company_address', 'contacted', )
exclude = ('site',)
@admin.register(Catalog)
class CatalogAdmin (admin.ModelAdmin):
name="Catalog"
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
......@@ -34,11 +35,48 @@ class ApiAccessRequestForm(forms.ModelForm):
super(ApiAccessRequestForm, self).__init__(*args, **kwargs)
class CatalogForm(forms.Form):
id = forms.IntegerField(required=False, widget=forms.HiddenInput)
name = forms.CharField(required=True, help_text="The name of this catalog")
query = forms.CharField(
required=True,
help_text="The query for courses to be returned by catalog",
widget=forms.Textarea
)
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,
},
),
]
......@@ -181,22 +181,43 @@ def _send_decision_email(instance):
log.exception('Error sending API user notification email for request [%s].', instance.id)
class CatalogManager(object):
def get(self, key):
log.info("GET api call: %s", key)
return None
def all(self):
log.info("ALL api call")
return []
def filter(self, **kwargs):
log.info("FILTER api call: %s", kwargs)
return []
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
class Catalog(models.Model):
objects = CatalogManager()
@property
def attributes(self):
"""Return a dictionary representation of this catalog."""
return {
'id': self.id,
'name': self.name,
'query': self.query,
'viewers': self.viewers,
}
class Meta:
managed = False
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)
)
......@@ -2,14 +2,11 @@
import logging
from django.conf import settings
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.sites.shortcuts import get_current_site
from django.core.urlresolvers import reverse_lazy, reverse
from django.http import HttpResponseRedirect
from django.shortcuts import redirect, render
from django.template import RequestContext
from django.http.response import JsonResponse
from django.shortcuts import redirect
from django.utils.translation import ugettext as _
from django.views.decorators.cache import never_cache
from django.views.generic import View
from django.views.generic.base import TemplateView
from django.views.generic.edit import CreateView
......@@ -18,11 +15,10 @@ from oauth2_provider.models import get_application_model
from oauth2_provider.views import ApplicationRegistration
from edxmako.shortcuts import render_to_response
from edx_rest_api_client.client import EdxRestApiClient
from openedx.core.djangoapps.api_admin.decorators import require_api_access
from openedx.core.djangoapps.api_admin.forms import ApiAccessRequestForm, CatalogForm
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest
from openedx.core.lib.token_utils import get_asymmetric_token
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__)
......@@ -123,72 +119,110 @@ class ApiTosView(TemplateView):
template_name = 'api_admin/terms_of_service.html'
@never_cache
@staff_member_required
def catalog_changelist(request):
# TODO: get catalogs
catalogs = [
{
'id': '1',
'name': 'test1',
'query': '*'
}
]
return render(
RequestContext(request),
'api_admin/catalog_changelist.html',
{
'catalogs': catalogs,
}
)
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(),
})
@never_cache
@staff_member_required
def catalog_changeform(request, id=None):
# import pdb; pdb.set_trace()
if request.method == 'POST':
def post(self, request, username):
"""Create a new catalog for a user."""
form = CatalogForm(request.POST)
change = False
if form.is_valid():
if id is None:
log.info("CREATE NEW CATALOGUE") # create new catalog
else:
change = True
log.info("UPDATE CATALOGUE") # update catalog
return HttpResponseRedirect('..')
else:
if id is None: # Create new catalog
change = False
form = CatalogForm()
else: # Update existing catalog
change = True
catalog = {
'id': '2',
'name': 'test2',
'query': 'test*'
} # Get catalogs
form = CatalogForm(catalog)
# del form.fields['hidden_field']
return render(
request,
'api_admin/catalog_changeform.html',
{
'change': change,
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 catalog_client(user):
token = get_asymmetric_token(user, 'course-discovery')
return EdxRestApiClient(
"http://18.111.106.34:8008/api/v1/",
jwt=token
)
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."""
# from openedx.core.djangoapps.api_admin.views import catalog_client
# from django.contrib.auth.models import User
# user = User.objects.all()[1]
# c = catalog_client(user)
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)
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