From ffdacc9129b33c185e2a0e24d32eee14b384a7cb Mon Sep 17 00:00:00 2001 From: Bill DeRusha Date: Fri, 15 Apr 2016 13:55:50 -0400 Subject: [PATCH 1/3] Use asymmetric key for signing JWTs --- lms/envs/aws.py | 2 ++ lms/envs/common.py | 4 +++ lms/envs/devstack.py | 41 ++++++++++++++++++++++++++ openedx/core/lib/rsa_key_utils.py | 21 +++++++++++++ openedx/core/lib/token_utils.py | 49 +++++++++++++++++++++++++++++++ requirements/edx/base.txt | 1 + 6 files changed, 118 insertions(+) create mode 100644 openedx/core/lib/rsa_key_utils.py diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 39e0f9a88f..9ba561965e 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -769,6 +769,8 @@ CREDIT_HELP_LINK_URL = ENV_TOKENS.get('CREDIT_HELP_LINK_URL', CREDIT_HELP_LINK_U JWT_ISSUER = ENV_TOKENS.get('JWT_ISSUER', JWT_ISSUER) JWT_EXPIRATION = ENV_TOKENS.get('JWT_EXPIRATION', JWT_EXPIRATION) JWT_AUTH.update(ENV_TOKENS.get('JWT_AUTH', {})) +PUBLIC_RSA_KEY = ENV_TOKENS.get('PUBLIC_RSA_KEY', PUBLIC_RSA_KEY) +PRIVATE_RSA_KEY = ENV_TOKENS.get('PRIVATE_RSA_KEY', PRIVATE_RSA_KEY) ################# PROCTORING CONFIGURATION ################## diff --git a/lms/envs/common.py b/lms/envs/common.py index 3daec93028..d2a64e9532 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2784,6 +2784,10 @@ LTI_AGGREGATE_SCORE_PASSBACK_DELAY = 15 * 60 JWT_EXPIRATION = 30 JWT_ISSUER = None +# For help generating a key pair import and run `openedx.core.lib.rsa_key_utils.generate_rsa_key_pair()` +PUBLIC_RSA_KEY = None +PRIVATE_RSA_KEY = None + # Credit notifications settings NOTIFICATION_EMAIL_CSS = "templates/credit_notifications/credit_notification.css" NOTIFICATION_EMAIL_EDX_LOGO = "templates/credit_notifications/edx-logo-header.png" diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 1657155966..fbc7dd29f4 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -225,6 +225,47 @@ CORS_ORIGIN_WHITELIST = () CORS_ORIGIN_ALLOW_ALL = True # JWT settings for devstack +PUBLIC_RSA_KEY = """\ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApCujf5oZBGK4MafMRGY9 ++zdRRI9YDm1r+81coDCysSrwkhTkFIwP2dmS6lYvJuQ5wifuQa3WFv1Kh9Nr2XRJ +1m9OL3/JpmMyTi/YuwD7tIf65tab1SOSRYkoxOKRuuvZuXQG9nWbXrGDncnwuWxf +eymwWaIrAhALUS5+nDa7dauj8VngsWauMrEA/MWShEzsR53wGKlciEZA1r/AfQ55 +XS42GvBobhhy9SeZ3B6LHiaAEywpwFmKPssuoHSNhbPa49LW3gXJ6CsFGRDcBFKd +xJ/l8O847Q7kg1lvckpLsKyu5167NK9Qj1X/O3SwVBL3cxx1HpQ6+q3SGLZ4ngow +hwIDAQAB +-----END PUBLIC KEY-----""" + +PRIVATE_RSA_KEY = """\ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCkK6N/mhkEYrgx +p8xEZj37N1FEj1gObWv7zVygMLKxKvCSFOQUjA/Z2ZLqVi8m5DnCJ+5BrdYW/UqH +02vZdEnWb04vf8mmYzJOL9i7APu0h/rm1pvVI5JFiSjE4pG669m5dAb2dZtesYOd +yfC5bF97KbBZoisCEAtRLn6cNrt1q6PxWeCxZq4ysQD8xZKETOxHnfAYqVyIRkDW +v8B9DnldLjYa8GhuGHL1J5ncHoseJoATLCnAWYo+yy6gdI2Fs9rj0tbeBcnoKwUZ +ENwEUp3En+Xw7zjtDuSDWW9ySkuwrK7nXrs0r1CPVf87dLBUEvdzHHUelDr6rdIY +tnieCjCHAgMBAAECggEBAJvTiAdQPzq4cVlAilTKLz7KTOsknFJlbj+9t5OdZZ9g +wKQIDE2sfEcti5O+Zlcl/eTaff39gN6lYR73gMEQ7h0J3U6cnsy+DzvDkpY94qyC +/ZYqUhPHBcnW3Mm0vNqNj0XGae15yBXjrKgSy9lUknSXJ3qMwQHeNL/DwA2KrfiL +g0iVjk32dvSSHWcBh0M+Qy1WyZU0cf9VWzx+Q1YLj9eUCHteStVubB610XV3JUZt +UTWiUCffpo2okHsTBuKPVXK/5BL+BpGplcxRSlnSbMaI611kN3iKlO8KGISXHBz7 +nOPdkfZC9poEXt5SshtINuGGCCc8hDxpg1otYqCLaYECgYEA1MSCPs3pBkEagchV +g0rxYmDUC8QkeIOBuZFjhkdoUgZ6rFntyRZd1NbCUi3YBbV1YC12ZGohqWUWom1S +AtNbQ2ZTbqEnDKWbNvLBRwkdp/9cKBce85lCCD6+U2o2Ha8C0+hKeLBn8un1y0zY +1AQTqLAz9ItNr0aDPb89cs5voWcCgYEAxYdC8vR3t8iYMUnK6LWYDrKSt7YiorvF +qXIMANcXQrnO0ptC0B56qrUCgKHNrtPi5bGpNBJ0oKMfbmGfwX+ca8sCUlLvq/O8 +S2WZwSJuaHH4lEBi8ErtY++8F4B4l3ENCT84Hyy5jiMpbpkHEnh/1GNcvvmyI8ud +3jzovCNZ4+ECgYEA0r+Oz0zAOzyzV8gqw7Cw5iRJBRqUkXaZQUj8jt4eO9lFG4C8 +IolwCclrk2Drb8Qsbka51X62twZ1ZA/qwve9l0Y88ADaIBHNa6EKxyUFZglvrBoy +w1GT8XzMou06iy52G5YkZeU+IYOSvnvw7hjXrChUXi65lRrAFqJd6GEIe5MCgYA/ +0LxDa9HFsWvh+JoyZoCytuSJr7Eu7AUnAi54kwTzzL3R8tE6Fa7BuesODbg6tD/I +v4YPyaqePzUnXyjSxdyOQq8EU8EUx5Dctv1elTYgTjnmA4szYLGjKM+WtC3Bl4eD +pkYGZFeqYRfAoHXVdNKvlk5fcKIpyF2/b+Qs7CrdYQKBgQCc/t+JxC9OpI+LhQtB +tEtwvklxuaBtoEEKJ76P9vrK1semHQ34M1XyNmvPCXUyKEI38MWtgCCXcdmg5syO +PBXdDINx+wKlW7LPgaiRL0Mi9G2aBpdFNI99CWVgCr88xqgSE24KsOxViMwmi0XB +Ld/IRK0DgpGP5EJRwpKsDYe/UQ== +-----END PRIVATE KEY-----""" + JWT_AUTH.update({ 'JWT_ALGORITHM': 'HS256', 'JWT_SECRET_KEY': 'lms-secret', diff --git a/openedx/core/lib/rsa_key_utils.py b/openedx/core/lib/rsa_key_utils.py new file mode 100644 index 0000000000..fd4853a5ec --- /dev/null +++ b/openedx/core/lib/rsa_key_utils.py @@ -0,0 +1,21 @@ +""" 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 diff --git a/openedx/core/lib/token_utils.py b/openedx/core/lib/token_utils.py index 4f9f00f406..9b5401d240 100644 --- a/openedx/core/lib/token_utils.py +++ b/openedx/core/lib/token_utils.py @@ -1,6 +1,8 @@ """Utilities for working with ID tokens.""" import datetime +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.serialization import load_pem_private_key from django.conf import settings from django.core.exceptions import ImproperlyConfigured import jwt @@ -63,3 +65,50 @@ def get_id_token(user, client_name): } return jwt.encode(payload, client.client_secret) + + +def get_asymmetric_token(user): + """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, + 'sub': anonymous_id_for_user(user, None), + } + + return jwt.encode(payload, private_key, algorithm='RS512') diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index a3ea098ea0..8c612a3f9c 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -10,6 +10,7 @@ bleach==1.4 html5lib==0.999 boto==2.39.0 celery==3.1.18 +cryptography==1.3.1 cssselect==0.9.1 dealer==2.0.4 defusedxml==0.4.1 From 25df9db6ff21e3d0cd7165a081bed6c59aaa5156 Mon Sep 17 00:00:00 2001 From: Bill DeRusha Date: Tue, 19 Apr 2016 11:08:29 -0400 Subject: [PATCH 2/3] WIP django catalog admin --- .../admin/api_admin/catalog/change_form.html | 50 ++++++++++++ .../admin/api_admin/catalog/change_list.html | 27 +++++++ .../api_admin/catalog_changeform.html | 49 +++++++++++ .../api_admin/catalog_changelist.html | 60 ++++++++++++++ lms/urls.py | 3 + openedx/core/djangoapps/api_admin/admin.py | 5 +- openedx/core/djangoapps/api_admin/forms.py | 10 +++ openedx/core/djangoapps/api_admin/models.py | 21 +++++ openedx/core/djangoapps/api_admin/views.py | 81 ++++++++++++++++++- openedx/core/lib/token_utils.py | 3 +- 10 files changed, 305 insertions(+), 4 deletions(-) create mode 100644 lms/templates/admin/api_admin/catalog/change_form.html create mode 100644 lms/templates/admin/api_admin/catalog/change_list.html create mode 100644 lms/templates/api_admin/catalog_changeform.html create mode 100644 lms/templates/api_admin/catalog_changelist.html diff --git a/lms/templates/admin/api_admin/catalog/change_form.html b/lms/templates/admin/api_admin/catalog/change_form.html new file mode 100644 index 0000000000..b8db8593c5 --- /dev/null +++ b/lms/templates/admin/api_admin/catalog/change_form.html @@ -0,0 +1,50 @@ +{% extends "admin/base_site.html" %} +{% load admin_modify adminmedia %} + +{% block extrahead %} +{{ block.super }} +{{ media }} +{% endblock %} + +{% block extrastyle %}{{ block.super }}{% endblock %} + +{% block coltype %}colMS{% endblock %} + +{% block bodyclass %} change-form{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+ {% block object-tools %} + {% endblock %} +
+ +
+ {% for field in form.visible_fields %} +
+ {{ field.errors }} + {{ field.label_tag }}{{ field }} + {% if field.field.help_text %}

{{ field.field.help_text|safe }}

{% endif %} +
+ {% endfor %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} + +
+ +
+
+{% endblock %} diff --git a/lms/templates/admin/api_admin/catalog/change_list.html b/lms/templates/admin/api_admin/catalog/change_list.html new file mode 100644 index 0000000000..e6785a186c --- /dev/null +++ b/lms/templates/admin/api_admin/catalog/change_list.html @@ -0,0 +1,27 @@ +{% extends "admin/base_site.html" %} +{% block extrahead %} + {{ block.super }} +{% endblock %} +{% block innercontent %} + + + + + + + + + {% for catalog in catalogs %} + + + + + {% endfor %} +
Name
  + {{catalog.name}} +
+{% endblock %} diff --git a/lms/templates/api_admin/catalog_changeform.html b/lms/templates/api_admin/catalog_changeform.html new file mode 100644 index 0000000000..de7e4b0eac --- /dev/null +++ b/lms/templates/api_admin/catalog_changeform.html @@ -0,0 +1,49 @@ +{% extends "admin/base_site.html" %} +{% load admin_modify staticfiles %} + +{% block extrahead %} +{{ block.super }} +{{ media }} +{% endblock %} + +{% block extrastyle %}{{ block.super }}{% endblock %} + +{% block coltype %}colMS{% endblock %} + +{% block bodyclass %} change-form{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+ {% block object-tools %} + {% endblock %} +
{% csrf_token %} +
+ {% for field in form.visible_fields %} +
+ {{ field.errors }} + {{ field.label_tag }}{{ field }} + {% if field.field.help_text %}

{{ field.field.help_text|safe }}

{% endif %} +
+ {% endfor %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} + +
+ +
+
+{% endblock %} diff --git a/lms/templates/api_admin/catalog_changelist.html b/lms/templates/api_admin/catalog_changelist.html new file mode 100644 index 0000000000..264580b542 --- /dev/null +++ b/lms/templates/api_admin/catalog_changelist.html @@ -0,0 +1,60 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static admin_list %} + +{% block extrastyle %} + {{ block.super }} + + {% if cl.formset %} + + {% endif %} + {% if cl.formset or action_form %} + + {% endif %} + {{ media.css }} + {% if not actions_on_top and not actions_on_bottom %} + + {% endif %} +{% endblock %} + +{% block extrahead %} +{{ block.super }} +{{ media.js }} +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block coltype %}flex{% endblock %} + +{% block content %} + {% block innercontent %} + + + + + + + + + {% for catalog in catalogs %} + + + + + {% endfor %} +
Name
  + {{catalog.name}} +
+ {% endblock %} +{% endblock %} diff --git a/lms/urls.py b/lms/urls.py index f3db4952ce..7fa30a78e0 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -113,6 +113,9 @@ 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\d+)/$', 'openedx.core.djangoapps.api_admin.views.catalog_changeform'), + url(r'^admin/api_admin/catalog/$', 'openedx.core.djangoapps.api_admin.views.catalog_changelist'), ) urlpatterns += ( diff --git a/openedx/core/djangoapps/api_admin/admin.py b/openedx/core/djangoapps/api_admin/admin.py index d6018f7bd8..c3e9952442 100644 --- a/openedx/core/djangoapps/api_admin/admin.py +++ b/openedx/core/djangoapps/api_admin/admin.py @@ -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 +from openedx.core.djangoapps.api_admin.models import ApiAccessRequest, ApiAccessConfig, Catalog @admin.register(ApiAccessRequest) @@ -15,5 +15,8 @@ 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) diff --git a/openedx/core/djangoapps/api_admin/forms.py b/openedx/core/djangoapps/api_admin/forms.py index 037e40d0a4..3d40d96008 100644 --- a/openedx/core/djangoapps/api_admin/forms.py +++ b/openedx/core/djangoapps/api_admin/forms.py @@ -32,3 +32,13 @@ class ApiAccessRequestForm(forms.ModelForm): # Get rid of the colons at the end of the field labels. kwargs.setdefault('label_suffix', '') super(ApiAccessRequestForm, self).__init__(*args, **kwargs) + + +class 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 + ) diff --git a/openedx/core/djangoapps/api_admin/models.py b/openedx/core/djangoapps/api_admin/models.py index 57ed3f6178..feca3bd469 100644 --- a/openedx/core/djangoapps/api_admin/models.py +++ b/openedx/core/djangoapps/api_admin/models.py @@ -179,3 +179,24 @@ def _send_decision_email(instance): instance.contacted = True except SMTPException: 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): + objects = CatalogManager() + + class Meta: + managed = False diff --git a/openedx/core/djangoapps/api_admin/views.py b/openedx/core/djangoapps/api_admin/views.py index 55343e6d2d..bb6c54dedb 100644 --- a/openedx/core/djangoapps/api_admin/views.py +++ b/openedx/core/djangoapps/api_admin/views.py @@ -2,10 +2,14 @@ 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.shortcuts import redirect +from django.http import HttpResponseRedirect +from django.shortcuts import redirect, render +from django.template import RequestContext 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 @@ -14,9 +18,11 @@ 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 +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 log = logging.getLogger(__name__) @@ -115,3 +121,74 @@ class ApiTosView(TemplateView): """View to show the API Terms of Service.""" 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, + } + ) + + +@never_cache +@staff_member_required +def catalog_changeform(request, id=None): + # import pdb; pdb.set_trace() + if request.method == 'POST': + 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, + 'form': form, + } + ) + + +def catalog_client(user): + token = get_asymmetric_token(user, 'course-discovery') + return EdxRestApiClient( + "http://18.111.106.34:8008/api/v1/", + jwt=token + ) + +# 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) diff --git a/openedx/core/lib/token_utils.py b/openedx/core/lib/token_utils.py index 9b5401d240..13aca508b5 100644 --- a/openedx/core/lib/token_utils.py +++ b/openedx/core/lib/token_utils.py @@ -67,7 +67,7 @@ def get_id_token(user, client_name): return jwt.encode(payload, client.client_secret) -def get_asymmetric_token(user): +def get_asymmetric_token(user, client_id): """Construct a JWT signed with this app's private key. The JWT includes the following claims: @@ -108,6 +108,7 @@ def get_asymmetric_token(user): 'iss': settings.OAUTH_OIDC_ISSUER, 'exp': now + datetime.timedelta(seconds=expires_in), 'iat': now, + 'aud': client_id, 'sub': anonymous_id_for_user(user, None), } From bcde8e55885aec2a3e6deffeaef50468b8b92c85 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Thu, 28 Apr 2016 15:43:07 -0400 Subject: [PATCH 3/3] Flesh out UI now that the backend is there. --- .../js/api_admin/catalog_preview_factory.js | 14 ++ .../js/api_admin/views/catalog_preview.js | 64 +++++++ .../js/spec/api_admin/catalog_preview_spec.js | 58 ++++++ lms/static/js/spec/main.js | 3 +- lms/static/karma_lms.conf.js | 3 +- lms/static/lms/js/build.js | 3 +- lms/static/sass/views/_api-access.scss | 135 ++++++++------ .../api_admin/api_access_request_form.html | 16 +- .../api_admin/catalog-error.underscore | 3 + .../api_admin/catalog-results.underscore | 6 + lms/templates/api_admin/catalogs/detail.html | 18 ++ lms/templates/api_admin/catalogs/edit.html | 41 ++++ lms/templates/api_admin/catalogs/list.html | 45 +++++ lms/templates/api_admin/catalogs/search.html | 28 +++ lms/templates/api_admin/status.html | 16 +- lms/templates/api_admin/terms_of_service.html | 102 +++++----- lms/urls.py | 3 - openedx/core/djangoapps/api_admin/admin.py | 6 +- openedx/core/djangoapps/api_admin/forms.py | 56 +++++- .../api_admin/migrations/0006_catalog.py | 26 +++ openedx/core/djangoapps/api_admin/models.py | 53 ++++-- .../djangoapps/api_admin/tests/factories.py | 14 +- .../djangoapps/api_admin/tests/test_views.py | 176 +++++++++++++++++- openedx/core/djangoapps/api_admin/urls.py | 42 ++++- openedx/core/djangoapps/api_admin/utils.py | 15 ++ openedx/core/djangoapps/api_admin/views.py | 172 ++++++++++------- 26 files changed, 895 insertions(+), 223 deletions(-) create mode 100644 lms/static/js/api_admin/catalog_preview_factory.js create mode 100644 lms/static/js/api_admin/views/catalog_preview.js create mode 100644 lms/static/js/spec/api_admin/catalog_preview_spec.js create mode 100644 lms/templates/api_admin/catalog-error.underscore create mode 100644 lms/templates/api_admin/catalog-results.underscore create mode 100644 lms/templates/api_admin/catalogs/detail.html create mode 100644 lms/templates/api_admin/catalogs/edit.html create mode 100644 lms/templates/api_admin/catalogs/list.html create mode 100644 lms/templates/api_admin/catalogs/search.html create mode 100644 openedx/core/djangoapps/api_admin/migrations/0006_catalog.py create mode 100644 openedx/core/djangoapps/api_admin/utils.py diff --git a/lms/static/js/api_admin/catalog_preview_factory.js b/lms/static/js/api_admin/catalog_preview_factory.js new file mode 100644 index 0000000000..df7c931e86 --- /dev/null +++ b/lms/static/js/api_admin/catalog_preview_factory.js @@ -0,0 +1,14 @@ +;(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); diff --git a/lms/static/js/api_admin/views/catalog_preview.js b/lms/static/js/api_admin/views/catalog_preview.js new file mode 100644 index 0000000000..f32110aa71 --- /dev/null +++ b/lms/static/js/api_admin/views/catalog_preview.js @@ -0,0 +1,64 @@ +;(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( + '' + ); + 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); diff --git a/lms/static/js/spec/api_admin/catalog_preview_spec.js b/lms/static/js/spec/api_admin/catalog_preview_spec.js new file mode 100644 index 0000000000..94b987813b --- /dev/null +++ b/lms/static/js/spec/api_admin/catalog_preview_spec.js @@ -0,0 +1,58 @@ +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( + '
' + + '' + + '
' + + '
' + ); + 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.' + ); + }); + }); +}); diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index 73f3eee655..61ad7e77c4 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -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++) { diff --git a/lms/static/karma_lms.conf.js b/lms/static/karma_lms.conf.js index 394ef86045..af8bcb4eba 100644 --- a/lms/static/karma_lms.conf.js +++ b/lms/static/karma_lms.conf.js @@ -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. diff --git a/lms/static/lms/js/build.js b/lms/static/lms/js/build.js index 86c5a000ea..3e19d1e1eb 100644 --- a/lms/static/lms/js/build.js +++ b/lms/static/lms/js/build.js @@ -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' ]), /** diff --git a/lms/static/sass/views/_api-access.scss b/lms/static/sass/views/_api-access.scss index 5fdfc5efc3..ea5e4a6b4e 100644 --- a/lms/static/sass/views/_api-access.scss +++ b/lms/static/sass/views/_api-access.scss @@ -1,19 +1,19 @@ #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; + + p { + margin: 1.5*$baseline 0; + + .helptext { + @extend %t-copy-sub1; + display: block; + } } - } - label { - @extend %t-copy-base; - display: block; - font-style: normal; - - &.tos-checkbox-label { - display: inline-block; - } - } - - 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); - } - } - - .errorlist { - - padding: 0; - list-style-type: none; - - li { + label { @extend %t-copy-base; - margin: 0; - color: $red; + display: block; + font-style: normal; + } + + input[type=checkbox] + label { + display: inline-block; + } + + 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; + + li { + @extend %t-copy-base; + margin: 0; + color: $red; + } + } + + #api-access-submit, .preview-query { + @extend %t-copy-base; + 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 { diff --git a/lms/templates/api_admin/api_access_request_form.html b/lms/templates/api_admin/api_access_request_form.html index ef1789a20a..27e6201de6 100644 --- a/lms/templates/api_admin/api_access_request_form.html +++ b/lms/templates/api_admin/api_access_request_form.html @@ -8,13 +8,17 @@ <%block name="pagetitle">${_("API Access Request")}
-

+

${_("{platform_name} API Access Request").format(platform_name=settings.PLATFORM_NAME)}

-
- - ${form.as_p() | n} - -
+
+
+
+ + ${form.as_p() | n} + +
+
+
diff --git a/lms/templates/api_admin/catalog-error.underscore b/lms/templates/api_admin/catalog-error.underscore new file mode 100644 index 0000000000..20c0fd6aaf --- /dev/null +++ b/lms/templates/api_admin/catalog-error.underscore @@ -0,0 +1,3 @@ +

+ <%- gettext('There was an error retrieving preview results for this catalog. Please check that your query is correct and try again.') %> +

diff --git a/lms/templates/api_admin/catalog-results.underscore b/lms/templates/api_admin/catalog-results.underscore new file mode 100644 index 0000000000..c0980e1e9e --- /dev/null +++ b/lms/templates/api_admin/catalog-results.underscore @@ -0,0 +1,6 @@ +

<%- gettext("This catalog's courses:") %>

+ diff --git a/lms/templates/api_admin/catalogs/detail.html b/lms/templates/api_admin/catalogs/detail.html new file mode 100644 index 0000000000..93e2f91ed3 --- /dev/null +++ b/lms/templates/api_admin/catalogs/detail.html @@ -0,0 +1,18 @@ +## 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 name="content"> + + diff --git a/lms/templates/api_admin/catalogs/edit.html b/lms/templates/api_admin/catalogs/edit.html new file mode 100644 index 0000000000..7af79368e8 --- /dev/null +++ b/lms/templates/api_admin/catalogs/edit.html @@ -0,0 +1,41 @@ +## 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 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}", + }); + + + +<%block name="content"> +
+

${catalog.name}

+ +
+
+
+ +

+ + +

+ ${form.as_p() | n} + +
+
+
+
+
+ diff --git a/lms/templates/api_admin/catalogs/list.html b/lms/templates/api_admin/catalogs/list.html new file mode 100644 index 0000000000..6d4a999a94 --- /dev/null +++ b/lms/templates/api_admin/catalogs/list.html @@ -0,0 +1,45 @@ +## 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 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}", +}); + + + +<%block name="content"> +
+

${_("Catalogs for {username}").format(username=username)}

+ + +
+

${_("Create new catalog:")}

+
+
+ + ${form.as_p() | n} + +
+
+
+
+
+ diff --git a/lms/templates/api_admin/catalogs/search.html b/lms/templates/api_admin/catalogs/search.html new file mode 100644 index 0000000000..36db13b7e0 --- /dev/null +++ b/lms/templates/api_admin/catalogs/search.html @@ -0,0 +1,28 @@ +## 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 name="content"> +
+

${_("Catalog Search")}

+ +
+

${_("Enter a username to view catalogs belonging to that user.")}

+
+
+ +

+ +

+ +
+
+
+
+ diff --git a/lms/templates/api_admin/status.html b/lms/templates/api_admin/status.html index b09c965d5e..7135d188f2 100644 --- a/lms/templates/api_admin/status.html +++ b/lms/templates/api_admin/status.html @@ -9,7 +9,7 @@ from openedx.core.djangolib.markup import Text, HTML %>
-

${_("{platform_name} API Access Request").format(platform_name=settings.PLATFORM_NAME)}

+

${_("{platform_name} API Access Request").format(platform_name=settings.PLATFORM_NAME)}

% if status == ApiAccessRequest.PENDING: @@ -41,11 +41,15 @@ from openedx.core.djangolib.markup import Text, HTML

${_('If you would like to regenerate your API client information, please use the form below.')}

% endif -
- - ${form.as_p() | n} - -
+
+
+
+ + ${form.as_p() | n} + +
+
+
% endif

diff --git a/lms/templates/api_admin/terms_of_service.html b/lms/templates/api_admin/terms_of_service.html index 2054d6ad7e..f52106ead7 100644 --- a/lms/templates/api_admin/terms_of_service.html +++ b/lms/templates/api_admin/terms_of_service.html @@ -7,99 +7,99 @@ from django.utils.translation import ugettext as _ %>
-

${_("Terms of Service for {platform_name} APIs").format(platform_name=settings.PLATFORM_NAME)}

-

${_("Effective Date: April 12th, 2016")}

+

${_("Terms of Service for {platform_name} APIs").format(platform_name=settings.PLATFORM_NAME)}

+

${_("Effective Date: April 12th, 2016")}

-

${_("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)}

+

${_("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)}

-

${_("API Access")}

+

${_("API Access")}

-

${_("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'))}

+

${_("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'))}

-

${_("Permissible Use")}

+

${_("Permissible Use")}

-

${_("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')}

+

${_("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')}

-

${_("Prohibited Uses and Activities")}

+

${_("Prohibited Uses and Activities")}

-

${_("{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)}

+

${_("{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)}

-

${_("The following activities are not acceptable when using the APIs (this is not an exhaustive list):")}

+

${_("The following activities are not acceptable when using the APIs (this is not an exhaustive list):")}

    -
  • ${_("collecting or storing the names, passwords, or other credentials of {platform_name} users;").format(platform_name=settings.PLATFORM_NAME)}
  • -
  • ${_("scraping or similar techniques to aggregate or otherwise create permanent copies of API Content;")}
  • -
  • ${_("violating, misappropriating or infringing any copyright, trademark rights, rights of privacy or publicity, confidential information or any other right of any third party;")}
  • -
  • ${_("altering or removing any trademark, copyright or other proprietary or legal notices contained in, or appearing on, the APIs or any API Content;")}
  • -
  • ${_("altering or editing any content or graphics in the API Content")}
  • -
  • ${_("sublicensing, re-distributing, renting, selling or leasing access to the APIs or your client secret to any third party;")}
  • -
  • ${_("distributing any virus, Trojan horse, spyware, adware, malware, bot, time bomb, worm, or other harmful or malicious component; or")}
  • -
  • ${_("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)}
  • +
  • ${_("collecting or storing the names, passwords, or other credentials of {platform_name} users;").format(platform_name=settings.PLATFORM_NAME)}
  • +
  • ${_("scraping or similar techniques to aggregate or otherwise create permanent copies of API Content;")}
  • +
  • ${_("violating, misappropriating or infringing any copyright, trademark rights, rights of privacy or publicity, confidential information or any other right of any third party;")}
  • +
  • ${_("altering or removing any trademark, copyright or other proprietary or legal notices contained in, or appearing on, the APIs or any API Content;")}
  • +
  • ${_("altering or editing any content or graphics in the API Content")}
  • +
  • ${_("sublicensing, re-distributing, renting, selling or leasing access to the APIs or your client secret to any third party;")}
  • +
  • ${_("distributing any virus, Trojan horse, spyware, adware, malware, bot, time bomb, worm, or other harmful or malicious component; or")}
  • +
  • ${_("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)}
-

${_("Usage and Quotas")}

+

${_("Usage and Quotas")}

-

${_("{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)}

+

${_("{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)}

-

${_("Compliance")}

+

${_("Compliance")}

-

${_("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)}

+

${_("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)}

-

${_("Ownership")}

+

${_("Ownership")}

-

${_("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).")}

+

${_("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).")}

-

${_("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.")}

+

${_("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.")}

-

${_("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)}

+

${_("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)}

-

${_("Privacy")}

+

${_("Privacy")}

-

${_("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)}

+

${_("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)}

-

${_("Right to Charge")}

+

${_("Right to Charge")}

-

${_("{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)}

+

${_("{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)}

-

${_("{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)}

+

${_("{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)}

-

${_("Confidentiality")}

+

${_("Confidentiality")}

-

${_("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.")}

+

${_("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.")}

-

${_("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)}

+

${_("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)}

-

${_("Disclaimer of Warranty / Limitation of Liabilities")}

+

${_("Disclaimer of Warranty / Limitation of Liabilities")}

-

${_("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.")}

+

${_("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.")}

-

${_("{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())}

+

${_("{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())}

-

${_("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())}

+

${_("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())}

-

${_("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())}

+

${_("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())}

-

${_("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())}

+

${_("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())}

-

${_("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.")}

+

${_("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.")}

-

${_("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)}

+

${_("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)}

-

Indemnification

+

Indemnification

-

${_("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)}/

+

${_("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)}/

-

${_("General Legal Terms")}

+

${_("General Legal Terms")}

-

${_("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)}

+

${_("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)}

-

${_("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.")}

+

${_("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.")}

-

${_("Termination")}

+

${_("Termination")}

-

${_("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)}

+

${_("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)}

-

${_("{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)}

+

${_("{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)}

-

${_("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)}

+

${_("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)}

diff --git a/lms/urls.py b/lms/urls.py index 7fa30a78e0..f3db4952ce 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -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\d+)/$', 'openedx.core.djangoapps.api_admin.views.catalog_changeform'), - url(r'^admin/api_admin/catalog/$', 'openedx.core.djangoapps.api_admin.views.catalog_changelist'), ) urlpatterns += ( diff --git a/openedx/core/djangoapps/api_admin/admin.py b/openedx/core/djangoapps/api_admin/admin.py index c3e9952442..e951f72312 100644 --- a/openedx/core/djangoapps/api_admin/admin.py +++ b/openedx/core/djangoapps/api_admin/admin.py @@ -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) diff --git a/openedx/core/djangoapps/api_admin/forms.py b/openedx/core/djangoapps/api_admin/forms.py index 3d40d96008..2c0c649b18 100644 --- a/openedx/core/djangoapps/api_admin/forms.py +++ b/openedx/core/djangoapps/api_admin/forms.py @@ -1,8 +1,9 @@ """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.'), + } diff --git a/openedx/core/djangoapps/api_admin/migrations/0006_catalog.py b/openedx/core/djangoapps/api_admin/migrations/0006_catalog.py new file mode 100644 index 0000000000..bbab6d1325 --- /dev/null +++ b/openedx/core/djangoapps/api_admin/migrations/0006_catalog.py @@ -0,0 +1,26 @@ +# -*- 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, + }, + ), + ] diff --git a/openedx/core/djangoapps/api_admin/models.py b/openedx/core/djangoapps/api_admin/models.py index feca3bd469..eb5c2600e4 100644 --- a/openedx/core/djangoapps/api_admin/models.py +++ b/openedx/core/djangoapps/api_admin/models.py @@ -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) +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 - def all(self): - log.info("ALL api call") - return [] + @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 filter(self, **kwargs): - log.info("FILTER api call: %s", kwargs) - return [] - - -class Catalog(models.Model): - objects = CatalogManager() - - class Meta: - managed = False + def __unicode__(self): + return u'Catalog {name} [{query}]'.format(name=self.name, query=self.query) diff --git a/openedx/core/djangoapps/api_admin/tests/factories.py b/openedx/core/djangoapps/api_admin/tests/factories.py index a6cf0305fe..ed9f2331c0 100644 --- a/openedx/core/djangoapps/api_admin/tests/factories.py +++ b/openedx/core/djangoapps/api_admin/tests/factories.py @@ -1,10 +1,11 @@ """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') diff --git a/openedx/core/djangoapps/api_admin/tests/test_views.py b/openedx/core/djangoapps/api_admin/tests/test_views.py index efa3c2ed06..512b07a059 100644 --- a/openedx/core/djangoapps/api_admin/tests/test_views.py +++ b/openedx/core/djangoapps/api_admin/tests/test_views.py @@ -1,21 +1,29 @@ #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}) diff --git a/openedx/core/djangoapps/api_admin/urls.py b/openedx/core/djangoapps/api_admin/urls.py index d47aa0e62f..cb84c9613e 100644 --- a/openedx/core/djangoapps/api_admin/urls.py +++ b/openedx/core/djangoapps/api_admin/urls.py @@ -1,10 +1,14 @@ """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( @@ -17,6 +21,42 @@ urlpatterns = ( api_access_enabled_or_404(ApiTosView.as_view()), 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[\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\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())), diff --git a/openedx/core/djangoapps/api_admin/utils.py b/openedx/core/djangoapps/api_admin/utils.py new file mode 100644 index 0000000000..93aab74c05 --- /dev/null +++ b/openedx/core/djangoapps/api_admin/utils.py @@ -0,0 +1,15 @@ +""" 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) + ) diff --git a/openedx/core/djangoapps/api_admin/views.py b/openedx/core/djangoapps/api_admin/views.py index bb6c54dedb..c9b87cd963 100644 --- a/openedx/core/djangoapps/api_admin/views.py +++ b/openedx/core/djangoapps/api_admin/views.py @@ -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', - { +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 + client = course_discovery_api_client(request.user) - form = CatalogForm(catalog) - # del form.fields['hidden_field'] - return render( - request, - 'api_admin/catalog_changeform.html', - { - 'change': change, + 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']})) -def catalog_client(user): - token = get_asymmetric_token(user, 'course-discovery') - return EdxRestApiClient( - "http://18.111.106.34:8008/api/v1/", - jwt=token - ) +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)