Commit 85d5a892 by Matt Drayer

mattdrayer/SOL-1929.2: Add Partner metadata model

parent c7179fce
...@@ -5,7 +5,7 @@ from django.contrib.auth.admin import UserAdmin ...@@ -5,7 +5,7 @@ from django.contrib.auth.admin import UserAdmin
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from course_discovery.apps.core.forms import UserThrottleRateForm from course_discovery.apps.core.forms import UserThrottleRateForm
from course_discovery.apps.core.models import User, UserThrottleRate, Currency from course_discovery.apps.core.models import User, UserThrottleRate, Currency, Partner
@admin.register(User) @admin.register(User)
...@@ -34,3 +34,10 @@ class CurrencyAdmin(admin.ModelAdmin): ...@@ -34,3 +34,10 @@ class CurrencyAdmin(admin.ModelAdmin):
list_display = ('code', 'name',) list_display = ('code', 'name',)
ordering = ('code', 'name',) ordering = ('code', 'name',)
search_fields = ('code', 'name',) search_fields = ('code', 'name',)
@admin.register(Partner)
class PartnerAdmin(admin.ModelAdmin):
list_display = ('name', 'short_code',)
ordering = ('name', 'short_code',)
search_fields = ('name', 'short_code',)
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django_extensions.db.fields
class Migration(migrations.Migration):
dependencies = [
('core', '0007_auto_20160510_2017'),
]
operations = [
migrations.CreateModel(
name='Partner',
fields=[
('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)),
('created', django_extensions.db.fields.CreationDateTimeField(verbose_name='created', auto_now_add=True)),
('modified', django_extensions.db.fields.ModificationDateTimeField(verbose_name='modified', auto_now=True)),
('name', models.CharField(max_length=128, unique=True)),
('short_code', models.CharField(max_length=8, unique=True)),
('courses_api_url', models.URLField(max_length=255, null=True)),
('ecommerce_api_url', models.URLField(max_length=255, null=True)),
('organizations_api_url', models.URLField(max_length=255, null=True)),
('programs_api_url', models.URLField(max_length=255, null=True)),
('marketing_api_url', models.URLField(max_length=255, null=True)),
('marketing_url_root', models.URLField(max_length=255, null=True)),
('social_auth_edx_oidc_url_root', models.CharField(max_length=255, null=True)),
('social_auth_edx_oidc_key', models.CharField(max_length=255, null=True)),
('social_auth_edx_oidc_secret', models.CharField(max_length=255, null=True)),
],
options={
'verbose_name': 'Partner',
'verbose_name_plural': 'Partners',
},
),
]
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django_extensions.db.models import TimeStampedModel
from guardian.mixins import GuardianUserMixin from guardian.mixins import GuardianUserMixin
...@@ -53,3 +54,24 @@ class Currency(models.Model): ...@@ -53,3 +54,24 @@ class Currency(models.Model):
class Meta(object): class Meta(object):
verbose_name_plural = 'Currencies' verbose_name_plural = 'Currencies'
class Partner(TimeStampedModel):
name = models.CharField(max_length=128, unique=True, null=False, blank=False)
short_code = models.CharField(max_length=8, unique=True, null=False, blank=False)
courses_api_url = models.URLField(max_length=255, null=True)
ecommerce_api_url = models.URLField(max_length=255, null=True)
organizations_api_url = models.URLField(max_length=255, null=True)
programs_api_url = models.URLField(max_length=255, null=True)
marketing_api_url = models.URLField(max_length=255, null=True)
marketing_url_root = models.URLField(max_length=255, null=True)
social_auth_edx_oidc_url_root = models.CharField(max_length=255, null=True)
social_auth_edx_oidc_key = models.CharField(max_length=255, null=True)
social_auth_edx_oidc_secret = models.CharField(max_length=255, null=True)
def __str__(self):
return '{name} ({code})'.format(name=self.name, code=self.short_code)
class Meta:
verbose_name = _('Partner')
verbose_name_plural = _('Partners')
import factory import factory
from factory.fuzzy import FuzzyText
from course_discovery.apps.core.models import User from course_discovery.apps.core.models import User, Partner
from course_discovery.apps.core.tests.utils import FuzzyUrlRoot
USER_PASSWORD = 'password' USER_PASSWORD = 'password'
...@@ -14,3 +16,20 @@ class UserFactory(factory.DjangoModelFactory): ...@@ -14,3 +16,20 @@ class UserFactory(factory.DjangoModelFactory):
class Meta: class Meta:
model = User model = User
class PartnerFactory(factory.DjangoModelFactory):
name = factory.Sequence(lambda n: 'test-partner-{}'.format(n)) # pylint: disable=unnecessary-lambda
short_code = factory.Sequence(lambda n: 'test{}'.format(n)) # pylint: disable=unnecessary-lambda
courses_api_url = '{root}/api/courses/v1/'.format(root=FuzzyUrlRoot().fuzz())
ecommerce_api_url = '{root}/api/courses/v1/'.format(root=FuzzyUrlRoot().fuzz())
organizations_api_url = '{root}/api/organizations/v1/'.format(root=FuzzyUrlRoot().fuzz())
programs_api_url = '{root}/api/programs/v1/'.format(root=FuzzyUrlRoot().fuzz())
marketing_api_url = '{root}/api/courses/v1/'.format(root=FuzzyUrlRoot().fuzz())
marketing_url_root = '{root}/'.format(root=FuzzyUrlRoot().fuzz())
social_auth_edx_oidc_url_root = '{root}'.format(root=FuzzyUrlRoot().fuzz())
social_auth_edx_oidc_key = FuzzyText().fuzz()
social_auth_edx_oidc_secret = FuzzyText().fuzz()
class Meta(object):
model = Partner
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
from django.test import TestCase from django.test import TestCase
from social.apps.django_app.default.models import UserSocialAuth from social.apps.django_app.default.models import UserSocialAuth
from course_discovery.apps.core.models import Currency from course_discovery.apps.core.models import Currency, Partner
from course_discovery.apps.core.tests.factories import UserFactory from course_discovery.apps.core.tests.factories import UserFactory
...@@ -54,3 +54,17 @@ class CurrencyTests(TestCase): ...@@ -54,3 +54,17 @@ class CurrencyTests(TestCase):
name = 'U.S. Dollar' name = 'U.S. Dollar'
instance = Currency(code=code, name=name) instance = Currency(code=code, name=name)
self.assertEqual(str(instance), '{code} - {name}'.format(code=code, name=name)) self.assertEqual(str(instance), '{code} - {name}'.format(code=code, name=name))
class PartnerTests(TestCase):
""" Tests for the Partner class. """
def test_str(self):
"""
Verify casting an instance to a string returns a string containing the name and short code of the partner.
"""
code = 'test'
name = 'Test Partner'
instance = Partner(name=name, short_code=code)
self.assertEqual(str(instance), '{name} ({code})'.format(name=name, code=code))
import json
from urllib.parse import parse_qs, urlparse
from factory.fuzzy import (
BaseFuzzyAttribute, FuzzyText, FuzzyChoice
)
class FuzzyDomain(BaseFuzzyAttribute):
def fuzz(self):
subdomain = FuzzyText()
domain = FuzzyText()
tld = FuzzyChoice(('com', 'net', 'org', 'biz', 'pizza', 'coffee', 'diamonds', 'fail', 'win', 'wtf',))
return "{subdomain}.{domain}.{tld}".format(
subdomain=subdomain.fuzz(),
domain=domain.fuzz(),
tld=tld.fuzz()
)
class FuzzyUrlRoot(BaseFuzzyAttribute):
def fuzz(self):
protocol = FuzzyChoice(('http', 'https',))
domain = FuzzyDomain()
return "{protocol}://{domain}".format(
protocol=protocol.fuzz(),
domain=domain.fuzz()
)
class FuzzyURL(BaseFuzzyAttribute):
def fuzz(self):
root = FuzzyUrlRoot()
resource = FuzzyText()
return "{root}/{resource}".format(
root=root.fuzz(),
resource=resource.fuzz()
)
def mock_api_callback(url, data, results_key=True, pagination=False):
def request_callback(request):
# pylint: disable=redefined-builtin
count = len(data)
next_url = None
previous_url = None
# Use the querystring to determine which page should be returned. Default to page 1.
# Note that the values of the dict returned by `parse_qs` are lists, hence the `[1]` default value.
qs = parse_qs(urlparse(request.path_url).query)
page = int(qs.get('page', [1])[0])
page_size = int(qs.get('page_size', [1])[0])
if (page * page_size) < count:
next_page = page + 1
next_url = '{}?page={}'.format(url, next_page)
if page > 1:
previous_page = page - 1
previous_url = '{}?page={}'.format(url, previous_page)
body = {
'count': count,
'next': next_url,
'previous': previous_url,
}
if pagination:
body = {
'pagination': body
}
if results_key:
body['results'] = data
else:
body.update(data)
return 200, {}, json.dumps(body)
return request_callback
...@@ -6,8 +6,6 @@ from urllib.parse import urljoin ...@@ -6,8 +6,6 @@ from urllib.parse import urljoin
import html2text import html2text
from dateutil.parser import parse from dateutil.parser import parse
from django.conf import settings
from django.utils.functional import cached_property
from edx_rest_api_client.client import EdxRestApiClient from edx_rest_api_client.client import EdxRestApiClient
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
...@@ -25,7 +23,7 @@ class AbstractDataLoader(metaclass=abc.ABCMeta): ...@@ -25,7 +23,7 @@ class AbstractDataLoader(metaclass=abc.ABCMeta):
""" Base class for all data loaders. """ Base class for all data loaders.
Attributes: Attributes:
api_url (str): URL of the API from which data is loaded partner (Partner): Partner which owns the data for this data loader
access_token (str): OAuth2 access token access_token (str): OAuth2 access token
PAGE_SIZE (int): Number of items to load per API call PAGE_SIZE (int): Number of items to load per API call
""" """
...@@ -33,12 +31,12 @@ class AbstractDataLoader(metaclass=abc.ABCMeta): ...@@ -33,12 +31,12 @@ class AbstractDataLoader(metaclass=abc.ABCMeta):
PAGE_SIZE = 50 PAGE_SIZE = 50
SUPPORTED_TOKEN_TYPES = ('bearer', 'jwt',) SUPPORTED_TOKEN_TYPES = ('bearer', 'jwt',)
def __init__(self, api_url, access_token, token_type): def __init__(self, partner, access_token, token_type):
""" """
Arguments: Arguments:
api_url (str): URL of the API from which data is loaded
access_token (str): OAuth2 access token access_token (str): OAuth2 access token
token_type (str): The type of access token passed in (e.g. Bearer, JWT) token_type (str): The type of access token passed in (e.g. Bearer, JWT)
partner (Partner): The Partner which owns the APIs and data being loaded
""" """
token_type = token_type.lower() token_type = token_type.lower()
...@@ -46,11 +44,10 @@ class AbstractDataLoader(metaclass=abc.ABCMeta): ...@@ -46,11 +44,10 @@ class AbstractDataLoader(metaclass=abc.ABCMeta):
raise ValueError('The token type {token_type} is invalid!'.format(token_type=token_type)) raise ValueError('The token type {token_type} is invalid!'.format(token_type=token_type))
self.access_token = access_token self.access_token = access_token
self.api_url = api_url
self.token_type = token_type self.token_type = token_type
self.partner = partner
@cached_property def get_api_client(self, api_url):
def api_client(self):
""" """
Returns an authenticated API client ready to call the API from which data is loaded. Returns an authenticated API client ready to call the API from which data is loaded.
...@@ -64,7 +61,7 @@ class AbstractDataLoader(metaclass=abc.ABCMeta): ...@@ -64,7 +61,7 @@ class AbstractDataLoader(metaclass=abc.ABCMeta):
else: else:
kwargs['oauth_access_token'] = self.access_token kwargs['oauth_access_token'] = self.access_token
return EdxRestApiClient(self.api_url, **kwargs) return EdxRestApiClient(api_url, **kwargs)
@abc.abstractmethod @abc.abstractmethod
def ingest(self): # pragma: no cover def ingest(self): # pragma: no cover
...@@ -127,11 +124,12 @@ class OrganizationsApiDataLoader(AbstractDataLoader): ...@@ -127,11 +124,12 @@ class OrganizationsApiDataLoader(AbstractDataLoader):
""" Loads organizations from the Organizations API. """ """ Loads organizations from the Organizations API. """
def ingest(self): def ingest(self):
client = self.api_client api_url = self.partner.organizations_api_url
client = self.get_api_client(api_url)
count = None count = None
page = 1 page = 1
logger.info('Refreshing Organizations from %s...', self.api_url) logger.info('Refreshing Organizations from %s...', api_url)
while page: while page:
response = client.organizations().get(page=page, page_size=self.PAGE_SIZE) response = client.organizations().get(page=page, page_size=self.PAGE_SIZE)
...@@ -143,12 +141,11 @@ class OrganizationsApiDataLoader(AbstractDataLoader): ...@@ -143,12 +141,11 @@ class OrganizationsApiDataLoader(AbstractDataLoader):
page += 1 page += 1
else: else:
page = None page = None
for body in results: for body in results:
body = self.clean_strings(body) body = self.clean_strings(body)
self.update_organization(body) self.update_organization(body)
logger.info('Retrieved %d organizations from %s.', count, self.api_url) logger.info('Retrieved %d organizations from %s.', count, api_url)
self.delete_orphans() self.delete_orphans()
...@@ -161,19 +158,22 @@ class OrganizationsApiDataLoader(AbstractDataLoader): ...@@ -161,19 +158,22 @@ class OrganizationsApiDataLoader(AbstractDataLoader):
'name': body['name'], 'name': body['name'],
'description': body['description'], 'description': body['description'],
'logo_image': image, 'logo_image': image,
'partner': self.partner,
} }
Organization.objects.update_or_create(key=body['short_name'], defaults=defaults) Organization.objects.update_or_create(key=body['short_name'], defaults=defaults)
logger.info('Created/updated organization "%s"', body['short_name'])
class CoursesApiDataLoader(AbstractDataLoader): class CoursesApiDataLoader(AbstractDataLoader):
""" Loads course runs from the Courses API. """ """ Loads course runs from the Courses API. """
def ingest(self): def ingest(self):
client = self.api_client api_url = self.partner.courses_api_url
client = self.get_api_client(api_url)
count = None count = None
page = 1 page = 1
logger.info('Refreshing Courses and CourseRuns from %s...', self.api_url) logger.info('Refreshing Courses and CourseRuns from %s...', api_url)
while page: while page:
response = client.courses().get(page=page, page_size=self.PAGE_SIZE) response = client.courses().get(page=page, page_size=self.PAGE_SIZE)
...@@ -194,9 +194,13 @@ class CoursesApiDataLoader(AbstractDataLoader): ...@@ -194,9 +194,13 @@ class CoursesApiDataLoader(AbstractDataLoader):
course = self.update_course(body) course = self.update_course(body)
self.update_course_run(course, body) self.update_course_run(course, body)
except: # pylint: disable=bare-except except: # pylint: disable=bare-except
logger.exception('An error occurred while updating [%s] from [%s]!', course_run_id, self.api_url) msg = 'An error occurred while updating {course_run} from {api_url}'.format(
course_run=course_run_id,
api_url=api_url
)
logger.exception(msg)
logger.info('Retrieved %d course runs from %s.', count, self.api_url) logger.info('Retrieved %d course runs from %s.', count, api_url)
self.delete_orphans() self.delete_orphans()
...@@ -205,10 +209,11 @@ class CoursesApiDataLoader(AbstractDataLoader): ...@@ -205,10 +209,11 @@ class CoursesApiDataLoader(AbstractDataLoader):
# which may not be unique for an organization. # which may not be unique for an organization.
course_run_key_str = body['id'] course_run_key_str = body['id']
course_run_key = CourseKey.from_string(course_run_key_str) course_run_key = CourseKey.from_string(course_run_key_str)
organization, __ = Organization.objects.get_or_create(key=course_run_key.org) organization, __ = Organization.objects.get_or_create(key=course_run_key.org, partner=self.partner)
course_key = self.convert_course_run_key(course_run_key_str) course_key = self.convert_course_run_key(course_run_key_str)
defaults = { defaults = {
'title': body['name'] 'title': body['name'],
'partner': self.partner,
} }
course, __ = Course.objects.update_or_create(key=course_key, defaults=defaults) course, __ = Course.objects.update_or_create(key=course_key, defaults=defaults)
...@@ -269,8 +274,9 @@ class DrupalApiDataLoader(AbstractDataLoader): ...@@ -269,8 +274,9 @@ class DrupalApiDataLoader(AbstractDataLoader):
"""Loads course runs from the Drupal API.""" """Loads course runs from the Drupal API."""
def ingest(self): def ingest(self):
client = self.api_client api_url = self.partner.marketing_api_url
logger.info('Refreshing Courses and CourseRuns from %s...', self.api_url) client = self.get_api_client(api_url)
logger.info('Refreshing Courses and CourseRuns from %s...', api_url)
response = client.courses.get() response = client.courses.get()
data = response['items'] data = response['items']
...@@ -288,14 +294,18 @@ class DrupalApiDataLoader(AbstractDataLoader): ...@@ -288,14 +294,18 @@ class DrupalApiDataLoader(AbstractDataLoader):
course = self.update_course(cleaned_body) course = self.update_course(cleaned_body)
self.update_course_run(course, cleaned_body) self.update_course_run(course, cleaned_body)
except: # pylint: disable=bare-except except: # pylint: disable=bare-except
logger.exception('An error occurred while updating [%s] from [%s]!', course_run_id, self.api_url) msg = 'An error occurred while updating {course_run} from {api_url}'.format(
course_run=course_run_id,
api_url=api_url
)
logger.exception(msg)
# Clean Organizations separately from other orphaned instances to avoid removing all orgnaziations # Clean Organizations separately from other orphaned instances to avoid removing all orgnaziations
# after an initial data load on an empty table. # after an initial data load on an empty table.
Organization.objects.filter(courseorganization__isnull=True).delete() Organization.objects.filter(courseorganization__isnull=True).delete()
self.delete_orphans() self.delete_orphans()
logger.info('Retrieved %d course runs from %s.', len(data), self.api_url) logger.info('Retrieved %d course runs from %s.', len(data), api_url)
def update_course(self, body): def update_course(self, body):
"""Create or update a course from Drupal data given by `body`.""" """Create or update a course from Drupal data given by `body`."""
...@@ -308,6 +318,7 @@ class DrupalApiDataLoader(AbstractDataLoader): ...@@ -308,6 +318,7 @@ class DrupalApiDataLoader(AbstractDataLoader):
course.full_description = self.clean_html(body['description']) course.full_description = self.clean_html(body['description'])
course.short_description = self.clean_html(body['subtitle']) course.short_description = self.clean_html(body['subtitle'])
course.partner = self.partner
level_type, __ = LevelType.objects.get_or_create(name=body['level']['title']) level_type, __ = LevelType.objects.get_or_create(name=body['level']['title'])
course.level_type = level_type course.level_type = level_type
...@@ -335,7 +346,7 @@ class DrupalApiDataLoader(AbstractDataLoader): ...@@ -335,7 +346,7 @@ class DrupalApiDataLoader(AbstractDataLoader):
defaults = { defaults = {
'name': sponsor_body['title'], 'name': sponsor_body['title'],
'logo_image': image, 'logo_image': image,
'homepage_url': urljoin(settings.MARKETING_URL_ROOT, sponsor_body['uri']) 'homepage_url': urljoin(self.partner.marketing_url_root, sponsor_body['uri']),
} }
organization, __ = Organization.objects.update_or_create(key=sponsor_body['uuid'], defaults=defaults) organization, __ = Organization.objects.update_or_create(key=sponsor_body['uuid'], defaults=defaults)
CourseOrganization.objects.create( CourseOrganization.objects.create(
...@@ -357,7 +368,7 @@ class DrupalApiDataLoader(AbstractDataLoader): ...@@ -357,7 +368,7 @@ class DrupalApiDataLoader(AbstractDataLoader):
course_run.language = self.get_language_tag(body) course_run.language = self.get_language_tag(body)
course_run.course = course course_run.course = course
course_run.marketing_url = urljoin(settings.MARKETING_URL_ROOT, body['course_about_uri']) course_run.marketing_url = urljoin(self.partner.marketing_url_root, body['course_about_uri'])
course_run.start = self.parse_date(body['start']) course_run.start = self.parse_date(body['start'])
course_run.end = self.parse_date(body['end']) course_run.end = self.parse_date(body['end'])
...@@ -409,11 +420,12 @@ class EcommerceApiDataLoader(AbstractDataLoader): ...@@ -409,11 +420,12 @@ class EcommerceApiDataLoader(AbstractDataLoader):
""" Loads course seats from the E-Commerce API. """ """ Loads course seats from the E-Commerce API. """
def ingest(self): def ingest(self):
client = self.api_client api_url = self.partner.ecommerce_api_url
client = self.get_api_client(api_url)
count = None count = None
page = 1 page = 1
logger.info('Refreshing course seats from %s...', self.api_url) logger.info('Refreshing course seats from %s...', api_url)
while page: while page:
response = client.courses().get(page=page, page_size=self.PAGE_SIZE, include_products=True) response = client.courses().get(page=page, page_size=self.PAGE_SIZE, include_products=True)
...@@ -430,7 +442,7 @@ class EcommerceApiDataLoader(AbstractDataLoader): ...@@ -430,7 +442,7 @@ class EcommerceApiDataLoader(AbstractDataLoader):
body = self.clean_strings(body) body = self.clean_strings(body)
self.update_seats(body) self.update_seats(body)
logger.info('Retrieved %d course seats from %s.', count, self.api_url) logger.info('Retrieved %d course seats from %s.', count, api_url)
self.delete_orphans() self.delete_orphans()
...@@ -495,11 +507,12 @@ class ProgramsApiDataLoader(AbstractDataLoader): ...@@ -495,11 +507,12 @@ class ProgramsApiDataLoader(AbstractDataLoader):
image_height = 145 image_height = 145
def ingest(self): def ingest(self):
client = self.api_client api_url = self.partner.programs_api_url
client = self.get_api_client(api_url)
count = None count = None
page = 1 page = 1
logger.info('Refreshing programs from %s...', self.api_url) logger.info('Refreshing programs from %s...', api_url)
while page: while page:
response = client.programs.get(page=page, page_size=self.PAGE_SIZE) response = client.programs.get(page=page, page_size=self.PAGE_SIZE)
...@@ -516,7 +529,7 @@ class ProgramsApiDataLoader(AbstractDataLoader): ...@@ -516,7 +529,7 @@ class ProgramsApiDataLoader(AbstractDataLoader):
program = self.clean_strings(program) program = self.clean_strings(program)
self.update_program(program) self.update_program(program)
logger.info('Retrieved %d programs from %s.', count, self.api_url) logger.info('Retrieved %d programs from %s.', count, api_url)
def update_program(self, body): def update_program(self, body):
defaults = { defaults = {
...@@ -526,13 +539,14 @@ class ProgramsApiDataLoader(AbstractDataLoader): ...@@ -526,13 +539,14 @@ class ProgramsApiDataLoader(AbstractDataLoader):
'status': body['status'], 'status': body['status'],
'marketing_slug': body['marketing_slug'], 'marketing_slug': body['marketing_slug'],
'image': self._get_image(body), 'image': self._get_image(body),
'partner': self.partner,
} }
program, __ = Program.objects.update_or_create(uuid=body['uuid'], defaults=defaults) program, __ = Program.objects.update_or_create(uuid=body['uuid'], defaults=defaults)
organizations = [] organizations = []
for org in body['organizations']: for org in body['organizations']:
organization, __ = Organization.objects.get_or_create( organization, __ = Organization.objects.get_or_create(
key=org['key'], defaults={'name': org['display_name']} key=org['key'], defaults={'name': org['display_name'], 'partner': self.partner}
) )
organizations.append(organization) organizations.append(organization)
......
import logging import logging
from django.conf import settings
from django.core.management import BaseCommand, CommandError from django.core.management import BaseCommand, CommandError
from edx_rest_api_client.client import EdxRestApiClient from edx_rest_api_client.client import EdxRestApiClient
from course_discovery.apps.course_metadata.data_loaders import ( from course_discovery.apps.course_metadata.data_loaders import (
CoursesApiDataLoader, DrupalApiDataLoader, OrganizationsApiDataLoader, EcommerceApiDataLoader, ProgramsApiDataLoader CoursesApiDataLoader, DrupalApiDataLoader, OrganizationsApiDataLoader, EcommerceApiDataLoader, ProgramsApiDataLoader
) )
from course_discovery.apps.core.models import Partner
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -31,7 +31,28 @@ class Command(BaseCommand): ...@@ -31,7 +31,28 @@ class Command(BaseCommand):
help='The type of access token being passed (e.g. Bearer, JWT).' help='The type of access token being passed (e.g. Bearer, JWT).'
) )
parser.add_argument(
'--partner_code',
action='store',
dest='partner_code',
default=None,
help='The short code for a specific partner to refresh.'
)
def handle(self, *args, **options): def handle(self, *args, **options):
# For each partner defined...
partners = Partner.objects.all()
# If a specific partner was indicated, filter down the set
partner_code = options.get('partner_code')
if partner_code:
partners = partners.filter(short_code=partner_code)
if not partners:
raise CommandError('No partners available!')
for partner in partners:
access_token = options.get('access_token') access_token = options.get('access_token')
token_type = options.get('token_type') token_type = options.get('token_type')
...@@ -44,25 +65,35 @@ class Command(BaseCommand): ...@@ -44,25 +65,35 @@ class Command(BaseCommand):
try: try:
access_token, __ = EdxRestApiClient.get_oauth_access_token( access_token, __ = EdxRestApiClient.get_oauth_access_token(
'{root}/access_token'.format(root=settings.SOCIAL_AUTH_EDX_OIDC_URL_ROOT), '{root}/access_token'.format(root=partner.social_auth_edx_oidc_url_root.strip('/')),
settings.SOCIAL_AUTH_EDX_OIDC_KEY, partner.social_auth_edx_oidc_key,
settings.SOCIAL_AUTH_EDX_OIDC_SECRET, partner.social_auth_edx_oidc_secret,
token_type=token_type token_type=token_type
) )
except Exception: except Exception:
logger.exception('No access token provided or acquired through client_credential flow.') logger.exception('No access token provided or acquired through client_credential flow.')
raise raise
loaders = ( loaders = []
(OrganizationsApiDataLoader, settings.ORGANIZATIONS_API_URL,),
(CoursesApiDataLoader, settings.COURSES_API_URL,), if partner.organizations_api_url:
(EcommerceApiDataLoader, settings.ECOMMERCE_API_URL,), loaders.append(OrganizationsApiDataLoader)
(DrupalApiDataLoader, settings.MARKETING_API_URL,), if partner.courses_api_url:
(ProgramsApiDataLoader, settings.PROGRAMS_API_URL,), loaders.append(CoursesApiDataLoader)
) if partner.ecommerce_api_url:
loaders.append(EcommerceApiDataLoader)
if partner.marketing_api_url:
loaders.append(DrupalApiDataLoader)
if partner.programs_api_url:
loaders.append(ProgramsApiDataLoader)
for loader_class, api_url in loaders: if loaders:
for loader_class in loaders:
try: try:
loader_class(api_url, access_token, token_type).ingest() loader_class(
except Exception: partner,
access_token,
token_type,
).ingest()
except Exception: # pylint: disable=broad-except
logger.exception('%s failed!', loader_class.__name__) logger.exception('%s failed!', loader_class.__name__)
import json
import responses
from django.core.management import call_command, CommandError
from django.test import TestCase
from course_discovery.apps.core.tests.factories import PartnerFactory
from course_discovery.apps.core.tests.utils import mock_api_callback
from course_discovery.apps.course_metadata.models import Course, CourseRun, Organization, Partner, Program
from course_discovery.apps.course_metadata.tests import mock_data
ACCESS_TOKEN = 'secret'
ACCESS_TOKEN_TYPE = 'Bearer'
JSON = 'application/json'
LOGGER_NAME = 'course_metadata.management.commands.refresh_course_metadata'
class RefreshCourseMetadataCommandTests(TestCase):
def setUp(self):
super(RefreshCourseMetadataCommandTests, self).setUp()
self.partner = PartnerFactory()
def mock_access_token_api(self):
body = {
'access_token': ACCESS_TOKEN,
'expires_in': 30
}
url = self.partner.social_auth_edx_oidc_url_root.strip('/') + '/access_token'
responses.add_callback(
responses.POST,
url,
callback=mock_api_callback(url, body, results_key=False),
content_type=JSON
)
return body
def mock_organizations_api(self):
bodies = mock_data.ORGANIZATIONS_API_BODIES
url = self.partner.organizations_api_url + 'organizations/'
responses.add_callback(
responses.GET,
url,
callback=mock_api_callback(url, bodies),
content_type=JSON
)
return bodies
def mock_lms_courses_api(self):
bodies = mock_data.COURSES_API_BODIES
url = self.partner.courses_api_url + 'courses/'
responses.add_callback(
responses.GET,
url,
callback=mock_api_callback(url, bodies, pagination=True),
content_type=JSON
)
return bodies
def mock_ecommerce_courses_api(self):
bodies = mock_data.ECOMMERCE_API_BODIES
url = self.partner.ecommerce_api_url + 'courses/'
responses.add_callback(
responses.GET,
url,
callback=mock_api_callback(url, bodies),
content_type=JSON
)
return bodies
def mock_marketing_courses_api(self):
"""Mock out the Marketing API. Returns a list of mocked-out course runs."""
body = mock_data.MARKETING_API_BODY
responses.add(
responses.GET,
self.partner.marketing_api_url + 'courses/',
body=json.dumps(body),
status=200,
content_type='application/json'
)
return body['items']
def mock_programs_api(self):
bodies = mock_data.PROGRAMS_API_BODIES
url = self.partner.programs_api_url + 'programs/'
responses.add_callback(
responses.GET,
url,
callback=mock_api_callback(url, bodies),
content_type=JSON
)
return bodies
@responses.activate
def test_refresh_course_metadata(self):
""" Verify """
self.mock_access_token_api()
self.mock_organizations_api()
self.mock_lms_courses_api()
self.mock_ecommerce_courses_api()
self.mock_marketing_courses_api()
self.mock_programs_api()
call_command('refresh_course_metadata')
partners = Partner.objects.all()
self.assertEqual(len(partners), 1)
organizations = Organization.objects.all()
self.assertEqual(len(organizations), 3)
for organization in organizations:
self.assertEqual(organization.partner.short_code, self.partner.short_code)
courses = Course.objects.all()
self.assertEqual(len(courses), 2)
for course in courses:
self.assertEqual(course.partner.short_code, self.partner.short_code)
course_runs = CourseRun.objects.all()
self.assertEqual(len(course_runs), 3)
for course_run in course_runs:
self.assertEqual(course_run.course.partner.short_code, self.partner.short_code)
programs = Program.objects.all()
self.assertEqual(len(programs), 2)
for program in programs:
self.assertEqual(program.partner.short_code, self.partner.short_code)
# Refresh only a specific partner
command_args = ['--partner_code={0}'.format(partners[0].short_code)]
call_command('refresh_course_metadata', *command_args)
# Invalid partner code
with self.assertRaises(CommandError):
command_args = ['--partner_code=invalid']
call_command('refresh_course_metadata', *command_args)
# Access token but no token type
with self.assertRaises(CommandError):
command_args = ['--access_token=test-access-token']
call_command('refresh_course_metadata', *command_args)
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('core', '0008_partner'),
('course_metadata', '0008_program_image'),
]
operations = [
migrations.AddField(
model_name='course',
name='partner',
field=models.ForeignKey(null=True, to='core.Partner'),
),
migrations.AddField(
model_name='historicalcourse',
name='partner',
field=models.ForeignKey(related_name='+', null=True, on_delete=django.db.models.deletion.DO_NOTHING, db_constraint=False, blank=True, to='core.Partner'),
),
migrations.AddField(
model_name='historicalorganization',
name='partner',
field=models.ForeignKey(related_name='+', null=True, on_delete=django.db.models.deletion.DO_NOTHING, db_constraint=False, blank=True, to='core.Partner'),
),
migrations.AddField(
model_name='organization',
name='partner',
field=models.ForeignKey(null=True, to='core.Partner'),
),
migrations.AddField(
model_name='program',
name='partner',
field=models.ForeignKey(null=True, to='core.Partner'),
),
]
...@@ -4,7 +4,6 @@ from urllib.parse import urljoin ...@@ -4,7 +4,6 @@ from urllib.parse import urljoin
from uuid import uuid4 from uuid import uuid4
import pytz import pytz
from django.conf import settings
from django.db import models from django.db import models
from django.db.models.query_utils import Q from django.db.models.query_utils import Q
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
...@@ -13,7 +12,7 @@ from haystack.query import SearchQuerySet ...@@ -13,7 +12,7 @@ from haystack.query import SearchQuerySet
from simple_history.models import HistoricalRecords from simple_history.models import HistoricalRecords
from sortedm2m.fields import SortedManyToManyField from sortedm2m.fields import SortedManyToManyField
from course_discovery.apps.core.models import Currency from course_discovery.apps.core.models import Currency, Partner
from course_discovery.apps.course_metadata.query import CourseQuerySet from course_discovery.apps.course_metadata.query import CourseQuerySet
from course_discovery.apps.course_metadata.utils import clean_query from course_discovery.apps.course_metadata.utils import clean_query
from course_discovery.apps.ietf_language_tags.models import LanguageTag from course_discovery.apps.ietf_language_tags.models import LanguageTag
...@@ -132,6 +131,7 @@ class Organization(TimeStampedModel): ...@@ -132,6 +131,7 @@ class Organization(TimeStampedModel):
description = models.TextField(null=True, blank=True) description = models.TextField(null=True, blank=True)
homepage_url = models.URLField(max_length=255, null=True, blank=True) homepage_url = models.URLField(max_length=255, null=True, blank=True)
logo_image = models.ForeignKey(Image, null=True, blank=True) logo_image = models.ForeignKey(Image, null=True, blank=True)
partner = models.ForeignKey(Partner, null=True, blank=False)
history = HistoricalRecords() history = HistoricalRecords()
...@@ -189,6 +189,7 @@ class Course(TimeStampedModel): ...@@ -189,6 +189,7 @@ class Course(TimeStampedModel):
history = HistoricalRecords() history = HistoricalRecords()
objects = CourseQuerySet.as_manager() objects = CourseQuerySet.as_manager()
partner = models.ForeignKey(Partner, null=True, blank=False)
@property @property
def owners(self): def owners(self):
...@@ -496,6 +497,8 @@ class Program(TimeStampedModel): ...@@ -496,6 +497,8 @@ class Program(TimeStampedModel):
organizations = models.ManyToManyField(Organization, blank=True) organizations = models.ManyToManyField(Organization, blank=True)
partner = models.ForeignKey(Partner, null=True, blank=False)
def __str__(self): def __str__(self):
return self.title return self.title
...@@ -503,7 +506,7 @@ class Program(TimeStampedModel): ...@@ -503,7 +506,7 @@ class Program(TimeStampedModel):
def marketing_url(self): def marketing_url(self):
if self.marketing_slug: if self.marketing_slug:
path = '{category}/{slug}'.format(category=self.category, slug=self.marketing_slug) path = '{category}/{slug}'.format(category=self.category, slug=self.marketing_slug)
return urljoin(settings.MARKETING_URL_ROOT, path) return urljoin(self.partner.marketing_url_root, path)
return None return None
......
...@@ -3,10 +3,12 @@ from uuid import uuid4 ...@@ -3,10 +3,12 @@ from uuid import uuid4
import factory import factory
from factory.fuzzy import ( from factory.fuzzy import (
BaseFuzzyAttribute, FuzzyText, FuzzyChoice, FuzzyDateTime, FuzzyInteger, FuzzyDecimal FuzzyText, FuzzyChoice, FuzzyDateTime, FuzzyInteger, FuzzyDecimal
) )
from pytz import UTC from pytz import UTC
from course_discovery.apps.core.tests.factories import PartnerFactory
from course_discovery.apps.core.tests.utils import FuzzyURL
from course_discovery.apps.core.models import Currency from course_discovery.apps.core.models import Currency
from course_discovery.apps.course_metadata.models import ( from course_discovery.apps.course_metadata.models import (
Course, CourseRun, Organization, Person, Image, Video, Subject, Seat, Prerequisite, LevelType, Program, Course, CourseRun, Organization, Person, Image, Video, Subject, Seat, Prerequisite, LevelType, Program,
...@@ -15,22 +17,6 @@ from course_discovery.apps.course_metadata.models import ( ...@@ -15,22 +17,6 @@ from course_discovery.apps.course_metadata.models import (
from course_discovery.apps.ietf_language_tags.models import LanguageTag from course_discovery.apps.ietf_language_tags.models import LanguageTag
class FuzzyURL(BaseFuzzyAttribute):
def fuzz(self):
protocol = FuzzyChoice(('http', 'https',))
subdomain = FuzzyText()
domain = FuzzyText()
tld = FuzzyChoice(('com', 'net', 'org', 'biz', 'pizza', 'coffee', 'diamonds', 'fail', 'win', 'wtf',))
resource = FuzzyText()
return "{protocol}://{subdomain}.{domain}.{tld}/{resource}".format(
protocol=protocol.fuzz(),
subdomain=subdomain.fuzz(),
domain=domain.fuzz(),
tld=tld.fuzz(),
resource=resource.fuzz()
)
class AbstractMediaModelFactory(factory.DjangoModelFactory): class AbstractMediaModelFactory(factory.DjangoModelFactory):
src = FuzzyURL() src = FuzzyURL()
description = FuzzyText() description = FuzzyText()
...@@ -89,6 +75,7 @@ class CourseFactory(factory.DjangoModelFactory): ...@@ -89,6 +75,7 @@ class CourseFactory(factory.DjangoModelFactory):
image = factory.SubFactory(ImageFactory) image = factory.SubFactory(ImageFactory)
video = factory.SubFactory(VideoFactory) video = factory.SubFactory(VideoFactory)
marketing_url = FuzzyText(prefix='https://example.com/test-course-url') marketing_url = FuzzyText(prefix='https://example.com/test-course-url')
partner = factory.SubFactory(PartnerFactory)
class Meta: class Meta:
model = Course model = Course
...@@ -123,6 +110,7 @@ class OrganizationFactory(factory.DjangoModelFactory): ...@@ -123,6 +110,7 @@ class OrganizationFactory(factory.DjangoModelFactory):
description = FuzzyText() description = FuzzyText()
homepage_url = FuzzyURL() homepage_url = FuzzyURL()
logo_image = factory.SubFactory(ImageFactory) logo_image = factory.SubFactory(ImageFactory)
partner = factory.SubFactory(PartnerFactory)
class Meta: class Meta:
model = Organization model = Organization
...@@ -150,6 +138,7 @@ class ProgramFactory(factory.django.DjangoModelFactory): ...@@ -150,6 +138,7 @@ class ProgramFactory(factory.django.DjangoModelFactory):
status = 'unpublished' status = 'unpublished'
marketing_slug = factory.Sequence(lambda n: 'test-slug-{}'.format(n)) # pylint: disable=unnecessary-lambda marketing_slug = factory.Sequence(lambda n: 'test-slug-{}'.format(n)) # pylint: disable=unnecessary-lambda
image = factory.SubFactory(ImageFactory) image = factory.SubFactory(ImageFactory)
partner = factory.SubFactory(PartnerFactory)
class AbstractSocialNetworkModelFactory(factory.DjangoModelFactory): class AbstractSocialNetworkModelFactory(factory.DjangoModelFactory):
......
# A course which exists, but has no associated runs
EXISTING_COURSE = {
'course_key': 'PartialX+P102',
'title': 'A partial course',
}
EXISTING_COURSE_AND_RUN_DATA = (
{
'course_run_key': 'course-v1:SC+BreadX+3T2015',
'course_key': 'SC+BreadX',
'title': 'Bread Baking 101',
'current_language': 'en-us',
},
{
'course_run_key': 'course-v1:TX+T201+3T2015',
'course_key': 'TX+T201',
'title': 'Testing 201',
'current_language': ''
}
)
ORPHAN_ORGANIZATION_KEY = 'orphan_org'
ORPHAN_STAFF_KEY = 'orphan_staff'
COURSES_API_BODIES = [
{
'end': '2015-08-08T00:00:00Z',
'enrollment_start': '2015-05-15T13:00:00Z',
'enrollment_end': '2015-06-29T13:00:00Z',
'id': 'course-v1:MITx+0.111x+2T2015',
'media': {
'image': {
'raw': 'http://example.com/image.jpg',
},
},
'name': 'Making Science and Engineering Pictures: A Practical Guide to Presenting Your Work',
'number': '0.111x',
'org': 'MITx',
'short_description': '',
'start': '2015-06-15T13:00:00Z',
'pacing': 'self',
},
{
'effort': None,
'end': '2015-12-11T06:00:00Z',
'enrollment_start': None,
'enrollment_end': None,
'id': 'course-v1:KyotoUx+000x+2T2016',
'media': {
'course_image': {
'uri': '/asset-v1:KyotoUx+000x+2T2016+type@asset+block@000x-course_imagec-378x225.jpg'
},
'course_video': {
'uri': None
}
},
'name': 'Evolution of the Human Sociality: A Quest for the Origin of Our Social Behavior',
'number': '000x',
'org': 'KyotoUx',
'short_description': '',
'start': '2015-10-29T09:00:00Z',
'pacing': 'instructor,'
},
{
# Add a second run of KyotoUx+000x (3T2016) to test merging data across
# multiple course runs into a single course.
'effort': None,
'end': None,
'enrollment_start': None,
'enrollment_end': None,
'id': 'course-v1:KyotoUx+000x+3T2016',
'media': {
'course_image': {
'uri': '/asset-v1:KyotoUx+000x+3T2016+type@asset+block@000x-course_imagec-378x225.jpg'
},
'course_video': {
'uri': None
}
},
'name': 'Evolution of the Human Sociality: A Quest for the Origin of Our Social Behavior',
'number': '000x',
'org': 'KyotoUx',
'short_description': '',
'start': None,
},
]
ECOMMERCE_API_BODIES = [
{
"id": "audit/course/run",
"products": [
{
"structure": "parent",
"price": None,
"expires": None,
"attribute_values": [],
"is_available_to_buy": False,
"stockrecords": []
},
{
"structure": "child",
"expires": None,
"attribute_values": [],
"stockrecords": [
{
"price_currency": "USD",
"price_excl_tax": "0.00",
}
]
}
]
},
{
"id": "verified/course/run",
"products": [
{
"structure": "parent",
"price": None,
"expires": None,
"attribute_values": [],
"is_available_to_buy": False,
"stockrecords": []
},
{
"structure": "child",
"expires": None,
"attribute_values": [
{
"name": "certificate_type",
"value": "honor"
}
],
"stockrecords": [
{
"price_currency": "EUR",
"price_excl_tax": "0.00",
}
]
},
{
"structure": "child",
"expires": "2017-01-01T12:00:00Z",
"attribute_values": [
{
"name": "certificate_type",
"value": "verified"
}
],
"stockrecords": [
{
"price_currency": "EUR",
"price_excl_tax": "25.00",
}
]
}
]
},
{
# This credit course has two credit seats to verify we are correctly finding/updating using the credit
# provider field.
"id": "credit/course/run",
"products": [
{
"structure": "parent",
"price": None,
"expires": None,
"attribute_values": [],
"is_available_to_buy": False,
"stockrecords": []
},
{
"structure": "child",
"expires": None,
"attribute_values": [],
"stockrecords": [
{
"price_currency": "USD",
"price_excl_tax": "0.00",
}
]
},
{
"structure": "child",
"expires": "2017-01-01T12:00:00Z",
"attribute_values": [
{
"name": "certificate_type",
"value": "verified"
}
],
"stockrecords": [
{
"price_currency": "USD",
"price_excl_tax": "25.00",
}
]
},
{
"structure": "child",
"expires": "2017-06-01T12:00:00Z",
"attribute_values": [
{
"name": "certificate_type",
"value": "credit"
},
{
"name": "credit_hours",
"value": 2
},
{
"name": "credit_provider",
"value": "asu"
},
{
"name": "verification_required",
"value": False
},
],
"stockrecords": [
{
"price_currency": "USD",
"price_excl_tax": "250.00",
}
]
},
{
"structure": "child",
"expires": "2017-06-01T12:00:00Z",
"attribute_values": [
{
"name": "certificate_type",
"value": "credit"
},
{
"name": "credit_hours",
"value": 2
},
{
"name": "credit_provider",
"value": "acme"
},
{
"name": "verification_required",
"value": False
},
],
"stockrecords": [
{
"price_currency": "USD",
"price_excl_tax": "250.00",
}
]
}
]
},
{ # Course with a currency not found in the database
"id": "nocurrency/course/run",
"products": [
{
"structure": "parent",
"price": None,
"expires": None,
"attribute_values": [],
"is_available_to_buy": False,
"stockrecords": []
},
{
"structure": "child",
"expires": None,
"attribute_values": [],
"stockrecords": [
{
"price_currency": "123",
"price_excl_tax": "0.00",
}
]
}
]
},
{ # Course which does not exist in LMS
"id": "fake-course-does-not-exist",
"products": [
{
"structure": "parent",
"price": None,
"expires": None,
"attribute_values": [],
"is_available_to_buy": False,
"stockrecords": []
},
{
"structure": "child",
"expires": None,
"attribute_values": [],
"stockrecords": [
{
"price_currency": "USD",
"price_excl_tax": "0.00",
}
]
}
]
}
]
MARKETING_API_BODY = {
'items': [
{
'title': EXISTING_COURSE_AND_RUN_DATA[0]['title'],
'start': '2015-06-15T13:00:00Z',
'end': '2015-12-15T13:00:00Z',
'level': {
'title': 'Introductory',
},
'course_about_uri': '/course/bread-baking-101',
'course_id': EXISTING_COURSE_AND_RUN_DATA[0]['course_run_key'],
'subjects': [{
'title': 'Bread baking',
}],
'current_language': EXISTING_COURSE_AND_RUN_DATA[0]['current_language'],
'subtitle': 'Learn about Bread',
'description': '<p>Bread is a <a href="/wiki/Staple_food" title="Staple food">staple food</a>.',
'sponsors': [{
'uuid': 'abc123',
'title': 'Tatte',
'image': 'http://example.com/tatte.jpg',
'uri': 'sponsor/tatte'
}],
'staff': [{
'uuid': 'staff123',
'title': 'The Muffin Man',
'image': 'http://example.com/muffinman.jpg',
'display_position': {
'title': 'Baker'
}
}, {
'uuid': 'staffZYX',
'title': 'Arthur',
'image': 'http://example.com/kingarthur.jpg',
'display_position': {
'title': 'King'
}
}]
},
{
'title': EXISTING_COURSE_AND_RUN_DATA[1]['title'],
'start': '2015-06-15T13:00:00Z',
'end': '2015-12-15T13:00:00Z',
'level': {
'title': 'Intermediate',
},
'course_about_uri': '/course/testing-201',
'course_id': EXISTING_COURSE_AND_RUN_DATA[1]['course_run_key'],
'subjects': [{
'title': 'testing',
}],
'current_language': EXISTING_COURSE_AND_RUN_DATA[1]['current_language'],
'subtitle': 'Testing 201',
'description': "how to test better",
'sponsors': [],
'staff': [{
'uuid': '432staff',
'title': 'Test',
'image': 'http://example.com/test.jpg',
'display_position': {
'title': 'Tester'
}
}]
},
{ # Create a course which exists in LMS/Otto, but without course runs
'title': EXISTING_COURSE['title'],
'start': '2015-06-15T13:00:00Z',
'end': '2015-12-15T13:00:00Z',
'level': {
'title': 'Advanced',
},
'course_about_uri': '/course/partial-101',
'course_id': 'course-v1:{course_key}+run'.format(course_key=EXISTING_COURSE['course_key']),
'subjects': [{
'title': 'partially fake',
}],
'current_language': 'en-us',
'subtitle': 'Nope',
'description': 'what is fake?',
'sponsors': [{
'uuid': '123abc',
'title': 'Fake',
'image': 'http://example.com/fake.jpg',
'uri': 'sponsor/fake'
}, {
'uuid': 'qwertyuiop',
'title': 'Faux',
'image': 'http://example.com/faux.jpg',
'uri': 'sponsor/faux'
}],
'staff': [],
},
{ # Create a fake course run which doesn't exist in LMS/Otto
'title': 'A partial course',
'start': '2015-06-15T13:00:00Z',
'end': '2015-12-15T13:00:00Z',
'level': {
'title': 'Advanced',
},
'course_about_uri': '/course/partial-101',
'course_id': 'course-v1:fakeX+fake+reallyfake',
'subjects': [{
'title': 'seriously fake',
}],
'current_language': 'en-us',
'subtitle': 'Nope',
'description': 'what is real?',
'sponsors': [],
'staff': [],
},
# NOTE (CCB): Some of the entries are empty arrays. Remove this as part of ECOM-4493.
[],
]
}
ORGANIZATIONS_API_BODIES = [
{
'name': 'edX',
'short_name': ' edX ',
'description': 'edX',
'logo': 'https://example.com/edx.jpg',
},
{
'name': 'Massachusetts Institute of Technology ',
'short_name': 'MITx',
'description': ' ',
'logo': '',
}
]
PROGRAMS_API_BODIES = [
{
'uuid': 'd9ee1a73-d82d-4ed7-8eb1-80ea2b142ad6',
'id': 1,
'name': 'Water Management',
'subtitle': 'Explore water management concepts and technologies',
'category': 'xseries',
'status': 'active',
'marketing_slug': 'water-management',
'organizations': [
{
'display_name': 'Delft University of Technology',
'key': 'DelftX'
}
],
'banner_image_urls': {
'w1440h480': 'https://example.com/delft-water__1440x480.jpg',
'w348h116': 'https://example.com/delft-water__348x116.jpg',
'w726h242': 'https://example.com/delft-water__726x242.jpg',
'w435h145': 'https://example.com/delft-water__435x145.jpg'
}
},
{
'uuid': 'b043f467-5e80-4225-93d2-248a93a8556a',
'id': 2,
'name': 'Supply Chain Management',
'subtitle': 'Learn how to design and optimize the supply chain to enhance business performance.',
'category': 'xseries',
'status': 'active',
'marketing_slug': 'supply-chain-management-0',
'organizations': [
{
'display_name': 'Massachusetts Institute of Technology',
'key': 'MITx'
}
],
'banner_image_urls': {},
},
]
...@@ -2,37 +2,33 @@ ...@@ -2,37 +2,33 @@
import datetime import datetime
import json import json
from decimal import Decimal from decimal import Decimal
from urllib.parse import parse_qs, urlparse
import ddt import ddt
import mock import mock
import responses import responses
from django.conf import settings from django.test import TestCase
from django.test import TestCase, override_settings
from edx_rest_api_client.auth import BearerAuth, SuppliedJwtAuth from edx_rest_api_client.auth import BearerAuth, SuppliedJwtAuth
from edx_rest_api_client.client import EdxRestApiClient from edx_rest_api_client.client import EdxRestApiClient
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from pytz import UTC from pytz import UTC
from course_discovery.apps.core.tests.factories import PartnerFactory
from course_discovery.apps.core.tests.utils import mock_api_callback
from course_discovery.apps.course_metadata.data_loaders import ( from course_discovery.apps.course_metadata.data_loaders import (
OrganizationsApiDataLoader, CoursesApiDataLoader, DrupalApiDataLoader, EcommerceApiDataLoader, AbstractDataLoader, OrganizationsApiDataLoader, CoursesApiDataLoader, DrupalApiDataLoader, EcommerceApiDataLoader, AbstractDataLoader,
ProgramsApiDataLoader) ProgramsApiDataLoader)
from course_discovery.apps.course_metadata.models import ( from course_discovery.apps.course_metadata.models import (
Course, CourseOrganization, CourseRun, Image, LanguageTag, Organization, Person, Seat, Subject, Course, CourseOrganization, CourseRun, Image, LanguageTag, Organization, Person, Seat, Subject,
Program) Program)
from course_discovery.apps.course_metadata.tests import mock_data
from course_discovery.apps.course_metadata.tests.factories import ( from course_discovery.apps.course_metadata.tests.factories import (
CourseRunFactory, SeatFactory, ImageFactory, PersonFactory, VideoFactory CourseRunFactory, SeatFactory, ImageFactory, PartnerFactory, PersonFactory, VideoFactory
) )
ACCESS_TOKEN = 'secret' ACCESS_TOKEN = 'secret'
ACCESS_TOKEN_TYPE = 'Bearer' ACCESS_TOKEN_TYPE = 'Bearer'
COURSES_API_URL = 'https://lms.example.com/api/courses/v1'
ECOMMERCE_API_URL = 'https://ecommerce.example.com/api/v2'
ENGLISH_LANGUAGE_TAG = LanguageTag(code='en-us', name='English - United States') ENGLISH_LANGUAGE_TAG = LanguageTag(code='en-us', name='English - United States')
JSON = 'application/json' JSON = 'application/json'
MARKETING_API_URL = 'https://example.com/api/catalog/v2/'
ORGANIZATIONS_API_URL = 'https://lms.example.com/api/organizations/v0'
PROGRAMS_API_URL = 'https://programs.example.com/api/v1'
class AbstractDataLoaderTest(TestCase): class AbstractDataLoaderTest(TestCase):
...@@ -73,12 +69,13 @@ class AbstractDataLoaderTest(TestCase): ...@@ -73,12 +69,13 @@ class AbstractDataLoaderTest(TestCase):
# pylint: disable=not-callable # pylint: disable=not-callable
@ddt.ddt @ddt.ddt
class DataLoaderTestMixin(object): class DataLoaderTestMixin(object):
api_url = None
loader_class = None loader_class = None
partner = None
def setUp(self): def setUp(self):
super(DataLoaderTestMixin, self).setUp() super(DataLoaderTestMixin, self).setUp()
self.loader = self.loader_class(self.api_url, ACCESS_TOKEN, ACCESS_TOKEN_TYPE) self.partner = PartnerFactory()
self.loader = self.loader_class(self.partner, ACCESS_TOKEN, ACCESS_TOKEN_TYPE)
def assert_api_called(self, expected_num_calls, check_auth=True): def assert_api_called(self, expected_num_calls, check_auth=True):
""" Asserts the API was called with the correct number of calls, and the appropriate Authorization header. """ """ Asserts the API was called with the correct number of calls, and the appropriate Authorization header. """
...@@ -88,24 +85,24 @@ class DataLoaderTestMixin(object): ...@@ -88,24 +85,24 @@ class DataLoaderTestMixin(object):
def test_init(self): def test_init(self):
""" Verify the constructor sets the appropriate attributes. """ """ Verify the constructor sets the appropriate attributes. """
self.assertEqual(self.loader.api_url, self.api_url) self.assertEqual(self.loader.partner.short_code, self.partner.short_code)
self.assertEqual(self.loader.access_token, ACCESS_TOKEN) self.assertEqual(self.loader.access_token, ACCESS_TOKEN)
self.assertEqual(self.loader.token_type, ACCESS_TOKEN_TYPE.lower()) self.assertEqual(self.loader.token_type, ACCESS_TOKEN_TYPE.lower())
def test_init_with_unsupported_token_type(self): def test_init_with_unsupported_token_type(self):
""" Verify the constructor raises an error if an unsupported token type is passed in. """ """ Verify the constructor raises an error if an unsupported token type is passed in. """
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
self.loader_class(self.api_url, ACCESS_TOKEN, 'not-supported') self.loader_class(self.partner, ACCESS_TOKEN, 'not-supported')
@ddt.unpack @ddt.unpack
@ddt.data( @ddt.data(
('Bearer', BearerAuth), ('Bearer', BearerAuth),
('JWT', SuppliedJwtAuth), ('JWT', SuppliedJwtAuth),
) )
def test_api_client(self, token_type, expected_auth_class): def test_get_api_client(self, token_type, expected_auth_class):
""" Verify the property returns an API client with the correct authentication. """ """ Verify the property returns an API client with the correct authentication. """
loader = self.loader_class(self.api_url, ACCESS_TOKEN, token_type) loader = self.loader_class(self.partner, ACCESS_TOKEN, token_type)
client = loader.api_client client = loader.get_api_client(self.partner.programs_api_url)
self.assertIsInstance(client, EdxRestApiClient) self.assertIsInstance(client, EdxRestApiClient)
# NOTE (CCB): My initial preference was to mock the constructor and ensure the correct auth arguments # NOTE (CCB): My initial preference was to mock the constructor and ensure the correct auth arguments
# were passed. However, that seems nearly impossible. This is the next best alternative. It is brittle, and # were passed. However, that seems nearly impossible. This is the next best alternative. It is brittle, and
...@@ -114,55 +111,18 @@ class DataLoaderTestMixin(object): ...@@ -114,55 +111,18 @@ class DataLoaderTestMixin(object):
@ddt.ddt @ddt.ddt
@override_settings(ORGANIZATIONS_API_URL=ORGANIZATIONS_API_URL)
class OrganizationsApiDataLoaderTests(DataLoaderTestMixin, TestCase): class OrganizationsApiDataLoaderTests(DataLoaderTestMixin, TestCase):
api_url = ORGANIZATIONS_API_URL
loader_class = OrganizationsApiDataLoader loader_class = OrganizationsApiDataLoader
def mock_api(self): def mock_api(self):
bodies = [ bodies = mock_data.ORGANIZATIONS_API_BODIES
{ url = self.partner.organizations_api_url + 'organizations/'
'name': 'edX', responses.add_callback(
'short_name': ' edX ', responses.GET,
'description': 'edX', url,
'logo': 'https://example.com/edx.jpg', callback=mock_api_callback(url, bodies),
}, content_type=JSON
{ )
'name': 'Massachusetts Institute of Technology ',
'short_name': 'MITx',
'description': ' ',
'logo': '',
}
]
def organizations_api_callback(url, data):
def request_callback(request):
# pylint: disable=redefined-builtin
next = None
count = len(bodies)
# Use the querystring to determine which page should be returned. Default to page 1.
# Note that the values of the dict returned by `parse_qs` are lists, hence the `[1]` default value.
qs = parse_qs(urlparse(request.path_url).query)
page = int(qs.get('page', [1])[0])
if page < count:
next = '{}?page={}'.format(url, page)
body = {
'count': count,
'next': next,
'previous': None,
'results': [data[page - 1]]
}
return 200, {}, json.dumps(body)
return request_callback
url = '{host}/organizations/'.format(host=self.api_url)
responses.add_callback(responses.GET, url, callback=organizations_api_callback(url, bodies), content_type=JSON)
return bodies return bodies
def assert_organization_loaded(self, body): def assert_organization_loaded(self, body):
...@@ -181,19 +141,19 @@ class OrganizationsApiDataLoaderTests(DataLoaderTestMixin, TestCase): ...@@ -181,19 +141,19 @@ class OrganizationsApiDataLoaderTests(DataLoaderTestMixin, TestCase):
@responses.activate @responses.activate
def test_ingest(self): def test_ingest(self):
""" Verify the method ingests data from the Organizations API. """ """ Verify the method ingests data from the Organizations API. """
data = self.mock_api() api_data = self.mock_api()
self.assertEqual(Organization.objects.count(), 0) self.assertEqual(Organization.objects.count(), 0)
self.loader.ingest() self.loader.ingest()
# Verify the API was called with the correct authorization header # Verify the API was called with the correct authorization header
expected_num_orgs = len(data) self.assert_api_called(1)
self.assert_api_called(expected_num_orgs)
# Verify the Organizations were created correctly # Verify the Organizations were created correctly
expected_num_orgs = len(api_data)
self.assertEqual(Organization.objects.count(), expected_num_orgs) self.assertEqual(Organization.objects.count(), expected_num_orgs)
for datum in data: for datum in api_data:
self.assert_organization_loaded(datum) self.assert_organization_loaded(datum)
# Verify multiple calls to ingest data do NOT result in data integrity errors. # Verify multiple calls to ingest data do NOT result in data integrity errors.
...@@ -201,106 +161,18 @@ class OrganizationsApiDataLoaderTests(DataLoaderTestMixin, TestCase): ...@@ -201,106 +161,18 @@ class OrganizationsApiDataLoaderTests(DataLoaderTestMixin, TestCase):
@ddt.ddt @ddt.ddt
@override_settings(COURSES_API_URL=COURSES_API_URL)
class CoursesApiDataLoaderTests(DataLoaderTestMixin, TestCase): class CoursesApiDataLoaderTests(DataLoaderTestMixin, TestCase):
api_url = COURSES_API_URL
loader_class = CoursesApiDataLoader loader_class = CoursesApiDataLoader
def mock_api(self): def mock_api(self):
bodies = [ bodies = mock_data.COURSES_API_BODIES
{ url = self.partner.courses_api_url + 'courses/'
'end': '2015-08-08T00:00:00Z', responses.add_callback(
'enrollment_start': '2015-05-15T13:00:00Z', responses.GET,
'enrollment_end': '2015-06-29T13:00:00Z', url,
'id': 'course-v1:MITx+0.111x+2T2015', callback=mock_api_callback(url, bodies, pagination=True),
'media': { content_type=JSON
'image': { )
'raw': 'http://example.com/image.jpg',
},
},
'name': 'Making Science and Engineering Pictures: A Practical Guide to Presenting Your Work',
'number': '0.111x',
'org': 'MITx',
'short_description': '',
'start': '2015-06-15T13:00:00Z',
'pacing': 'self',
},
{
'effort': None,
'end': '2015-12-11T06:00:00Z',
'enrollment_start': None,
'enrollment_end': None,
'id': 'course-v1:KyotoUx+000x+2T2016',
'media': {
'course_image': {
'uri': '/asset-v1:KyotoUx+000x+2T2016+type@asset+block@000x-course_imagec-378x225.jpg'
},
'course_video': {
'uri': None
}
},
'name': 'Evolution of the Human Sociality: A Quest for the Origin of Our Social Behavior',
'number': '000x',
'org': 'KyotoUx',
'short_description': '',
'start': '2015-10-29T09:00:00Z',
'pacing': 'instructor,'
},
{
# Add a second run of KyotoUx+000x (3T2016) to test merging data across
# multiple course runs into a single course.
'effort': None,
'end': None,
'enrollment_start': None,
'enrollment_end': None,
'id': 'course-v1:KyotoUx+000x+3T2016',
'media': {
'course_image': {
'uri': '/asset-v1:KyotoUx+000x+3T2016+type@asset+block@000x-course_imagec-378x225.jpg'
},
'course_video': {
'uri': None
}
},
'name': 'Evolution of the Human Sociality: A Quest for the Origin of Our Social Behavior',
'number': '000x',
'org': 'KyotoUx',
'short_description': '',
'start': None,
},
]
def courses_api_callback(url, data):
def request_callback(request):
# pylint: disable=redefined-builtin
next = None
count = len(bodies)
# Use the querystring to determine which page should be returned. Default to page 1.
# Note that the values of the dict returned by `parse_qs` are lists, hence the `[1]` default value.
qs = parse_qs(urlparse(request.path_url).query)
page = int(qs.get('page', [1])[0])
if page < count:
next = '{}?page={}'.format(url, page)
body = {
'pagination': {
'count': count,
'next': next,
'num_pages': len(data),
'previous': None,
},
'results': [data[page - 1]]
}
return 200, {}, json.dumps(body)
return request_callback
url = '{host}/courses/'.format(host=settings.COURSES_API_URL)
responses.add_callback(responses.GET, url, callback=courses_api_callback(url, bodies), content_type=JSON)
return bodies return bodies
def assert_course_run_loaded(self, body): def assert_course_run_loaded(self, body):
...@@ -330,20 +202,20 @@ class CoursesApiDataLoaderTests(DataLoaderTestMixin, TestCase): ...@@ -330,20 +202,20 @@ class CoursesApiDataLoaderTests(DataLoaderTestMixin, TestCase):
@responses.activate @responses.activate
def test_ingest(self): def test_ingest(self):
""" Verify the method ingests data from the Courses API. """ """ Verify the method ingests data from the Courses API. """
data = self.mock_api() api_data = self.mock_api()
self.assertEqual(Course.objects.count(), 0) self.assertEqual(Course.objects.count(), 0)
self.assertEqual(CourseRun.objects.count(), 0) self.assertEqual(CourseRun.objects.count(), 0)
self.loader.ingest() self.loader.ingest()
# Verify the API was called with the correct authorization header # Verify the API was called with the correct authorization header
expected_num_course_runs = len(data) self.assert_api_called(1)
self.assert_api_called(expected_num_course_runs)
# Verify the CourseRuns were created correctly # Verify the CourseRuns were created correctly
expected_num_course_runs = len(api_data)
self.assertEqual(CourseRun.objects.count(), expected_num_course_runs) self.assertEqual(CourseRun.objects.count(), expected_num_course_runs)
for datum in data: for datum in api_data:
self.assert_course_run_loaded(datum) self.assert_course_run_loaded(datum)
# Verify multiple calls to ingest data do NOT result in data integrity errors. # Verify multiple calls to ingest data do NOT result in data integrity errors.
...@@ -352,15 +224,17 @@ class CoursesApiDataLoaderTests(DataLoaderTestMixin, TestCase): ...@@ -352,15 +224,17 @@ class CoursesApiDataLoaderTests(DataLoaderTestMixin, TestCase):
@responses.activate @responses.activate
def test_ingest_exception_handling(self): def test_ingest_exception_handling(self):
""" Verify the data loader properly handles exceptions during processing of the data from the API. """ """ Verify the data loader properly handles exceptions during processing of the data from the API. """
data = self.mock_api() api_data = self.mock_api()
with mock.patch.object(self.loader, 'clean_strings', side_effect=Exception): with mock.patch.object(self.loader, 'clean_strings', side_effect=Exception):
with mock.patch('course_discovery.apps.course_metadata.data_loaders.logger') as mock_logger: with mock.patch('course_discovery.apps.course_metadata.data_loaders.logger') as mock_logger:
self.loader.ingest() self.loader.ingest()
self.assertEqual(mock_logger.exception.call_count, len(data)) self.assertEqual(mock_logger.exception.call_count, len(api_data))
mock_logger.exception.assert_called_with( msg = 'An error occurred while updating {0} from {1}'.format(
'An error occurred while updating [%s] from [%s]!', data[-1]['id'], self.api_url api_data[-1]['id'],
self.partner.courses_api_url
) )
mock_logger.exception.assert_called_with(msg)
def test_get_pacing_type_field_missing(self): def test_get_pacing_type_field_missing(self):
""" Verify the method returns None if the API response does not include a pacing field. """ """ Verify the method returns None if the API response does not include a pacing field. """
...@@ -422,38 +296,12 @@ class CoursesApiDataLoaderTests(DataLoaderTestMixin, TestCase): ...@@ -422,38 +296,12 @@ class CoursesApiDataLoaderTests(DataLoaderTestMixin, TestCase):
@ddt.ddt @ddt.ddt
@override_settings(MARKETING_API_URL=MARKETING_API_URL)
class DrupalApiDataLoaderTests(DataLoaderTestMixin, TestCase): class DrupalApiDataLoaderTests(DataLoaderTestMixin, TestCase):
EXISTING_COURSE_AND_RUN_DATA = (
{
'course_run_key': 'course-v1:SC+BreadX+3T2015',
'course_key': 'SC+BreadX',
'title': 'Bread Baking 101',
'current_language': 'en-us',
},
{
'course_run_key': 'course-v1:TX+T201+3T2015',
'course_key': 'TX+T201',
'title': 'Testing 201',
'current_language': ''
}
)
# A course which exists, but has no associated runs
EXISTING_COURSE = {
'course_key': 'PartialX+P102',
'title': 'A partial course',
}
ORPHAN_ORGANIZATION_KEY = 'orphan_org'
ORPHAN_STAFF_KEY = 'orphan_staff'
api_url = MARKETING_API_URL
loader_class = DrupalApiDataLoader loader_class = DrupalApiDataLoader
def setUp(self): def setUp(self):
super(DrupalApiDataLoaderTests, self).setUp() super(DrupalApiDataLoaderTests, self).setUp()
for course_dict in self.EXISTING_COURSE_AND_RUN_DATA: for course_dict in mock_data.EXISTING_COURSE_AND_RUN_DATA:
course = Course.objects.create(key=course_dict['course_key'], title=course_dict['title']) course = Course.objects.create(key=course_dict['course_key'], title=course_dict['title'])
course_run = CourseRun.objects.create( course_run = CourseRun.objects.create(
key=course_dict['course_run_key'], key=course_dict['course_run_key'],
...@@ -471,130 +319,16 @@ class DrupalApiDataLoaderTests(DataLoaderTestMixin, TestCase): ...@@ -471,130 +319,16 @@ class DrupalApiDataLoaderTests(DataLoaderTestMixin, TestCase):
relation_type=CourseOrganization.SPONSOR relation_type=CourseOrganization.SPONSOR
) )
Course.objects.create(key=self.EXISTING_COURSE['course_key'], title=self.EXISTING_COURSE['title']) Course.objects.create(key=mock_data.EXISTING_COURSE['course_key'], title=mock_data.EXISTING_COURSE['title'])
Person.objects.create(key=self.ORPHAN_STAFF_KEY) Person.objects.create(key=mock_data.ORPHAN_STAFF_KEY)
Organization.objects.create(key=self.ORPHAN_ORGANIZATION_KEY) Organization.objects.create(key=mock_data.ORPHAN_ORGANIZATION_KEY)
def mock_api(self): def mock_api(self):
"""Mock out the Drupal API. Returns a list of mocked-out course runs.""" """Mock out the Drupal API. Returns a list of mocked-out course runs."""
body = { body = mock_data.MARKETING_API_BODY
'items': [
{
'title': self.EXISTING_COURSE_AND_RUN_DATA[0]['title'],
'start': '2015-06-15T13:00:00Z',
'end': '2015-12-15T13:00:00Z',
'level': {
'title': 'Introductory',
},
'course_about_uri': '/course/bread-baking-101',
'course_id': self.EXISTING_COURSE_AND_RUN_DATA[0]['course_run_key'],
'subjects': [{
'title': 'Bread baking',
}],
'current_language': self.EXISTING_COURSE_AND_RUN_DATA[0]['current_language'],
'subtitle': 'Learn about Bread',
'description': '<p>Bread is a <a href="/wiki/Staple_food" title="Staple food">staple food</a>.',
'sponsors': [{
'uuid': 'abc123',
'title': 'Tatte',
'image': 'http://example.com/tatte.jpg',
'uri': 'sponsor/tatte'
}],
'staff': [{
'uuid': 'staff123',
'title': 'The Muffin Man',
'image': 'http://example.com/muffinman.jpg',
'display_position': {
'title': 'Baker'
}
}, {
'uuid': 'staffZYX',
'title': 'Arthur',
'image': 'http://example.com/kingarthur.jpg',
'display_position': {
'title': 'King'
}
}]
},
{
'title': self.EXISTING_COURSE_AND_RUN_DATA[1]['title'],
'start': '2015-06-15T13:00:00Z',
'end': '2015-12-15T13:00:00Z',
'level': {
'title': 'Intermediate',
},
'course_about_uri': '/course/testing-201',
'course_id': self.EXISTING_COURSE_AND_RUN_DATA[1]['course_run_key'],
'subjects': [{
'title': 'testing',
}],
'current_language': self.EXISTING_COURSE_AND_RUN_DATA[1]['current_language'],
'subtitle': 'Testing 201',
'description': "how to test better",
'sponsors': [],
'staff': [{
'uuid': '432staff',
'title': 'Test',
'image': 'http://example.com/test.jpg',
'display_position': {
'title': 'Tester'
}
}]
},
{ # Create a course which exists in LMS/Otto, but without course runs
'title': self.EXISTING_COURSE['title'],
'start': '2015-06-15T13:00:00Z',
'end': '2015-12-15T13:00:00Z',
'level': {
'title': 'Advanced',
},
'course_about_uri': '/course/partial-101',
'course_id': 'course-v1:{course_key}+run'.format(course_key=self.EXISTING_COURSE['course_key']),
'subjects': [{
'title': 'partially fake',
}],
'current_language': 'en-us',
'subtitle': 'Nope',
'description': 'what is fake?',
'sponsors': [{
'uuid': '123abc',
'title': 'Fake',
'image': 'http://example.com/fake.jpg',
'uri': 'sponsor/fake'
}, {
'uuid': 'qwertyuiop',
'title': 'Faux',
'image': 'http://example.com/faux.jpg',
'uri': 'sponsor/faux'
}],
'staff': [],
},
{ # Create a fake course run which doesn't exist in LMS/Otto
'title': 'A partial course',
'start': '2015-06-15T13:00:00Z',
'end': '2015-12-15T13:00:00Z',
'level': {
'title': 'Advanced',
},
'course_about_uri': '/course/partial-101',
'course_id': 'course-v1:fakeX+fake+reallyfake',
'subjects': [{
'title': 'seriously fake',
}],
'current_language': 'en-us',
'subtitle': 'Nope',
'description': 'what is real?',
'sponsors': [],
'staff': [],
},
# NOTE (CCB): Some of the entries are empty arrays. Remove this as part of ECOM-4493.
[],
]
}
responses.add( responses.add(
responses.GET, responses.GET,
settings.MARKETING_API_URL + 'courses/', self.partner.marketing_api_url + 'courses/',
body=json.dumps(body), body=json.dumps(body),
status=200, status=200,
content_type='application/json' content_type='application/json'
...@@ -624,6 +358,7 @@ class DrupalApiDataLoaderTests(DataLoaderTestMixin, TestCase): ...@@ -624,6 +358,7 @@ class DrupalApiDataLoaderTests(DataLoaderTestMixin, TestCase):
def assert_staff_loaded(self, course_run, body): def assert_staff_loaded(self, course_run, body):
"""Verify that staff have been loaded correctly.""" """Verify that staff have been loaded correctly."""
course_run_staff = course_run.staff.all() course_run_staff = course_run.staff.all()
api_staff = body['staff'] api_staff = body['staff']
self.assertEqual(len(course_run_staff), len(api_staff)) self.assertEqual(len(course_run_staff), len(api_staff))
...@@ -662,10 +397,10 @@ class DrupalApiDataLoaderTests(DataLoaderTestMixin, TestCase): ...@@ -662,10 +397,10 @@ class DrupalApiDataLoaderTests(DataLoaderTestMixin, TestCase):
@responses.activate @responses.activate
def test_ingest(self): def test_ingest(self):
"""Verify the data loader ingests data from Drupal.""" """Verify the data loader ingests data from Drupal."""
data = self.mock_api() api_data = self.mock_api()
# Neither the faked course, nor the empty array, should not be loaded from Drupal. # Neither the faked course, nor the empty array, should not be loaded from Drupal.
# Change this back to -2 as part of ECOM-4493. # Change this back to -2 as part of ECOM-4493.
loaded_data = data[:-3] loaded_data = api_data[:-3]
self.loader.ingest() self.loader.ingest()
...@@ -674,27 +409,28 @@ class DrupalApiDataLoaderTests(DataLoaderTestMixin, TestCase): ...@@ -674,27 +409,28 @@ class DrupalApiDataLoaderTests(DataLoaderTestMixin, TestCase):
# Assert that the fake course was not created # Assert that the fake course was not created
self.assertEqual(CourseRun.objects.count(), len(loaded_data)) self.assertEqual(CourseRun.objects.count(), len(loaded_data))
for datum in loaded_data: for datum in loaded_data:
self.assert_course_run_loaded(datum) self.assert_course_run_loaded(datum)
Course.objects.get(key=self.EXISTING_COURSE['course_key'], title=self.EXISTING_COURSE['title']) Course.objects.get(key=mock_data.EXISTING_COURSE['course_key'], title=mock_data.EXISTING_COURSE['title'])
# Verify multiple calls to ingest data do NOT result in data integrity errors. # Verify multiple calls to ingest data do NOT result in data integrity errors.
self.loader.ingest() self.loader.ingest()
# Verify that orphan data is deleted # Verify that orphan data is deleted
self.assertFalse(Person.objects.filter(key=self.ORPHAN_STAFF_KEY).exists()) self.assertFalse(Person.objects.filter(key=mock_data.ORPHAN_STAFF_KEY).exists())
self.assertFalse(Organization.objects.filter(key=self.ORPHAN_ORGANIZATION_KEY).exists()) self.assertFalse(Organization.objects.filter(key=mock_data.ORPHAN_ORGANIZATION_KEY).exists())
self.assertFalse(Person.objects.filter(key__startswith='orphan_staff_').exists()) self.assertFalse(Person.objects.filter(key__startswith='orphan_staff_').exists())
self.assertFalse(Organization.objects.filter(key__startswith='orphan_org_').exists()) self.assertFalse(Organization.objects.filter(key__startswith='orphan_org_').exists())
@responses.activate @responses.activate
def test_ingest_exception_handling(self): def test_ingest_exception_handling(self):
""" Verify the data loader properly handles exceptions during processing of the data from the API. """ """ Verify the data loader properly handles exceptions during processing of the data from the API. """
data = self.mock_api() api_data = self.mock_api()
# Include all data, except the empty array. # Include all data, except the empty array.
# TODO: Remove the -1 after ECOM-4493 is in production. # TODO: Remove the -1 after ECOM-4493 is in production.
expected_call_count = len(data) - 1 expected_call_count = len(api_data) - 1
with mock.patch.object(self.loader, 'clean_strings', side_effect=Exception): with mock.patch.object(self.loader, 'clean_strings', side_effect=Exception):
with mock.patch('course_discovery.apps.course_metadata.data_loaders.logger') as mock_logger: with mock.patch('course_discovery.apps.course_metadata.data_loaders.logger') as mock_logger:
...@@ -702,9 +438,11 @@ class DrupalApiDataLoaderTests(DataLoaderTestMixin, TestCase): ...@@ -702,9 +438,11 @@ class DrupalApiDataLoaderTests(DataLoaderTestMixin, TestCase):
self.assertEqual(mock_logger.exception.call_count, expected_call_count) self.assertEqual(mock_logger.exception.call_count, expected_call_count)
# TODO: Change the -2 to -1 after ECOM-4493 is in production. # TODO: Change the -2 to -1 after ECOM-4493 is in production.
mock_logger.exception.assert_called_with( msg = 'An error occurred while updating {0} from {1}'.format(
'An error occurred while updating [%s] from [%s]!', data[-2]['course_id'], self.api_url api_data[-2]['course_id'],
self.partner.marketing_api_url
) )
mock_logger.exception.assert_called_with(msg)
@ddt.data( @ddt.data(
('', ''), ('', ''),
...@@ -733,274 +471,34 @@ class DrupalApiDataLoaderTests(DataLoaderTestMixin, TestCase): ...@@ -733,274 +471,34 @@ class DrupalApiDataLoaderTests(DataLoaderTestMixin, TestCase):
@ddt.ddt @ddt.ddt
@override_settings(ECOMMERCE_API_URL=ECOMMERCE_API_URL)
class EcommerceApiDataLoaderTests(DataLoaderTestMixin, TestCase): class EcommerceApiDataLoaderTests(DataLoaderTestMixin, TestCase):
api_url = ECOMMERCE_API_URL
loader_class = EcommerceApiDataLoader loader_class = EcommerceApiDataLoader
def mock_api(self): def mock_api(self):
course_run_audit = CourseRunFactory(title_override='audit')
course_run_verified = CourseRunFactory(title_override='verified')
course_run_credit = CourseRunFactory(title_override='credit')
course_run_no_currency = CourseRunFactory(title_override='no currency')
# create existing seats to be removed by ingest # create existing seats to be removed by ingest
SeatFactory(course_run=course_run_audit, type=Seat.PROFESSIONAL) audit_run = CourseRunFactory(title_override='audit', key='audit/course/run')
SeatFactory(course_run=course_run_verified, type=Seat.PROFESSIONAL) verified_run = CourseRunFactory(title_override='verified', key='verified/course/run')
SeatFactory(course_run=course_run_credit, type=Seat.PROFESSIONAL) credit_run = CourseRunFactory(title_override='credit', key='credit/course/run')
SeatFactory(course_run=course_run_no_currency, type=Seat.PROFESSIONAL) no_currency_run = CourseRunFactory(title_override='no currency', key='nocurrency/course/run')
bodies = [ SeatFactory(course_run=audit_run, type=Seat.PROFESSIONAL)
{ SeatFactory(course_run=verified_run, type=Seat.PROFESSIONAL)
"id": course_run_audit.key, SeatFactory(course_run=credit_run, type=Seat.PROFESSIONAL)
"products": [ SeatFactory(course_run=no_currency_run, type=Seat.PROFESSIONAL)
{
"structure": "parent", bodies = mock_data.ECOMMERCE_API_BODIES
"price": None, url = self.partner.ecommerce_api_url + 'courses/'
"expires": None, responses.add_callback(
"attribute_values": [], responses.GET,
"is_available_to_buy": False, url,
"stockrecords": [] callback=mock_api_callback(url, bodies),
}, content_type=JSON
{ )
"structure": "child",
"expires": None,
"attribute_values": [],
"stockrecords": [
{
"price_currency": "USD",
"price_excl_tax": "0.00",
}
]
}
]
},
{
"id": course_run_verified.key,
"products": [
{
"structure": "parent",
"price": None,
"expires": None,
"attribute_values": [],
"is_available_to_buy": False,
"stockrecords": []
},
{
"structure": "child",
"expires": None,
"attribute_values": [
{
"name": "certificate_type",
"value": "honor"
}
],
"stockrecords": [
{
"price_currency": "EUR",
"price_excl_tax": "0.00",
}
]
},
{
"structure": "child",
"expires": "2017-01-01T12:00:00Z",
"attribute_values": [
{
"name": "certificate_type",
"value": "verified"
}
],
"stockrecords": [
{
"price_currency": "EUR",
"price_excl_tax": "25.00",
}
]
}
]
},
{
# This credit course has two credit seats to verify we are correctly finding/updating using the credit
# provider field.
"id": course_run_credit.key,
"products": [
{
"structure": "parent",
"price": None,
"expires": None,
"attribute_values": [],
"is_available_to_buy": False,
"stockrecords": []
},
{
"structure": "child",
"expires": None,
"attribute_values": [],
"stockrecords": [
{
"price_currency": "USD",
"price_excl_tax": "0.00",
}
]
},
{
"structure": "child",
"expires": "2017-01-01T12:00:00Z",
"attribute_values": [
{
"name": "certificate_type",
"value": "verified"
}
],
"stockrecords": [
{
"price_currency": "USD",
"price_excl_tax": "25.00",
}
]
},
{
"structure": "child",
"expires": "2017-06-01T12:00:00Z",
"attribute_values": [
{
"name": "certificate_type",
"value": "credit"
},
{
"name": "credit_hours",
"value": 2
},
{
"name": "credit_provider",
"value": "asu"
},
{
"name": "verification_required",
"value": False
},
],
"stockrecords": [
{
"price_currency": "USD",
"price_excl_tax": "250.00",
}
]
},
{
"structure": "child",
"expires": "2017-06-01T12:00:00Z",
"attribute_values": [
{
"name": "certificate_type",
"value": "credit"
},
{
"name": "credit_hours",
"value": 2
},
{
"name": "credit_provider",
"value": "acme"
},
{
"name": "verification_required",
"value": False
},
],
"stockrecords": [
{
"price_currency": "USD",
"price_excl_tax": "250.00",
}
]
}
]
},
{ # Course with a currency not found in the database
"id": course_run_no_currency.key,
"products": [
{
"structure": "parent",
"price": None,
"expires": None,
"attribute_values": [],
"is_available_to_buy": False,
"stockrecords": []
},
{
"structure": "child",
"expires": None,
"attribute_values": [],
"stockrecords": [
{
"price_currency": "123",
"price_excl_tax": "0.00",
}
]
}
]
},
{ # Course which does not exist in LMS
"id": "fake-course-does-not-exist",
"products": [
{
"structure": "parent",
"price": None,
"expires": None,
"attribute_values": [],
"is_available_to_buy": False,
"stockrecords": []
},
{
"structure": "child",
"expires": None,
"attribute_values": [],
"stockrecords": [
{
"price_currency": "USD",
"price_excl_tax": "0.00",
}
]
}
]
}
]
def courses_api_callback(url, data):
def request_callback(request):
# pylint: disable=redefined-builtin
next = None
count = len(bodies)
# Use the querystring to determine which page should be returned. Default to page 1.
# Note that the values of the dict returned by `parse_qs` are lists, hence the `[1]` default value.
qs = parse_qs(urlparse(request.path_url).query)
page = int(qs.get('page', [1])[0])
if page < count:
next = '{}?page={}'.format(url, page)
body = {
'count': count,
'next': next,
'previous': None,
'results': [data[page - 1]]
}
return 200, {}, json.dumps(body)
return request_callback
url = '{host}/courses/'.format(host=settings.ECOMMERCE_API_URL)
responses.add_callback(responses.GET, url, callback=courses_api_callback(url, bodies), content_type=JSON)
return bodies return bodies
def assert_seats_loaded(self, body): def assert_seats_loaded(self, body):
""" Assert a Seat corresponding to the specified data body was properly loaded into the database. """ """ Assert a Seat corresponding to the specified data body was properly loaded into the database. """
course_run = CourseRun.objects.get(key=body['id']) course_run = CourseRun.objects.get(key=body['id'])
products = [p for p in body['products'] if p['structure'] == 'child'] products = [p for p in body['products'] if p['structure'] == 'child']
# Verify that the old seat is removed # Verify that the old seat is removed
...@@ -1042,9 +540,9 @@ class EcommerceApiDataLoaderTests(DataLoaderTestMixin, TestCase): ...@@ -1042,9 +540,9 @@ class EcommerceApiDataLoaderTests(DataLoaderTestMixin, TestCase):
@responses.activate @responses.activate
def test_ingest(self): def test_ingest(self):
""" Verify the method ingests data from the E-Commerce API. """ """ Verify the method ingests data from the E-Commerce API. """
data = self.mock_api() api_data = self.mock_api()
loaded_course_run_data = data[:-1] loaded_course_run_data = api_data[:-1]
loaded_seat_data = data[:-2] loaded_seat_data = api_data[:-2]
self.assertEqual(CourseRun.objects.count(), len(loaded_course_run_data)) self.assertEqual(CourseRun.objects.count(), len(loaded_course_run_data))
...@@ -1055,8 +553,7 @@ class EcommerceApiDataLoaderTests(DataLoaderTestMixin, TestCase): ...@@ -1055,8 +553,7 @@ class EcommerceApiDataLoaderTests(DataLoaderTestMixin, TestCase):
self.loader.ingest() self.loader.ingest()
# Verify the API was called with the correct authorization header # Verify the API was called with the correct authorization header
expected_num_course_runs = len(data) self.assert_api_called(1)
self.assert_api_called(expected_num_course_runs)
for datum in loaded_seat_data: for datum in loaded_seat_data:
self.assert_seats_loaded(datum) self.assert_seats_loaded(datum)
...@@ -1085,80 +582,18 @@ class EcommerceApiDataLoaderTests(DataLoaderTestMixin, TestCase): ...@@ -1085,80 +582,18 @@ class EcommerceApiDataLoaderTests(DataLoaderTestMixin, TestCase):
@ddt.ddt @ddt.ddt
@override_settings(PROGRAMS_API_URL=PROGRAMS_API_URL)
class ProgramsApiDataLoaderTests(DataLoaderTestMixin, TestCase): class ProgramsApiDataLoaderTests(DataLoaderTestMixin, TestCase):
api_url = PROGRAMS_API_URL
loader_class = ProgramsApiDataLoader loader_class = ProgramsApiDataLoader
def mock_api(self): def mock_api(self):
bodies = [ bodies = mock_data.PROGRAMS_API_BODIES
{ url = self.partner.programs_api_url + 'programs/'
'uuid': 'd9ee1a73-d82d-4ed7-8eb1-80ea2b142ad6', responses.add_callback(
'id': 1, responses.GET,
'name': 'Water Management', url,
'subtitle': 'Explore water management concepts and technologies', callback=mock_api_callback(url, bodies),
'category': 'xseries', content_type=JSON
'status': 'active', )
'marketing_slug': 'water-management',
'organizations': [
{
'display_name': 'Delft University of Technology',
'key': 'DelftX'
}
],
'banner_image_urls': {
'w1440h480': 'https://example.com/delft-water__1440x480.jpg',
'w348h116': 'https://example.com/delft-water__348x116.jpg',
'w726h242': 'https://example.com/delft-water__726x242.jpg',
'w435h145': 'https://example.com/delft-water__435x145.jpg'
}
},
{
'uuid': 'b043f467-5e80-4225-93d2-248a93a8556a',
'id': 2,
'name': 'Supply Chain Management',
'subtitle': 'Learn how to design and optimize the supply chain to enhance business performance.',
'category': 'xseries',
'status': 'active',
'marketing_slug': 'supply-chain-management-0',
'organizations': [
{
'display_name': 'Massachusetts Institute of Technology',
'key': 'MITx'
}
],
'banner_image_urls': {},
},
]
def programs_api_callback(url, data):
def request_callback(request):
# pylint: disable=redefined-builtin
next = None
count = len(bodies)
# Use the querystring to determine which page should be returned. Default to page 1.
# Note that the values of the dict returned by `parse_qs` are lists, hence the `[1]` default value.
qs = parse_qs(urlparse(request.path_url).query)
page = int(qs.get('page', [1])[0])
if page < count:
next = '{}?page={}'.format(url, page)
body = {
'count': count,
'next': next,
'previous': None,
'results': [data[page - 1]]
}
return 200, {}, json.dumps(body)
return request_callback
url = '{host}/programs/'.format(host=self.api_url)
responses.add_callback(responses.GET, url, callback=programs_api_callback(url, bodies), content_type=JSON)
return bodies return bodies
def assert_program_loaded(self, body): def assert_program_loaded(self, body):
...@@ -1176,23 +611,26 @@ class ProgramsApiDataLoaderTests(DataLoaderTestMixin, TestCase): ...@@ -1176,23 +611,26 @@ class ProgramsApiDataLoaderTests(DataLoaderTestMixin, TestCase):
image_url = body.get('banner_image_urls', {}).get('w435h145') image_url = body.get('banner_image_urls', {}).get('w435h145')
if image_url: if image_url:
image = Image.objects.get(src=image_url, width=self.loader_class.image_width, image = Image.objects.get(src=image_url, width=self.loader.image_width,
height=self.loader_class.image_height) height=self.loader.image_height)
self.assertEqual(program.image, image) self.assertEqual(program.image, image)
@responses.activate @responses.activate
def test_ingest(self): def test_ingest(self):
""" Verify the method ingests data from the Organizations API. """ """ Verify the method ingests data from the Organizations API. """
data = self.mock_api() api_data = self.mock_api()
self.assertEqual(Program.objects.count(), 0) self.assertEqual(Program.objects.count(), 0)
self.loader.ingest() self.loader.ingest()
expected_num_programs = len(data) # Verify the API was called with the correct authorization header
self.assert_api_called(expected_num_programs) self.assert_api_called(1)
# Verify the Programs were created correctly
expected_num_programs = len(api_data)
self.assertEqual(Program.objects.count(), expected_num_programs) self.assertEqual(Program.objects.count(), expected_num_programs)
for datum in data: for datum in api_data:
self.assert_program_loaded(datum) self.assert_program_loaded(datum)
self.loader.ingest() self.loader.ingest()
...@@ -3,8 +3,8 @@ import datetime ...@@ -3,8 +3,8 @@ import datetime
import ddt import ddt
import mock import mock
import pytz import pytz
from dateutil.parser import parse from dateutil.parser import parse
from django.conf import settings
from django.db import IntegrityError from django.db import IntegrityError
from django.test import TestCase from django.test import TestCase
from freezegun import freeze_time from freezegun import freeze_time
...@@ -278,7 +278,7 @@ class ProgramTests(TestCase): ...@@ -278,7 +278,7 @@ class ProgramTests(TestCase):
def test_marketing_url(self): def test_marketing_url(self):
""" Verify the property creates a complete marketing URL. """ """ Verify the property creates a complete marketing URL. """
expected = '{root}/{category}/{slug}'.format(root=settings.MARKETING_URL_ROOT.strip('/'), expected = '{root}/{category}/{slug}'.format(root=self.program.partner.marketing_url_root.strip('/'),
category=self.program.category, slug=self.program.marketing_slug) category=self.program.category, slug=self.program.marketing_slug)
self.assertEqual(self.program.marketing_url, expected) self.assertEqual(self.program.marketing_url, expected)
......
...@@ -354,13 +354,10 @@ HAYSTACK_CONNECTIONS = { ...@@ -354,13 +354,10 @@ HAYSTACK_CONNECTIONS = {
HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor' HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'
COURSES_API_URL = 'http://127.0.0.1:8000/api/courses/v1/'
ECOMMERCE_API_URL = 'http://127.0.0.1:8002/api/v2/'
ORGANIZATIONS_API_URL = 'http://127.0.0.1:8000/api/organizations/v0/'
PROGRAMS_API_URL = 'http://127.0.0.1:8003/api/v1/'
MARKETING_API_URL = 'http://example.org/api/catalog/v2/'
MARKETING_URL_ROOT = 'http://example.org/'
COMPRESS_PRECOMPILERS = ( COMPRESS_PRECOMPILERS = (
('text/x-scss', 'django_libsass.SassCompiler'), ('text/x-scss', 'django_libsass.SassCompiler'),
) )
DEFAULT_PARTNER_ID = None
...@@ -42,3 +42,5 @@ JWT_AUTH['JWT_SECRET_KEY'] = 'course-discovery-jwt-secret-key' ...@@ -42,3 +42,5 @@ JWT_AUTH['JWT_SECRET_KEY'] = 'course-discovery-jwt-secret-key'
EDX_DRF_EXTENSIONS = { EDX_DRF_EXTENSIONS = {
'OAUTH2_USER_INFO_URL': 'http://example.com/oauth2/user_info', 'OAUTH2_USER_INFO_URL': 'http://example.com/oauth2/user_info',
} }
DEFAULT_PARTNER_ID = 1
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment