Commit 9d270564 by Clinton Blackburn Committed by Clinton Blackburn

Publishing course runs to Studio upon creation

LEARNER-2469
parent 3585eda5
...@@ -48,7 +48,9 @@ def make_request(): ...@@ -48,7 +48,9 @@ def make_request():
return request return request
def serialize_datetime(d): def serialize_datetime_without_timezone(d):
# TODO: Remove this function, and replace usage of it with serialize_datetime, after
# https://github.com/encode/django-rest-framework/issues/3732 is released.
return d.strftime('%Y-%m-%dT%H:%M:%S') if d else None return d.strftime('%Y-%m-%dT%H:%M:%S') if d else None
...@@ -1200,10 +1202,10 @@ class CourseRunSearchSerializerTests(ElasticsearchTestMixin, TestCase): ...@@ -1200,10 +1202,10 @@ class CourseRunSearchSerializerTests(ElasticsearchTestMixin, TestCase):
'max_effort': course_run.max_effort, 'max_effort': course_run.max_effort,
'weeks_to_complete': course_run.weeks_to_complete, 'weeks_to_complete': course_run.weeks_to_complete,
'short_description': course_run.short_description, 'short_description': course_run.short_description,
'start': serialize_datetime(course_run.start), 'start': serialize_datetime_without_timezone(course_run.start),
'end': serialize_datetime(course_run.end), 'end': serialize_datetime_without_timezone(course_run.end),
'enrollment_start': serialize_datetime(course_run.enrollment_start), 'enrollment_start': serialize_datetime_without_timezone(course_run.enrollment_start),
'enrollment_end': serialize_datetime(course_run.enrollment_end), 'enrollment_end': serialize_datetime_without_timezone(course_run.enrollment_end),
'key': course_run.key, 'key': course_run.key,
'marketing_url': course_run.marketing_url, 'marketing_url': course_run.marketing_url,
'pacing_type': course_run.pacing_type, 'pacing_type': course_run.pacing_type,
......
""" Core models. """ """ Core models. """
import datetime
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.core.cache import cache
from django.db import models from django.db import models
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django_extensions.db.models import TimeStampedModel from django_extensions.db.models import TimeStampedModel
from edx_rest_api_client.client import EdxRestApiClient
from guardian.mixins import GuardianUserMixin from guardian.mixins import GuardianUserMixin
...@@ -91,3 +95,32 @@ class Partner(TimeStampedModel): ...@@ -91,3 +95,32 @@ class Partner(TimeStampedModel):
@property @property
def has_marketing_site(self): def has_marketing_site(self):
return bool(self.marketing_site_url_root) return bool(self.marketing_site_url_root)
@property
def access_token(self):
""" Returns an access token for this site's service user.
Returns:
str: JWT access token
"""
key = 'partner_access_token_{}'.format(self.id)
access_token = cache.get(key)
if not access_token:
url = '{root}/access_token'.format(root=self.oidc_url_root)
access_token, expiration_datetime = EdxRestApiClient.get_oauth_access_token(
url,
self.oidc_key,
self.oidc_secret,
token_type='jwt'
)
expires = (expiration_datetime - datetime.datetime.utcnow()).seconds
cache.set(key, access_token, expires)
return access_token
@cached_property
def studio_api_client(self):
studio_api_url = '{root}/api/v1/'.format(root=self.studio_url.strip('/'))
return EdxRestApiClient(studio_api_url, jwt=self.access_token)
...@@ -40,18 +40,18 @@ class PartnerFactory(factory.DjangoModelFactory): ...@@ -40,18 +40,18 @@ class PartnerFactory(factory.DjangoModelFactory):
name = factory.Sequence(lambda n: 'test-partner-{}'.format(n)) # pylint: disable=unnecessary-lambda 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 short_code = factory.Sequence(lambda n: 'test{}'.format(n)) # pylint: disable=unnecessary-lambda
courses_api_url = '{root}/api/courses/v1/'.format(root=FuzzyUrlRoot().fuzz()) courses_api_url = '{root}/api/courses/v1/'.format(root=FuzzyUrlRoot().fuzz())
ecommerce_api_url = '{root}/api/courses/v1/'.format(root=FuzzyUrlRoot().fuzz()) ecommerce_api_url = '{root}/api/v2/'.format(root=FuzzyUrlRoot().fuzz())
organizations_api_url = '{root}/api/organizations/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()) programs_api_url = '{root}/api/programs/v1/'.format(root=FuzzyUrlRoot().fuzz())
marketing_site_api_url = '{root}/api/courses/v1/'.format(root=FuzzyUrlRoot().fuzz()) marketing_site_api_url = '{root}/api/courses/v1/'.format(root=FuzzyUrlRoot().fuzz())
marketing_site_url_root = '{root}/'.format(root=FuzzyUrlRoot().fuzz()) marketing_site_url_root = factory.Faker('url')
marketing_site_api_username = factory.Faker('user_name') marketing_site_api_username = factory.Faker('user_name')
marketing_site_api_password = factory.Faker('password') marketing_site_api_password = factory.Faker('password')
oidc_url_root = '{root}'.format(root=FuzzyUrlRoot().fuzz()) oidc_url_root = factory.Faker('url')
oidc_key = factory.Faker('sha256') oidc_key = factory.Faker('sha256')
oidc_secret = factory.Faker('sha256') oidc_secret = factory.Faker('sha256')
site = factory.SubFactory(SiteFactory) site = factory.SubFactory(SiteFactory)
studio_url = FuzzyUrlRoot().fuzz() studio_url = factory.Faker('url')
class Meta(object): class Meta(object):
model = Partner model = Partner
""" Tests for core models. """ """ Tests for core models. """
import ddt import ddt
import responses
from django.test import TestCase from django.test import TestCase
from social_django.models import UserSocialAuth from social_django.models import UserSocialAuth
...@@ -75,3 +76,21 @@ class PartnerTests(TestCase): ...@@ -75,3 +76,21 @@ class PartnerTests(TestCase):
def test_has_marketing_site(self, marketing_site_url_root, expected): def test_has_marketing_site(self, marketing_site_url_root, expected):
partner = PartnerFactory(marketing_site_url_root=marketing_site_url_root) partner = PartnerFactory(marketing_site_url_root=marketing_site_url_root)
self.assertEqual(partner.has_marketing_site, expected) # pylint: disable=no-member self.assertEqual(partner.has_marketing_site, expected) # pylint: disable=no-member
@responses.activate
def test_access_token(self):
""" Verify the property retrieves, and caches, an access token from the OAuth 2.0 provider. """
token = 'abc123'
partner = PartnerFactory()
url = '{root}/access_token'.format(root=partner.oidc_url_root)
body = {
'access_token': token,
'expires_in': 3600,
}
responses.add(responses.POST, url, json=body, status=200)
assert partner.access_token == token
assert len(responses.calls) == 1
# No HTTP calls should be made if the access token is cached.
responses.reset()
assert partner.access_token == token
...@@ -8,6 +8,10 @@ from course_discovery.settings.process_synonyms import get_synonyms ...@@ -8,6 +8,10 @@ from course_discovery.settings.process_synonyms import get_synonyms
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def serialize_datetime(d):
return d.strftime('%Y-%m-%dT%H:%M:%SZ') if d else None
class ElasticsearchUtils(object): class ElasticsearchUtils(object):
@classmethod @classmethod
def create_alias_and_index(cls, es_connection, alias): def create_alias_and_index(cls, es_connection, alias):
...@@ -84,6 +88,7 @@ class SearchQuerySetWrapper(object): ...@@ -84,6 +88,7 @@ class SearchQuerySetWrapper(object):
""" """
Decorates a SearchQuerySet object using a generator for efficient iteration Decorates a SearchQuerySet object using a generator for efficient iteration
""" """
def __init__(self, qs): def __init__(self, qs):
self.qs = qs self.qs = qs
......
...@@ -146,7 +146,7 @@ class SeatFactory(factory.DjangoModelFactory): ...@@ -146,7 +146,7 @@ class SeatFactory(factory.DjangoModelFactory):
class OrganizationFactory(factory.DjangoModelFactory): class OrganizationFactory(factory.DjangoModelFactory):
uuid = factory.LazyFunction(uuid4) uuid = factory.LazyFunction(uuid4)
key = FuzzyText(prefix='Org.fake/') key = FuzzyText()
name = FuzzyText() name = FuzzyText()
description = FuzzyText() description = FuzzyText()
homepage_url = FuzzyURL() homepage_url = FuzzyURL()
......
default_app_config = 'course_discovery.apps.publisher.apps.PublisherAppConfig'
from django.apps import AppConfig
class PublisherAppConfig(AppConfig):
name = 'course_discovery.apps.publisher'
verbose_name = 'Publisher'
def ready(self):
super().ready()
# noinspection PyUnresolvedReferences
import course_discovery.apps.publisher.signals # pylint: disable=unused-variable
...@@ -6,6 +6,7 @@ from django.contrib.auth.models import Group ...@@ -6,6 +6,7 @@ from django.contrib.auth.models import Group
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django_extensions.db.models import TimeStampedModel from django_extensions.db.models import TimeStampedModel
from django_fsm import FSMField, transition from django_fsm import FSMField, transition
...@@ -16,6 +17,7 @@ from taggit.managers import TaggableManager ...@@ -16,6 +17,7 @@ from taggit.managers import TaggableManager
from course_discovery.apps.core.models import Currency, User from course_discovery.apps.core.models import Currency, User
from course_discovery.apps.course_metadata.choices import CourseRunPacing from course_discovery.apps.course_metadata.choices import CourseRunPacing
from course_discovery.apps.course_metadata.models import Course as DiscoveryCourse
from course_discovery.apps.course_metadata.models import LevelType, Organization, Person, Subject from course_discovery.apps.course_metadata.models import LevelType, Organization, Person, Subject
from course_discovery.apps.course_metadata.utils import UploadToFieldNamePath from course_discovery.apps.course_metadata.utils import UploadToFieldNamePath
from course_discovery.apps.ietf_language_tags.models import LanguageTag from course_discovery.apps.ietf_language_tags.models import LanguageTag
...@@ -253,6 +255,11 @@ class Course(TimeStampedModel, ChangedByMixin): ...@@ -253,6 +255,11 @@ class Course(TimeStampedModel, ChangedByMixin):
return self.title return self.title
@cached_property
def discovery_counterpart(self):
course_key = '{org}+{number}'.format(org=self.organizations.first().key, number=self.number)
return DiscoveryCourse.objects.get(partner=self.partner, key=course_key)
class CourseRun(TimeStampedModel, ChangedByMixin): class CourseRun(TimeStampedModel, ChangedByMixin):
""" Publisher CourseRun model. It contains fields related to the course run intake form.""" """ Publisher CourseRun model. It contains fields related to the course run intake form."""
......
import logging
import waffle
from django.core.exceptions import ObjectDoesNotExist
from django.db.models.signals import post_save
from django.dispatch import receiver
from slumber.exceptions import SlumberBaseException
from course_discovery.apps.publisher.models import CourseRun
from course_discovery.apps.publisher.studio_api_utils import StudioAPI
logger = logging.getLogger(__name__)
def get_related_discovery_course_run(publisher_course_run):
discovery_course = publisher_course_run.course.discovery_counterpart
return discovery_course.course_runs.latest('start')
@receiver(post_save, sender=CourseRun)
def create_course_run_in_studio_receiver(sender, instance, created, **kwargs): # pylint: disable=unused-argument
if created and waffle.switch_is_active('enable_publisher_create_course_run_in_studio'):
course = instance.course
partner = course.partner
if not partner:
logger.error('Failed to publish course run [%d] to Studio. Related course [%d] has no associated Partner.',
instance.id, course.id)
return
logger.info('Publishing course run [%d] to Studio...', instance.id)
api = StudioAPI(instance.course.partner.studio_api_client)
# TODO How to handle the fact that Publisher does not expose enrollment date fields?
instance.enrollment_start = instance.enrollment_start or instance.start
instance.enrollment_end = instance.enrollment_end or instance.end
try:
try:
discovery_course_run = get_related_discovery_course_run(instance)
response = api.create_course_rerun_in_studio(instance, discovery_course_run)
except ObjectDoesNotExist:
response = api.create_course_run_in_studio(instance)
instance.lms_course_id = response['id']
instance.save()
except SlumberBaseException as ex:
logger.exception('Failed to create course run [%d] on Studio: %s', instance.id, ex.content)
raise
try:
api.update_course_run_image_in_studio(instance)
except SlumberBaseException as ex:
logger.exception(
'Failed to update Studio image for course run [%s]: %s', instance.lms_course_id, ex.content
)
logger.info('Completed creation of course run [%s] on Studio.', instance.lms_course_id)
import logging
import math
from opaque_keys.edx.keys import CourseKey
from course_discovery.apps.core.utils import serialize_datetime
logger = logging.getLogger(__name__)
class StudioAPI:
def __init__(self, api_client):
self._api = api_client
@classmethod
def _get_next_run(cls, root, suffix, existing_runs):
candidate = root + suffix
if candidate in existing_runs:
# If our candidate is an existing run, use the next letter in the alphabet as the
# run suffix (e.g. 1T2017, 1T2017a, 1T2017b, ...).
suffix = chr(ord(suffix) + 1) if suffix else 'a'
return cls._get_next_run(root, suffix, existing_runs)
return candidate
@classmethod
def calculate_course_run_key_run_value(cls, course_run):
start = course_run.start
trimester = math.ceil(start.month / 4.)
run = '{trimester}T{year}'.format(trimester=trimester, year=start.year)
try:
discovery_course = course_run.course.discovery_counterpart
except: # pylint:disable=bare-except
logger.exception('Failed to get Discovery counterpart for Publisher course [%d]', course_run.course.id)
return run
related_course_runs = discovery_course.course_runs.values_list('key', flat=True)
related_course_runs = [CourseKey.from_string(key).run for key in related_course_runs]
return cls._get_next_run(run, '', related_course_runs)
@classmethod
def generate_data_for_studio_api(cls, publisher_course_run):
course = publisher_course_run.course
course_team_admin = course.course_team_admin
team = []
if course_team_admin:
team = [
{
'user': course_team_admin.username,
'role': 'instructor',
},
]
return {
'title': publisher_course_run.title_override or course.title,
'org': course.organizations.first().key,
'number': course.number,
'run': cls.calculate_course_run_key_run_value(publisher_course_run),
'schedule': {
'start': serialize_datetime(publisher_course_run.start),
'end': serialize_datetime(publisher_course_run.end),
'enrollment_start': serialize_datetime(publisher_course_run.enrollment_start),
'enrollment_end': serialize_datetime(publisher_course_run.enrollment_end),
},
'team': team,
}
def create_course_rerun_in_studio(self, publisher_course_run, discovery_course_run):
data = self.generate_data_for_studio_api(publisher_course_run)
return self._api.course_runs(discovery_course_run.key).rerun.post(data)
def create_course_run_in_studio(self, publisher_course_run):
data = self.generate_data_for_studio_api(publisher_course_run)
return self._api.course_runs.post(data)
def update_course_run_image_in_studio(self, publisher_course_run):
files = {'card_image': publisher_course_run.course.image}
return self._api.course_runs(publisher_course_run.lms_course_id).images.post(files=files)
...@@ -28,6 +28,7 @@ class CourseFactory(factory.DjangoModelFactory): ...@@ -28,6 +28,7 @@ class CourseFactory(factory.DjangoModelFactory):
syllabus = FuzzyText() syllabus = FuzzyText()
learner_testimonial = FuzzyText() learner_testimonial = FuzzyText()
level_type = factory.SubFactory(factories.LevelTypeFactory) level_type = factory.SubFactory(factories.LevelTypeFactory)
image = factory.django.ImageField()
primary_subject = factory.SubFactory(factories.SubjectFactory) primary_subject = factory.SubFactory(factories.SubjectFactory)
secondary_subject = factory.SubFactory(factories.SubjectFactory) secondary_subject = factory.SubFactory(factories.SubjectFactory)
......
...@@ -246,7 +246,9 @@ class PublisherCustomCourseFormTests(TestCase): ...@@ -246,7 +246,9 @@ class PublisherCustomCourseFormTests(TestCase):
Verify that course_title is properly escaped and saved in database while Verify that course_title is properly escaped and saved in database while
updating the course updating the course
""" """
course, course_admin = self.setup_course(title='test_course') course, course_admin = self.setup_course(image=None)
assert course.title != 'áçã'
organization = course.organizations.first().id organization = course.organizations.first().id
course_from_data = { course_from_data = {
'title': 'áçã', 'title': 'áçã',
...@@ -258,6 +260,7 @@ class PublisherCustomCourseFormTests(TestCase): ...@@ -258,6 +260,7 @@ class PublisherCustomCourseFormTests(TestCase):
**{'data': course_from_data, 'instance': course, 'user': course_admin, **{'data': course_from_data, 'instance': course, 'user': course_admin,
'organization': organization} 'organization': organization}
) )
self.assertTrue(course_form.is_valid()) assert course_form.is_valid()
course_updated_data = course_form.save() course_form.save()
self.assertTrue(course_updated_data.title, 'áçã') course.refresh_from_db()
assert course.title == 'áçã'
...@@ -43,27 +43,24 @@ class CourseRunTests(TestCase): ...@@ -43,27 +43,24 @@ class CourseRunTests(TestCase):
self.assertIsNone(self.course_run.created_by) self.assertIsNone(self.course_run.created_by)
user = UserFactory() user = UserFactory()
history_object = self.course_run.history.first() history_object = self.course_run.history.order_by('history_date').first()
history_object.history_user = user history_object.history_user = user
history_object.save() history_object.save()
self.assertEqual(self.course_run.created_by, user.get_full_name()) assert self.course_run.created_by == user.get_full_name()
def test_studio_url(self): def test_studio_url(self):
""" Verify that property returns studio url. """ assert self.course_run.studio_url is None
self.assertFalse(self.course_run.studio_url)
# save the lms course id and save the organization.
self.course_run.lms_course_id = 'test' self.course_run.lms_course_id = 'test'
self.course_run.save() self.course_run.save()
organization = OrganizationFactory() organization = OrganizationFactory()
self.course_run.course.organizations.add(organization) self.course_run.course.organizations.add(organization)
self.assertEqual(self.course_run.course.partner, organization.partner) assert self.course_run.course.partner == organization.partner
self.assertEqual( actual = '{url}/course/{id}'.format(url=self.course_run.course.partner.studio_url.strip('/'),
'{url}/course/{id}'.format(url=self.course_run.course.partner.studio_url, id='test'), id=self.course_run.lms_course_id)
self.course_run.studio_url assert actual == self.course_run.studio_url
)
def test_has_valid_staff(self): def test_has_valid_staff(self):
""" Verify that property returns True if course-run must have a staff member """ Verify that property returns True if course-run must have a staff member
...@@ -331,24 +328,19 @@ class CourseTests(TestCase): ...@@ -331,24 +328,19 @@ class CourseTests(TestCase):
self.assertEqual(self.user1, self.course2.publisher) self.assertEqual(self.user1, self.course2.publisher)
def test_course_image_url(self): def test_course_image_url(self):
""" Verify that the property returns the course image url. """ course = factories.CourseFactory(image=None)
self.assertIsNone(self.course.course_image_url) assert course.course_image_url is None
# Create a published course-run with card_image_url. course_run = factories.CourseRunFactory(course=course)
course_run = factories.CourseRunFactory(course=self.course)
factories.CourseRunStateFactory(course_run=course_run, name=CourseRunStateChoices.Published) factories.CourseRunStateFactory(course_run=course_run, name=CourseRunStateChoices.Published)
course_run.card_image_url = 'http://example.com/test.jpg' course_run.card_image_url = 'http://example.com/test.jpg'
course_run.save() course_run.save()
assert course.course_image_url == course_run.card_image_url
# Verify that property returns card_image_url of course-run.
self.assertEqual(self.course.course_image_url, course_run.card_image_url)
# Create a course image. # Create a course image.
self.course.image = make_image_file('test_banner1.jpg') course.image = make_image_file('test_banner1.jpg')
self.course.save() course.save()
assert course.course_image_url == course.image.url
# Verify that property returns course image field url.
self.assertEqual(self.course.course_image_url, self.course.image.url)
def test_short_description_override(self): def test_short_description_override(self):
""" Verify that the property returns the short_description. """ """ Verify that the property returns the short_description. """
......
import datetime
import json
import mock
import pytest
import responses
from slumber.exceptions import HttpServerError
from waffle.testutils import override_switch
from course_discovery.apps.core.models import Partner
from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory as DiscoveryCourseRunFactory
from course_discovery.apps.course_metadata.tests.factories import OrganizationFactory
from course_discovery.apps.publisher.studio_api_utils import StudioAPI
from course_discovery.apps.publisher.tests.factories import CourseRunFactory
@pytest.mark.django_db
class TestSignals:
@override_switch('enable_publisher_create_course_run_in_studio', active=True)
def test_create_course_run_in_studio_without_partner(self):
with mock.patch('course_discovery.apps.publisher.signals.logger.error') as mock_logger:
publisher_course_run = CourseRunFactory(course__organizations=[])
assert publisher_course_run.course.partner is None
mock_logger.assert_called_with(
'Failed to publish course run [%d] to Studio. Related course [%d] has no associated Partner.',
publisher_course_run.id,
publisher_course_run.course.id
)
@responses.activate
@mock.patch.object(Partner, 'access_token', return_value='JWT fake')
@override_switch('enable_publisher_create_course_run_in_studio', active=True)
def test_create_course_run_in_studio(self, mock_access_token): # pylint: disable=unused-argument
organization = OrganizationFactory()
partner = organization.partner
start = datetime.datetime.utcnow()
run = StudioAPI.calculate_course_run_key_run_value(CourseRunFactory.build(start=start))
course_run_key = 'course-v1:TestX+Testing101x+' + run
body = {'id': course_run_key}
studio_url_root = partner.studio_url.strip('/')
url = '{}/api/v1/course_runs/'.format(studio_url_root)
responses.add(responses.POST, url, json=body, status=200)
body = {'card_image': 'https://example.com/image.jpg'}
url = '{root}/api/v1/course_runs/{course_run_key}/images/'.format(
root=studio_url_root,
course_run_key=course_run_key
)
responses.add(responses.POST, url, json=body, status=200)
publisher_course_run = CourseRunFactory(start=start, lms_course_id=None, course__organizations=[organization])
# We refresh because the signal should update the instance with the course run key from Studio
publisher_course_run.refresh_from_db()
assert len(responses.calls) == 2
assert publisher_course_run.lms_course_id == course_run_key
@responses.activate
@mock.patch.object(Partner, 'access_token', return_value='JWT fake')
@override_switch('enable_publisher_create_course_run_in_studio', active=True)
def test_create_course_run_in_studio_as_rerun(self, mock_access_token): # pylint: disable=unused-argument
number = 'TestX'
organization = OrganizationFactory()
partner = organization.partner
course_key = '{org}+{number}'.format(org=organization.key, number=number)
discovery_course_run = DiscoveryCourseRunFactory(course__partner=partner, course__key=course_key)
start = datetime.datetime.utcnow()
run = StudioAPI.calculate_course_run_key_run_value(CourseRunFactory.build(start=start))
course_run_key = 'course-v1:TestX+Testing101x+' + run
body = {'id': course_run_key}
studio_url_root = partner.studio_url.strip('/')
url = '{root}/api/v1/course_runs/{course_run_key}/rerun/'.format(
root=studio_url_root,
course_run_key=discovery_course_run.key
)
responses.add(responses.POST, url, json=body, status=200)
body = {'card_image': 'https://example.com/image.jpg'}
url = '{root}/api/v1/course_runs/{course_run_key}/images/'.format(
root=studio_url_root,
course_run_key=course_run_key
)
responses.add(responses.POST, url, json=body, status=200)
publisher_course_run = CourseRunFactory(
start=start,
lms_course_id=None,
course__organizations=[organization],
course__number=number
)
# We refresh because the signal should update the instance with the course run key from Studio
publisher_course_run.refresh_from_db()
assert len(responses.calls) == 2
assert publisher_course_run.lms_course_id == course_run_key
@responses.activate
@mock.patch.object(Partner, 'access_token', return_value='JWT fake')
@override_switch('enable_publisher_create_course_run_in_studio', active=True)
def test_create_course_run_in_studio_with_image_failure(self, mock_access_token): # pylint: disable=unused-argument
organization = OrganizationFactory()
partner = organization.partner
start = datetime.datetime.utcnow()
run = StudioAPI.calculate_course_run_key_run_value(CourseRunFactory.build(start=start))
course_run_key = 'course-v1:TestX+Testing101x+' + run
body = {'id': course_run_key}
studio_url_root = partner.studio_url.strip('/')
url = '{}/api/v1/course_runs/'.format(studio_url_root)
responses.add(responses.POST, url, json=body, status=200)
body = {'error': 'Server error'}
url = '{root}/api/v1/course_runs/{course_run_key}/images/'.format(
root=studio_url_root,
course_run_key=course_run_key
)
responses.add(responses.POST, url, json=body, status=500)
with mock.patch('course_discovery.apps.publisher.signals.logger.exception') as mock_logger:
publisher_course_run = CourseRunFactory(
start=start,
lms_course_id=None,
course__organizations=[organization]
)
assert len(responses.calls) == 2
assert publisher_course_run.lms_course_id == course_run_key
mock_logger.assert_called_with(
'Failed to update Studio image for course run [%s]: %s', course_run_key, json.dumps(body).encode('utf8')
)
@responses.activate
@mock.patch.object(Partner, 'access_token', return_value='JWT fake')
@override_switch('enable_publisher_create_course_run_in_studio', active=True)
def test_create_course_run_in_studio_with_api_failure(self, mock_access_token): # pylint: disable=unused-argument
organization = OrganizationFactory()
partner = organization.partner
body = {'error': 'Server error'}
studio_url_root = partner.studio_url.strip('/')
url = '{}/api/v1/course_runs/'.format(studio_url_root)
responses.add(responses.POST, url, json=body, status=500)
with mock.patch('course_discovery.apps.publisher.signals.logger.exception') as mock_logger:
with pytest.raises(HttpServerError):
publisher_course_run = CourseRunFactory(lms_course_id=None, course__organizations=[organization])
assert len(responses.calls) == 1
assert publisher_course_run.lms_course_id is None
mock_logger.assert_called_with(
'Failed to create course run [%d] on Studio: %s',
publisher_course_run.id,
json.dumps(body).encode('utf8')
)
import datetime
from itertools import product
import pytest
from course_discovery.apps.core.utils import serialize_datetime
from course_discovery.apps.course_metadata.tests.factories import CourseFactory as DiscoveryCourseFactory
from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory as DiscoveryCourseRunFactory
from course_discovery.apps.course_metadata.tests.factories import OrganizationFactory
from course_discovery.apps.publisher.choices import PublisherUserRole
from course_discovery.apps.publisher.studio_api_utils import StudioAPI
from course_discovery.apps.publisher.tests.factories import CourseRunFactory, CourseUserRoleFactory
test_data = list(product(range(1, 5), ['1T2017'])) + list(product(range(5, 8), ['2T2017'])) + \
list(product(range(9, 13), ['3T2017']))
@pytest.mark.django_db
@pytest.mark.parametrize('month,expected', test_data)
def test_calculate_course_run_key_run_value(month, expected):
course_run = CourseRunFactory(start=datetime.datetime(2017, month, 1))
assert StudioAPI.calculate_course_run_key_run_value(course_run) == expected
@pytest.mark.django_db
def test_calculate_course_run_key_run_value_with_multiple_runs_per_trimester():
number = 'TestX'
organization = OrganizationFactory()
partner = organization.partner
course_key = '{org}+{number}'.format(org=organization.key, number=number)
discovery_course = DiscoveryCourseFactory(partner=partner, key=course_key)
DiscoveryCourseRunFactory(key='course-v1:TestX+Testing101x+1T2017', course=discovery_course)
course_run = CourseRunFactory(
start=datetime.datetime(2017, 2, 1),
lms_course_id=None,
course__organizations=[organization],
course__number=number
)
assert StudioAPI.calculate_course_run_key_run_value(course_run) == '1T2017a'
DiscoveryCourseRunFactory(key='course-v1:TestX+Testing101x+1T2017a', course=discovery_course)
assert StudioAPI.calculate_course_run_key_run_value(course_run) == '1T2017b'
@pytest.mark.django_db
@pytest.mark.parametrize('with_team', (True, False,))
def test_generate_data_for_studio_api(with_team):
course_run = CourseRunFactory(course__organizations=[OrganizationFactory()])
course = course_run.course
team = []
if with_team:
role = CourseUserRoleFactory(course=course, role=PublisherUserRole.CourseTeam)
team = [
{
'user': role.user.username,
'role': 'instructor',
},
]
expected = {
'title': course_run.title_override or course.title,
'org': course.organizations.first().key,
'number': course.number,
'run': StudioAPI.calculate_course_run_key_run_value(course_run),
'schedule': {
'start': serialize_datetime(course_run.start),
'end': serialize_datetime(course_run.end),
'enrollment_start': serialize_datetime(course_run.enrollment_start),
'enrollment_end': serialize_datetime(course_run.enrollment_end),
},
'team': team,
}
assert StudioAPI.generate_data_for_studio_api(course_run) == expected
...@@ -1973,7 +1973,7 @@ class CourseDetailViewTests(TestCase): ...@@ -1973,7 +1973,7 @@ class CourseDetailViewTests(TestCase):
def setUp(self): def setUp(self):
super(CourseDetailViewTests, self).setUp() super(CourseDetailViewTests, self).setUp()
self.organization_extension = factories.OrganizationExtensionFactory() self.organization_extension = factories.OrganizationExtensionFactory()
self.course = factories.CourseFactory(organizations=[self.organization_extension.organization]) self.course = factories.CourseFactory(organizations=[self.organization_extension.organization], image=None)
self.user = UserFactory() self.user = UserFactory()
self.client.login(username=self.user.username, password=USER_PASSWORD) self.client.login(username=self.user.username, password=USER_PASSWORD)
...@@ -2395,7 +2395,7 @@ class CourseEditViewTests(SiteMixin, TestCase): ...@@ -2395,7 +2395,7 @@ class CourseEditViewTests(SiteMixin, TestCase):
def setUp(self): def setUp(self):
super(CourseEditViewTests, self).setUp() super(CourseEditViewTests, self).setUp()
self.organization_extension = factories.OrganizationExtensionFactory() self.organization_extension = factories.OrganizationExtensionFactory()
self.course = factories.CourseFactory(organizations=[self.organization_extension.organization]) self.course = factories.CourseFactory(organizations=[self.organization_extension.organization], image=None)
self.user = UserFactory() self.user = UserFactory()
self.course_team_user = UserFactory() self.course_team_user = UserFactory()
self.client.login(username=self.user.username, password=USER_PASSWORD) self.client.login(username=self.user.username, password=USER_PASSWORD)
......
...@@ -162,13 +162,14 @@ class CourseRunWrapperTests(TestCase): ...@@ -162,13 +162,14 @@ class CourseRunWrapperTests(TestCase):
self.assertEqual(self.wrapped_course_run.course_team_admin, self.course.course_team_admin) self.assertEqual(self.wrapped_course_run.course_team_admin, self.course.course_team_admin)
def test_course_image_url(self): def test_course_image_url(self):
""" Verify that the wrapper return the course image url. """ course_run = factories.CourseRunFactory(course__image=None)
self.assertIsNone(self.wrapped_course_run.course_image_url) wrapped_course_run = CourseRunWrapper(course_run)
assert wrapped_course_run.course_image_url is None
self.course.image = make_image_file('test_banner1.jpg')
self.course.save()
self.assertEqual(self.wrapped_course_run.course_image_url, self.course.image.url) course = course_run.course
course.image = make_image_file('test_banner1.jpg')
course.save()
assert wrapped_course_run.course_image_url == course.image.url
def test_course_staff(self): def test_course_staff(self):
"""Verify that the wrapper return staff list.""" """Verify that the wrapper return staff list."""
......
...@@ -13,6 +13,6 @@ pep8==1.7.0 ...@@ -13,6 +13,6 @@ pep8==1.7.0
pytest==3.0.6 pytest==3.0.6
pytest-django==3.1.2 pytest-django==3.1.2
pytest-django-ordering==1.0.1 pytest-django-ordering==1.0.1
responses==0.5.1 responses==0.7.0
selenium==3.4.0 selenium==3.4.0
testfixtures==4.13.1 testfixtures==4.13.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